Initial commit
This commit is contained in:
37
src/components/ProtectedRoute.jsx
Normal file
37
src/components/ProtectedRoute.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { useAuth } from '@/lib/AuthContext';
|
||||
import UserNotRegisteredError from '@/components/UserNotRegisteredError';
|
||||
|
||||
const DefaultFallback = () => (
|
||||
<div className="fixed inset-0 flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-4 border-slate-200 border-t-slate-800 rounded-full animate-spin"></div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default function ProtectedRoute({ fallback = <DefaultFallback />, unauthenticatedElement }) {
|
||||
const { isAuthenticated, isLoadingAuth, authChecked, authError, checkUserAuth } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!authChecked && !isLoadingAuth) {
|
||||
checkUserAuth();
|
||||
}
|
||||
}, [authChecked, isLoadingAuth, checkUserAuth]);
|
||||
|
||||
if (isLoadingAuth || !authChecked) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (authError) {
|
||||
if (authError.type === 'user_not_registered') {
|
||||
return <UserNotRegisteredError />;
|
||||
}
|
||||
return unauthenticatedElement;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return unauthenticatedElement;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
33
src/components/ScrollToTop.jsx
Normal file
33
src/components/ScrollToTop.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useEffect } from "react";
|
||||
import { useLocation, useNavigationType } from "react-router-dom";
|
||||
|
||||
const getHashId = (hash) => {
|
||||
const rawId = hash.slice(1);
|
||||
|
||||
try {
|
||||
return decodeURIComponent(rawId);
|
||||
} catch {
|
||||
return rawId;
|
||||
}
|
||||
};
|
||||
|
||||
export default function ScrollToTop() {
|
||||
const { pathname, hash } = useLocation();
|
||||
const navigationType = useNavigationType();
|
||||
|
||||
useEffect(() => {
|
||||
if (navigationType === "POP") return;
|
||||
|
||||
if (hash) {
|
||||
const id = getHashId(hash);
|
||||
const timer = window.setTimeout(() => {
|
||||
document.getElementById(id)?.scrollIntoView({ behavior: "smooth" });
|
||||
}, 50);
|
||||
return () => window.clearTimeout(timer);
|
||||
}
|
||||
|
||||
window.scrollTo({ top: 0, left: 0, behavior: "instant" });
|
||||
}, [pathname, hash, navigationType]);
|
||||
|
||||
return null;
|
||||
}
|
||||
31
src/components/UserNotRegisteredError.jsx
Normal file
31
src/components/UserNotRegisteredError.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
|
||||
const UserNotRegisteredError = () => {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gradient-to-b from-white to-slate-50">
|
||||
<div className="max-w-md w-full p-8 bg-white rounded-lg shadow-lg border border-slate-100">
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 mb-6 rounded-full bg-orange-100">
|
||||
<svg className="w-8 h-8 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-slate-900 mb-4">Access Restricted</h1>
|
||||
<p className="text-slate-600 mb-8">
|
||||
You are not registered to use this application. Please contact the app administrator to request access.
|
||||
</p>
|
||||
<div className="p-4 bg-slate-50 rounded-md text-sm text-slate-600">
|
||||
<p>If you believe this is an error, you can:</p>
|
||||
<ul className="list-disc list-inside mt-2 space-y-1">
|
||||
<li>Verify you are logged in with the correct account</li>
|
||||
<li>Contact the app administrator for access</li>
|
||||
<li>Try logging out and back in again</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserNotRegisteredError;
|
||||
150
src/components/ai-assistant/AssistantMessage.jsx
Normal file
150
src/components/ai-assistant/AssistantMessage.jsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Renders the light markup the capabilities emit — `**bold**`, bullets,
|
||||
* numbered items, `_asides_`, and ✓/⚠ check lines.
|
||||
*
|
||||
* A full markdown renderer is available in the project, but this content is
|
||||
* produced locally in a known shape, and parsing five constructs keeps the
|
||||
* assistant's typography inside the design system rather than inheriting a
|
||||
* library's defaults.
|
||||
*/
|
||||
|
||||
const BOLD = /\*\*(.+?)\*\*/g;
|
||||
|
||||
function inline(line, keyPrefix) {
|
||||
const parts = [];
|
||||
let last = 0;
|
||||
let match;
|
||||
BOLD.lastIndex = 0;
|
||||
|
||||
while ((match = BOLD.exec(line)) !== null) {
|
||||
if (match.index > last) parts.push(line.slice(last, match.index));
|
||||
parts.push(
|
||||
<strong key={`${keyPrefix}-${match.index}`} className="font-semibold text-ink-1">
|
||||
{match[1]}
|
||||
</strong>
|
||||
);
|
||||
last = match.index + match[0].length;
|
||||
}
|
||||
if (last < line.length) parts.push(line.slice(last));
|
||||
return parts.length ? parts : line;
|
||||
}
|
||||
|
||||
export function RichText({ text, className }) {
|
||||
const lines = React.useMemo(() => String(text).split('\n'), [text]);
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-1.5 text-body-sm text-ink-2', className)}>
|
||||
{lines.map((line, i) => {
|
||||
if (!line.trim()) return <div key={i} className="h-1.5" aria-hidden="true" />;
|
||||
|
||||
/* Aside — a caveat the capabilities wrap in underscores. */
|
||||
if (line.startsWith('_') && line.endsWith('_')) {
|
||||
return (
|
||||
<p key={i} className="border-l-2 border-border pl-2.5 text-caption italic text-ink-3 leading-relaxed">
|
||||
{inline(line.slice(1, -1), i)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/* Bullet. */
|
||||
if (line.startsWith('• ')) {
|
||||
return (
|
||||
<div key={i} className="flex gap-2 leading-relaxed">
|
||||
<span className="text-krow-blue shrink-0" aria-hidden="true">•</span>
|
||||
<span className="min-w-0">{inline(line.slice(2), i)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* Indented continuation of a numbered item. */
|
||||
if (line.startsWith(' ')) {
|
||||
return (
|
||||
<p key={i} className="pl-6 text-caption text-ink-3 leading-relaxed">
|
||||
{inline(line.trim(), i)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/* Numbered item. */
|
||||
const numbered = line.match(/^(\d+)\.\s+(.*)$/);
|
||||
if (numbered) {
|
||||
return (
|
||||
<div key={i} className="flex gap-2.5 leading-relaxed">
|
||||
<span className="grid place-items-center w-4 h-4 mt-0.5 shrink-0 rounded-full bg-krow-blue-tint text-[10px] font-bold text-krow-blue">
|
||||
{numbered[1]}
|
||||
</span>
|
||||
<span className="min-w-0">{inline(numbered[2], i)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* Health-check line. */
|
||||
if (line.startsWith('✓ ') || line.startsWith('⚠ ')) {
|
||||
const ok = line.startsWith('✓');
|
||||
return (
|
||||
<div key={i} className="flex gap-2 leading-relaxed">
|
||||
<span className={cn('shrink-0 font-semibold', ok ? 'text-success' : 'text-warning')} aria-hidden="true">
|
||||
{ok ? '✓' : '⚠'}
|
||||
</span>
|
||||
<span className="min-w-0">{inline(line.slice(2), i)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <p key={i} className="leading-relaxed">{inline(line, i)}</p>;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The three-dot indicator shown before the first delta arrives. */
|
||||
export function ThinkingIndicator() {
|
||||
return (
|
||||
<div className="inline-flex items-center gap-1 rounded-xl rounded-tl-sm border border-border bg-surface px-3 py-2.5 shadow-xs">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="w-1.5 h-1.5 rounded-full bg-ink-4 animate-typing"
|
||||
style={{ animationDelay: `${i * 0.18}s` }}
|
||||
/>
|
||||
))}
|
||||
<span className="sr-only">Assistant is thinking</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One turn in the conversation. The user's own words sit in a filled bubble; the
|
||||
* assistant's answer runs full width, because these answers are structured
|
||||
* multi-paragraph reports and a chat bubble would waste a third of a 380px
|
||||
* column on padding.
|
||||
*/
|
||||
export function Message({ role, text, streaming, stopped }) {
|
||||
if (role === 'user') {
|
||||
return (
|
||||
<div className="flex justify-end animate-slide-up">
|
||||
<div className="max-w-[85%] rounded-xl rounded-br-sm bg-krow-blue px-3 py-2 text-body-sm text-white">
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="animate-slide-up">
|
||||
<RichText text={text} />
|
||||
{streaming && (
|
||||
<span
|
||||
className="inline-block w-1.5 h-3.5 ml-0.5 -mb-0.5 bg-krow-blue animate-caret"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{stopped && (
|
||||
<p className="mt-1.5 text-caption italic text-ink-4">Stopped early.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
src/components/ai-assistant/AssistantPanel.jsx
Normal file
78
src/components/ai-assistant/AssistantPanel.jsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import * as React from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { cn } from '@/lib/utils';
|
||||
import KrowAssistant from './KrowAssistant';
|
||||
import { useAssistantPanel } from './AssistantPanelContext';
|
||||
|
||||
/** Panel width, plus the gutter separating it from the dashboard. */
|
||||
const PANEL_WIDTH = 380;
|
||||
const GUTTER = 24;
|
||||
|
||||
/**
|
||||
* AssistantPanel — the sliding column the layout renders.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Two things about the structure matter, and both were bugs before:
|
||||
*
|
||||
* 1. The clip lives *inside* the sticky element, not around it. An ancestor with
|
||||
* `overflow: hidden` becomes the nearest scroll container, which silently
|
||||
* disables `position: sticky` on everything beneath it — the panel computed
|
||||
* as sticky but never actually stuck.
|
||||
*
|
||||
* 2. The aside must stretch to the shell's full height. As a flex item under
|
||||
* `items-start` it collapsed to its own height, so its sticky context ended
|
||||
* partway down the page and the panel scrolled away, leaving empty space
|
||||
* beside a long dashboard.
|
||||
*
|
||||
* With both fixed the panel stays pinned for the whole scroll, and because the
|
||||
* assistant is a fixed-height frame, only its conversation scrolls.
|
||||
*/
|
||||
export function AssistantPanel({ stickyClassName, panelHeightClassName }) {
|
||||
const { context, isOpen, close } = useAssistantPanel();
|
||||
|
||||
return (
|
||||
<AnimatePresence initial={false}>
|
||||
{isOpen && context && (
|
||||
<motion.aside
|
||||
key="assistant"
|
||||
aria-label="Krow AI Assistant"
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={{ width: PANEL_WIDTH + GUTTER, opacity: 1 }}
|
||||
exit={{ width: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: [0.65, 0, 0.35, 1] }}
|
||||
/* No `overflow-hidden` here — see (1) above. `self-stretch` keeps the
|
||||
sticky context running the full height of the shell — see (2). */
|
||||
className="relative shrink-0 self-stretch"
|
||||
>
|
||||
<div className={cn('sticky', stickyClassName)}>
|
||||
{/* The clip: contains the slide-in and the fixed panel width while
|
||||
the track animates open, without breaking the sticky above it. */}
|
||||
<div className="overflow-hidden" style={{ width: PANEL_WIDTH + GUTTER }}>
|
||||
<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: [0.65, 0, 0.35, 1] }}
|
||||
>
|
||||
{/* 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}
|
||||
onClose={close}
|
||||
className={panelHeightClassName}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.aside>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
export default AssistantPanel;
|
||||
94
src/components/ai-assistant/AssistantPanelContext.jsx
Normal file
94
src/components/ai-assistant/AssistantPanelContext.jsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import * as React from 'react';
|
||||
import { resolveAssistantContext } from './placement';
|
||||
|
||||
/**
|
||||
* Open/closed state for the Krow AI Assistant.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* The assistant is closed by default. The dashboard is the primary experience;
|
||||
* the assistant is something you open when you want it.
|
||||
*/
|
||||
|
||||
const AssistantPanelContext = React.createContext(null);
|
||||
|
||||
const STORAGE_KEY = 'krow_assistant_open';
|
||||
|
||||
/**
|
||||
* Whether the panel was open before. Kept for the session so moving between
|
||||
* supported pages does not close a panel the user deliberately opened, while a
|
||||
* new session still starts clean on the dashboard.
|
||||
*/
|
||||
function readStoredOpen() {
|
||||
try {
|
||||
return sessionStorage.getItem(STORAGE_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function AssistantPanelProvider({ role, pathname, children }) {
|
||||
const [open, setOpen] = React.useState(readStoredOpen);
|
||||
|
||||
const context = React.useMemo(
|
||||
() => resolveAssistantContext(role, pathname),
|
||||
[role, pathname]
|
||||
);
|
||||
|
||||
const supported = Boolean(context);
|
||||
// A stored "open" must not force the panel onto a page that has no assistant.
|
||||
const isOpen = supported && open;
|
||||
|
||||
const persist = React.useCallback((next) => {
|
||||
setOpen(next);
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, next ? '1' : '0');
|
||||
} catch {
|
||||
// Private mode — the panel simply reverts to closed next session.
|
||||
}
|
||||
}, []);
|
||||
|
||||
/* Escape closes the panel, as it does for every other dismissible surface. */
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) return undefined;
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') persist(false);
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [isOpen, persist]);
|
||||
|
||||
const value = React.useMemo(() => ({
|
||||
context,
|
||||
supported,
|
||||
isOpen,
|
||||
open: () => persist(true),
|
||||
close: () => persist(false),
|
||||
toggle: () => persist(!isOpen),
|
||||
}), [context, supported, isOpen, persist]);
|
||||
|
||||
return (
|
||||
<AssistantPanelContext.Provider value={value}>
|
||||
{children}
|
||||
</AssistantPanelContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Panel state for triggers and the layout.
|
||||
*
|
||||
* Returns a safe inert value outside a provider, so a page rendering an
|
||||
* `AssistantTrigger` in isolation (a test, a storybook) does not crash.
|
||||
*/
|
||||
export function useAssistantPanel() {
|
||||
return React.useContext(AssistantPanelContext) ?? {
|
||||
context: null,
|
||||
supported: false,
|
||||
isOpen: false,
|
||||
open: () => {},
|
||||
close: () => {},
|
||||
toggle: () => {},
|
||||
};
|
||||
}
|
||||
72
src/components/ai-assistant/AssistantTrigger.jsx
Normal file
72
src/components/ai-assistant/AssistantTrigger.jsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { KROW_LOGO_URL } from '@/assets/brand';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useAssistantPanel } from './AssistantPanelContext';
|
||||
|
||||
/**
|
||||
* AssistantTrigger — the KROW mark used as a secondary action.
|
||||
*
|
||||
* This is not the application's branding: the navigation logo is untouched.
|
||||
* Here the mark is deliberately smaller and sits in a page's action row, where
|
||||
* it reads as "the intelligent thing this page can do" rather than as a chrome
|
||||
* element. Pages render it beside their own actions.
|
||||
*
|
||||
* It self-hides on pages with no assistant, so a page can render it
|
||||
* unconditionally and never needs to know the placement rules.
|
||||
*/
|
||||
export function AssistantTrigger({ className, size = 'default' }) {
|
||||
const { supported, isOpen, toggle, context } = useAssistantPanel();
|
||||
|
||||
if (!supported) return null;
|
||||
|
||||
const label = isOpen ? 'Close Krow AI' : `Ask Krow AI 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' };
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={250}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-label={label}
|
||||
aria-expanded={isOpen}
|
||||
className={cn(
|
||||
'group relative inline-flex items-center justify-center rounded-full border',
|
||||
'transition-all duration-base ease-out cursor-pointer',
|
||||
'outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/60 focus-visible:ring-offset-2',
|
||||
'hover:scale-[1.04] active:scale-100',
|
||||
heights[size],
|
||||
isOpen
|
||||
? 'border-krow-blue/40 bg-krow-blue-tint shadow-[0_0_0_3px_rgba(10,57,223,0.10)]'
|
||||
: 'border-border bg-surface shadow-xs hover:border-krow-blue/40 hover:bg-krow-blue-tint hover:shadow-[0_0_18px_-2px_rgba(10,57,223,0.35)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={KROW_LOGO_URL}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className={cn('w-auto object-contain', logoHeights[size])}
|
||||
/>
|
||||
|
||||
{/* A quiet indicator that this mark does something intelligent —
|
||||
enough signal without adding a competing icon or label. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'absolute -top-0.5 -right-0.5 block w-2 h-2 rounded-full ring-2 ring-white transition-colors duration-base',
|
||||
isOpen ? 'bg-krow-blue' : 'bg-krow-yellow group-hover:bg-krow-blue'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default AssistantTrigger;
|
||||
167
src/components/ai-assistant/KrowAssistant.jsx
Normal file
167
src/components/ai-assistant/KrowAssistant.jsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import * as React from 'react';
|
||||
import { RotateCcw, Sparkles, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Surface } from '@/components/ds/Surface';
|
||||
import { IconButton } from '@/components/ds/IconButton';
|
||||
import { Alert } from '@/components/ds/Alert';
|
||||
import { KROW_LOGO_URL } from '@/assets/brand';
|
||||
import { useAssistantFacts, useConversation } from './useAssistant';
|
||||
import { Message, ThinkingIndicator } from './AssistantMessage';
|
||||
import { PromptInput } from './PromptInput';
|
||||
|
||||
/**
|
||||
* Krow AI Assistant — the dashboard's contextual assistant.
|
||||
*
|
||||
* One component, reused on every supported page; only `context` differs.
|
||||
*
|
||||
* The layout is a fixed three-part frame — header, conversation, composer —
|
||||
* where only the conversation scrolls. That is what lets the panel stay pinned
|
||||
* beside a long dashboard without ever growing the page or losing its input.
|
||||
*
|
||||
* The empty state is deliberately a conversational entry point rather than a
|
||||
* menu: an icon, a question, and an input. Suggested chips fill the input rather
|
||||
* than firing an action, so the user always sees the question before it is asked
|
||||
* and can edit it — which is what makes this read as an assistant instead of a
|
||||
* second dashboard.
|
||||
*
|
||||
* Independent of Owliver. It talks to a provider (see provider.js) which can
|
||||
* later be pointed at an external service without changing anything here.
|
||||
*/
|
||||
export default function KrowAssistant({ context, className, onClose }) {
|
||||
const facts = useAssistantFacts({ needsWorkerProfile: context.needsWorkerProfile });
|
||||
const { messages, pending, error, busy, send, stop, reset } = useConversation({
|
||||
contextId: context.id,
|
||||
facts,
|
||||
});
|
||||
|
||||
const [input, setInput] = React.useState('');
|
||||
const scrollRef = React.useRef(null);
|
||||
const isEmpty = messages.length === 0 && !pending;
|
||||
|
||||
/* Follow the newest content as it streams. */
|
||||
React.useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [messages, pending]);
|
||||
|
||||
/**
|
||||
* A chip's prompt resolves to its capability so the answer is precise, while
|
||||
* the same question typed by hand still resolves through keyword matching.
|
||||
*/
|
||||
const submit = (text) => {
|
||||
const question = text.trim();
|
||||
if (!question || busy) return;
|
||||
const chip = context.chips.find((c) => c.prompt === question);
|
||||
send({ question, capability: chip?.capability ?? null });
|
||||
setInput('');
|
||||
};
|
||||
|
||||
return (
|
||||
<Surface
|
||||
variant="glass"
|
||||
radius="xl"
|
||||
padding="none"
|
||||
elevation="sm"
|
||||
className={cn('flex flex-col overflow-hidden', className)}
|
||||
aria-label="Krow AI Assistant"
|
||||
>
|
||||
{/* Header — fixed */}
|
||||
<div className="flex shrink-0 items-center gap-2.5 border-b border-white/60 px-4 py-3">
|
||||
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-krow-blue text-white shadow-brand">
|
||||
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||
</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>
|
||||
<p className="truncate text-caption leading-tight text-ink-3">{context.page}</p>
|
||||
</div>
|
||||
|
||||
{messages.length > 0 && (
|
||||
<IconButton icon={RotateCcw} label="New conversation" variant="ghost" size="sm" onClick={reset} />
|
||||
)}
|
||||
{onClose && (
|
||||
<IconButton icon={X} label="Close assistant" variant="ghost" size="sm" onClick={onClose} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Conversation — the only scrolling region */}
|
||||
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto" aria-live="polite">
|
||||
{isEmpty ? (
|
||||
/* Landing: an invitation to ask, not a list of features. */
|
||||
<div className="flex h-full flex-col items-center justify-center px-6 py-8 text-center animate-fade-in">
|
||||
<span className="relative mb-5 grid h-14 w-14 place-items-center rounded-2xl bg-white shadow-brand ring-1 ring-krow-blue/10">
|
||||
<img src={KROW_LOGO_URL} alt="" aria-hidden="true" className="h-5 w-auto object-contain" />
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -right-1 -top-1 grid h-5 w-5 place-items-center rounded-full bg-krow-blue text-white shadow-sm"
|
||||
>
|
||||
<Sparkles className="h-2.5 w-2.5" />
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<h3 className="font-heading text-title text-ink-1">
|
||||
What would you like to analyze today?
|
||||
</h3>
|
||||
<p className="mt-2 max-w-[19rem] text-body-sm leading-relaxed text-ink-3">
|
||||
{context.tagline}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 px-4 py-4">
|
||||
{messages.map((message, i) => (
|
||||
<Message key={i} role={message.role} text={message.text} stopped={message.stopped} />
|
||||
))}
|
||||
|
||||
{pending?.thinking && <ThinkingIndicator />}
|
||||
{pending && !pending.thinking && <Message role="assistant" text={pending.text} streaming />}
|
||||
|
||||
{error && <Alert tone="destructive">{error}</Alert>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Suggested prompts + composer — fixed */}
|
||||
<div className="shrink-0 space-y-2.5 border-t border-white/60 px-3 pb-3 pt-2.5">
|
||||
{!busy && (
|
||||
/* On the landing there is room to show every suggestion, so they wrap.
|
||||
Once a conversation is underway the space belongs to the thread, so
|
||||
they collapse to a single scrollable row. */
|
||||
<div
|
||||
className={cn(
|
||||
'flex gap-1.5',
|
||||
isEmpty ? 'flex-wrap justify-center' : 'overflow-x-auto no-scrollbar'
|
||||
)}
|
||||
>
|
||||
{context.chips.map((chip) => (
|
||||
<button
|
||||
key={chip.label}
|
||||
type="button"
|
||||
/* Fills the input — never sends, never navigates. */
|
||||
onClick={() => setInput(chip.prompt)}
|
||||
className="shrink-0 rounded-full border border-border bg-surface px-2.5 py-1 text-caption
|
||||
text-ink-3 transition-colors duration-base hover:border-krow-blue/40
|
||||
hover:bg-krow-blue-tint hover:text-krow-blue focus-ring"
|
||||
>
|
||||
{chip.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PromptInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
onSubmit={submit}
|
||||
onStop={stop}
|
||||
busy={busy}
|
||||
placeholders={context.placeholders}
|
||||
size={isEmpty ? 'large' : 'default'}
|
||||
/>
|
||||
|
||||
<p className="px-1 text-[10px] leading-tight text-ink-4">
|
||||
Krow AI reads this page's data. Check anything you act on.
|
||||
</p>
|
||||
</div>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
128
src/components/ai-assistant/PromptInput.jsx
Normal file
128
src/components/ai-assistant/PromptInput.jsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import * as React from 'react';
|
||||
import { ArrowUp, Square } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* PromptInput — the assistant's primary action.
|
||||
*
|
||||
* A textarea rather than an input, because these prompts are sentences: it grows
|
||||
* with the content up to a cap, keeps Enter as send, and leaves Shift+Enter for a
|
||||
* 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.
|
||||
*/
|
||||
export function PromptInput({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onStop,
|
||||
busy = false,
|
||||
placeholders = ['Ask anything…'],
|
||||
/** Larger treatment for the landing state, where the input is the focus. */
|
||||
size = 'default',
|
||||
autoFocus = false,
|
||||
className,
|
||||
}) {
|
||||
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(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = 'auto';
|
||||
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. */
|
||||
React.useEffect(() => {
|
||||
if (value || focused || placeholders.length < 2) return undefined;
|
||||
const timer = setInterval(
|
||||
() => setPlaceholderIndex((i) => (i + 1) % placeholders.length),
|
||||
3800
|
||||
);
|
||||
return () => clearInterval(timer);
|
||||
}, [value, focused, placeholders.length]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (autoFocus) textareaRef.current?.focus();
|
||||
}, [autoFocus]);
|
||||
|
||||
const submit = () => {
|
||||
if (!value.trim() || busy) return;
|
||||
onSubmit(value);
|
||||
};
|
||||
|
||||
const onKeyDown = (event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}
|
||||
};
|
||||
|
||||
const large = size === 'large';
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); submit(); }}
|
||||
className={cn(
|
||||
'relative flex items-end gap-2 rounded-2xl border bg-surface transition-all duration-base ease-out',
|
||||
large ? 'px-4 py-3' : 'px-3 py-2.5',
|
||||
focused
|
||||
? 'border-krow-blue/50 shadow-[0_8px_28px_-8px_rgba(10,57,223,0.28)] ring-2 ring-krow-blue/15'
|
||||
: 'border-border shadow-md hover:border-krow-blue/30',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
placeholder={placeholders[placeholderIndex]}
|
||||
aria-label="Ask Krow AI"
|
||||
className={cn(
|
||||
'flex-1 min-w-0 resize-none bg-transparent leading-relaxed text-ink-1',
|
||||
'placeholder:text-ink-4 outline-none no-scrollbar',
|
||||
large ? 'text-body py-1' : 'text-body-sm py-0.5'
|
||||
)}
|
||||
/>
|
||||
|
||||
{busy ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
aria-label="Stop generating"
|
||||
className={cn(
|
||||
'grid shrink-0 place-items-center rounded-full border border-border bg-surface text-ink-2',
|
||||
'transition-colors duration-base hover:bg-surface-sunken focus-ring',
|
||||
large ? 'w-9 h-9' : 'w-8 h-8'
|
||||
)}
|
||||
>
|
||||
<Square className={large ? 'w-3.5 h-3.5' : 'w-3 h-3'} aria-hidden="true" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!value.trim()}
|
||||
aria-label="Send message"
|
||||
className={cn(
|
||||
'grid shrink-0 place-items-center rounded-full bg-krow-blue text-white',
|
||||
'transition-all duration-base ease-out hover:bg-krow-blue-dark',
|
||||
'disabled:bg-surface-sunken disabled:text-ink-4 focus-ring',
|
||||
large ? 'w-9 h-9' : 'w-8 h-8'
|
||||
)}
|
||||
>
|
||||
<ArrowUp className={large ? 'w-4 h-4' : 'w-3.5 h-3.5'} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
277
src/components/ai-assistant/capabilities/admin.js
Normal file
277
src/components/ai-assistant/capabilities/admin.js
Normal file
@@ -0,0 +1,277 @@
|
||||
import { AlertTriangle, Eye, Gauge, Lightbulb, ScrollText, TrendingUp } from 'lucide-react';
|
||||
import { bullets, compose, heading, numbered, plural, verb } from '../insights';
|
||||
|
||||
/**
|
||||
* Admin capabilities — Control Center, Candidates Analysis, Activity.
|
||||
*
|
||||
* Admin answers are platform-wide rather than pipeline-specific: data integrity,
|
||||
* talent supply, audit reconciliation.
|
||||
*/
|
||||
|
||||
/* ── Control Center ─────────────────────────────────────────────────────── */
|
||||
|
||||
const platformHealth = (f) => {
|
||||
const checks = [
|
||||
{
|
||||
label: 'Data integrity',
|
||||
ok: f.applications.every((a) => a.job_posting_id),
|
||||
good: 'Every application is linked to a live posting.',
|
||||
bad: 'Some applications reference a posting that no longer exists.',
|
||||
},
|
||||
{
|
||||
label: 'Screening coverage',
|
||||
ok: f.standardizedPct >= 80,
|
||||
good: `${f.standardizedPct}% of applicants carry a score.`,
|
||||
bad: `Only ${f.standardizedPct}% of applicants are scored — ${plural(f.unscreened.length, 'record')} unstandardized.`,
|
||||
},
|
||||
{
|
||||
label: 'Interview integrity',
|
||||
ok: f.flagged.length === 0,
|
||||
good: 'No interviews flagged.',
|
||||
bad: `${plural(f.flagged.length, 'interview')} flagged below full integrity.`,
|
||||
},
|
||||
{
|
||||
label: 'Profile completeness',
|
||||
ok: f.unscoredProfiles.length <= f.profiles.length / 2,
|
||||
good: `${f.profiles.length - f.unscoredProfiles.length} of ${f.profiles.length} talent profiles have a career score.`,
|
||||
bad: `${plural(f.unscoredProfiles.length, 'profile')} of ${f.profiles.length} ${verb(f.unscoredProfiles.length, 'has', 'have')} no score, so ${verb(f.unscoredProfiles.length, 'it cannot', 'they cannot')} be matched or ranked.`,
|
||||
},
|
||||
{
|
||||
label: 'Throughput',
|
||||
ok: f.hired.length > 0,
|
||||
good: `${plural(f.hired.length, 'hire')} closed at a ${f.hireRate}% hire rate.`,
|
||||
bad: 'No hires closed yet, so there is no conversion baseline.',
|
||||
},
|
||||
];
|
||||
|
||||
const passing = checks.filter((c) => c.ok).length;
|
||||
|
||||
return compose(
|
||||
heading(`Platform health: ${passing}/${checks.length} checks passing`),
|
||||
'',
|
||||
checks.map((c) => `${c.ok ? '✓' : '⚠'} **${c.label}** — ${c.ok ? c.good : c.bad}`).join('\n'),
|
||||
'',
|
||||
heading('Scale'),
|
||||
bullets([
|
||||
`${plural(f.postings.length, 'position')}, ${f.openPositions.length} open`,
|
||||
`${plural(f.total, 'application')}`,
|
||||
`${plural(f.interviews.length, 'AI interview')}`,
|
||||
`${plural(f.profiles.length, 'talent profile')}, ${f.elite.length} Elite`,
|
||||
`${plural(f.activity.length, 'logged event')}`,
|
||||
]),
|
||||
'',
|
||||
passing === checks.length
|
||||
? 'Nothing needs intervention at the platform level.'
|
||||
: 'The failing checks are data-quality issues, not outages — they degrade matching quality rather than break anything.'
|
||||
);
|
||||
};
|
||||
|
||||
const recommendations = (f) => {
|
||||
const actions = [];
|
||||
|
||||
if (f.unscoredProfiles.length) {
|
||||
actions.push(`**Prompt ${plural(f.unscoredProfiles.length, 'worker')} to finish onboarding.** Without a career score they are invisible to matching, so employers cannot find them even when they fit.`);
|
||||
}
|
||||
if (f.unscreened.length) {
|
||||
actions.push(`**${plural(f.unscreened.length, 'application')} unscored.** Each one is a decision made without a standard.`);
|
||||
}
|
||||
if (f.starvedPositions.length) {
|
||||
actions.push(`**${plural(f.starvedPositions.length, 'posting')} with zero applicants** — ${f.starvedPositions.map((p) => p.title).join(', ')}. Check distribution and pay bands.`);
|
||||
}
|
||||
if (f.unratedStaff.length) {
|
||||
actions.push(`**${plural(f.unratedStaff.length, 'hire')} unrated.** Employer ratings are the only verified performance signal feeding scores; without them the scores stay theoretical.`);
|
||||
}
|
||||
if (f.flagged.length) {
|
||||
actions.push(`**Review ${plural(f.flagged.length, 'flagged interview')}** before those candidates progress.`);
|
||||
}
|
||||
|
||||
if (!actions.length) return 'No platform actions outstanding. Data is complete and nothing is flagged.';
|
||||
|
||||
return compose(heading('Recommended actions'), '', numbered(actions));
|
||||
};
|
||||
|
||||
/* ── Candidates Analysis ────────────────────────────────────────────────── */
|
||||
|
||||
const recruitmentInsights = (f) => {
|
||||
const bands = {
|
||||
Elite: f.profiles.filter((p) => (p.krow_score || 0) >= 90).length,
|
||||
Excellent: f.profiles.filter((p) => (p.krow_score || 0) >= 75 && (p.krow_score || 0) < 90).length,
|
||||
Solid: f.profiles.filter((p) => (p.krow_score || 0) >= 60 && (p.krow_score || 0) < 75).length,
|
||||
Building: f.profiles.filter((p) => (p.krow_score || 0) > 0 && (p.krow_score || 0) < 60).length,
|
||||
Unscored: f.unscoredProfiles.length,
|
||||
};
|
||||
|
||||
const verified = f.profiles.filter((p) => (p.capabilities || []).length).length;
|
||||
const topHeavy = bands.Elite + bands.Excellent;
|
||||
|
||||
return compose(
|
||||
heading('Talent supply'),
|
||||
'',
|
||||
bullets(Object.entries(bands).map(([band, count]) => `${band} — ${count}`)),
|
||||
'',
|
||||
heading('Pipeline'),
|
||||
bullets([
|
||||
`${plural(f.total, 'applicant')} averaging ${f.avgScore}`,
|
||||
`${plural(f.ranked.filter((a) => a.ai_score >= 80).length, 'candidate')} at 80+`,
|
||||
`${plural(verified, 'worker')} with at least one verified capability`,
|
||||
`${plural(f.eventCounts.apply_job || 0, 'application event')} logged`,
|
||||
]),
|
||||
'',
|
||||
bands.Unscored > topHeavy
|
||||
? `The pool is bottom-heavy: ${bands.Unscored} unscored against ${topHeavy} at Excellent or above. Supply is not the constraint — verification is.`
|
||||
: 'The scored portion outweighs the unscored, so verification is keeping pace with sign-ups.',
|
||||
'',
|
||||
heading('By role category'),
|
||||
bullets(
|
||||
f.byRole.slice().sort((a, b) => b.applied - a.applied)
|
||||
.map((r) => `${r.title} — ${plural(r.applied, 'applicant')}, ${r.qualified} at 70+`)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const candidateRiskAnalysis = (f) => {
|
||||
const risks = [];
|
||||
|
||||
if (f.missingCredentials.length) {
|
||||
risks.push(`**No credentials on file** — ${plural(f.missingCredentials.length, 'candidate')}. Compliance risk if placed on a role that requires certification.`);
|
||||
}
|
||||
if (f.narrowAvailability.length) {
|
||||
risks.push(`**Narrow availability** — ${plural(f.narrowAvailability.length, 'candidate')} listed one slot or none. Most likely to fall through on the day.`);
|
||||
}
|
||||
if (f.flagged.length) {
|
||||
risks.push(`**Interview integrity** — ${plural(f.flagged.length, 'interview')} below full integrity, usually suspiciously fast responses. Worth a human re-interview.`);
|
||||
}
|
||||
const noHistory = f.scored.filter((a) => !a.years_experience);
|
||||
if (noHistory.length) {
|
||||
risks.push(`**No recorded experience** — ${plural(noHistory.length, 'candidate')}. Not disqualifying, but unverifiable on paper.`);
|
||||
}
|
||||
if (f.singleCandidateRoles.length) {
|
||||
risks.push(`**Concentration risk** — ${f.singleCandidateRoles.map((e) => e.posting.title).join(', ')} ${verb(f.singleCandidateRoles.length, 'depends', 'depend')} on a single viable candidate.`);
|
||||
}
|
||||
|
||||
if (!risks.length) return 'No material risk flags: credentials, availability and interview integrity all check out across the scored pool.';
|
||||
|
||||
return compose(
|
||||
heading(`${plural(risks.length, 'risk flag')}`),
|
||||
'',
|
||||
numbered(risks),
|
||||
'',
|
||||
'_Flags to check, not judgements. Each is a question for a human, not a rejection._'
|
||||
);
|
||||
};
|
||||
|
||||
/* ── Activity ───────────────────────────────────────────────────────────── */
|
||||
|
||||
const auditSummary = (f) => {
|
||||
const count = (type) => f.activity.filter((e) => e.event_type === type).length;
|
||||
const hires = count('hire_candidate');
|
||||
const screens = count('screen_candidate');
|
||||
const creates = count('create_position');
|
||||
|
||||
return compose(
|
||||
heading('Audit summary'),
|
||||
'',
|
||||
bullets([
|
||||
`${plural(f.activity.length, 'event')} total`,
|
||||
`${plural(f.activity7d.length, 'event')} in the last 7 days`,
|
||||
`${plural(f.activity24h.length, 'event')} in the last 24 hours`,
|
||||
`${plural(new Set(f.activity.map((e) => e.user_email)).size, 'distinct user')}`,
|
||||
]),
|
||||
'',
|
||||
heading('Privileged actions'),
|
||||
bullets([
|
||||
`${plural(creates, 'position creation')}`,
|
||||
`${plural(screens, 'screening action')}`,
|
||||
`${plural(hires, 'hire')}`,
|
||||
]),
|
||||
'',
|
||||
heading('Reconciliation'),
|
||||
bullets([
|
||||
hires === f.staff.length
|
||||
? `All ${f.staff.length} people on staff have a matching hire event.`
|
||||
: `${f.staff.length} on staff but ${hires} hire ${verb(hires, 'event', 'events')} — ${Math.abs(f.staff.length - hires)} unexplained.`,
|
||||
screens < f.scored.length
|
||||
? `${f.scored.length} candidates carry scores but only ${plural(screens, 'screening event')} logged. Batch screening records one event per run, not per candidate.`
|
||||
: 'Screening events reconcile with scored candidates.',
|
||||
]),
|
||||
'',
|
||||
'_An audit trail is only as good as its gaps. The reconciliation above is what I would check first in a real review._'
|
||||
);
|
||||
};
|
||||
|
||||
const userActivityInsights = (f) => {
|
||||
const perUser = f.activity.reduce((acc, e) => {
|
||||
acc[e.user_email] ||= { name: e.user_name, role: e.account_type, count: 0, types: new Set() };
|
||||
acc[e.user_email].count += 1;
|
||||
acc[e.user_email].types.add(e.event_type);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const ranked = Object.entries(perUser).sort((a, b) => b[1].count - a[1].count);
|
||||
if (!ranked.length) return 'No activity to profile yet.';
|
||||
|
||||
const byRole = f.activity.reduce((acc, e) => {
|
||||
const role = e.account_type || 'unknown';
|
||||
acc[role] = (acc[role] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const [topEmail, top] = ranked[0];
|
||||
|
||||
return compose(
|
||||
heading('Most active accounts'),
|
||||
'',
|
||||
bullets(ranked.slice(0, 6).map(([email, d]) =>
|
||||
`**${d.name || email}** (${d.role || 'unknown'}) — ${plural(d.count, 'event')} across ${plural(d.types.size, 'action type')}`
|
||||
)),
|
||||
'',
|
||||
heading('By role'),
|
||||
bullets(Object.entries(byRole).sort((a, b) => b[1] - a[1])
|
||||
.map(([role, c]) => `${role.charAt(0).toUpperCase() + role.slice(1)} — ${plural(c, 'event')}`)),
|
||||
'',
|
||||
`${top.name || topEmail} accounts for ${top.count} of ${f.activity.length} events. On a live platform, one account dominating the log is worth confirming is a person and not an integration.`,
|
||||
'',
|
||||
f.activity24h.length === 0
|
||||
? 'No activity in the last day. Expected for a demo dataset; on a live platform it would be worth checking ingestion.'
|
||||
: 'Activity is flowing normally.'
|
||||
);
|
||||
};
|
||||
|
||||
/* ── Free-text responders ───────────────────────────────────────────────── */
|
||||
|
||||
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);
|
||||
return platformHealth(f);
|
||||
};
|
||||
|
||||
export const respondAdminCandidates = (question, f) => {
|
||||
const q = question.toLowerCase();
|
||||
if (has(q, 'risk', 'flag', 'concern', 'compliance')) return candidateRiskAnalysis(f);
|
||||
return recruitmentInsights(f);
|
||||
};
|
||||
|
||||
export const respondActivity = (question, f) => {
|
||||
const q = question.toLowerCase();
|
||||
if (has(q, 'user', 'who', 'active', 'behaviour', 'behavior', 'account')) return userActivityInsights(f);
|
||||
return auditSummary(f);
|
||||
};
|
||||
|
||||
/* ── Capability manifests ───────────────────────────────────────────────── */
|
||||
|
||||
export const CONTROL_CENTER_CAPABILITIES = [
|
||||
{ id: 'platform-health', label: 'Platform Health', icon: Gauge, run: platformHealth },
|
||||
{ id: 'recommendations', label: 'Recommendations', icon: Lightbulb, run: recommendations },
|
||||
];
|
||||
|
||||
export const ADMIN_CANDIDATE_CAPABILITIES = [
|
||||
{ id: 'recruitment-insights', label: 'Recruitment Insights', icon: TrendingUp, run: recruitmentInsights },
|
||||
{ id: 'candidate-risk', label: 'Candidate Risk Analysis', icon: AlertTriangle, run: candidateRiskAnalysis },
|
||||
];
|
||||
|
||||
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 },
|
||||
];
|
||||
462
src/components/ai-assistant/capabilities/employer.js
Normal file
462
src/components/ai-assistant/capabilities/employer.js
Normal file
@@ -0,0 +1,462 @@
|
||||
import {
|
||||
AlertTriangle, BarChart3, ClipboardCheck, FileSpreadsheet, FileText, GitCompare,
|
||||
HeartPulse, LineChart, ListChecks, MessageSquareQuote, Sparkles, Target, Layers,
|
||||
} from 'lucide-react';
|
||||
import { bullets, compose, heading, numbered, plural, verb } from '../insights';
|
||||
|
||||
/**
|
||||
* Employer capabilities — Overview, Candidates, Analytics.
|
||||
*
|
||||
* Each capability is a pure function of the fact sheet. Keeping them pure is
|
||||
* what allows the same code to run against local data today and against a
|
||||
* remote AI service later without the UI changing.
|
||||
*/
|
||||
|
||||
/* ── Overview ───────────────────────────────────────────────────────────── */
|
||||
|
||||
const hiringSummary = (f) => {
|
||||
if (!f.total) return 'Nothing in the pipeline yet. Publish a position and I will start tracking it here.';
|
||||
|
||||
return compose(
|
||||
heading('Where hiring stands'),
|
||||
bullets([
|
||||
`${plural(f.total, 'applicant')} across ${plural(f.openPositions.length, 'open position')}`,
|
||||
`${f.screened.length} screened · ${f.unscreened.length} awaiting a score`,
|
||||
`${plural(f.interviewing.length, 'candidate')} mid-interview`,
|
||||
`${plural(f.hired.length, 'hire')} averaging ${f.hiredAvgScore}/100`,
|
||||
]),
|
||||
'',
|
||||
f.stalled.length
|
||||
? `Most urgent: ${plural(f.stalled.length, 'candidate')} scored 80+ and ${verb(f.stalled.length, 'is', 'are')} still sitting at AI Screened.`
|
||||
: f.unscreened.length
|
||||
? `Most urgent: ${plural(f.unscreened.length, 'applicant')} ${verb(f.unscreened.length, 'has', 'have')} never been scored.`
|
||||
: 'Nothing is stuck — every applicant is screened and every strong candidate has been actioned.'
|
||||
);
|
||||
};
|
||||
|
||||
const hiringHealth = (f) => {
|
||||
const checks = [
|
||||
{
|
||||
label: 'Screening coverage',
|
||||
value: `${f.standardizedPct}%`,
|
||||
ok: f.standardizedPct >= 80,
|
||||
note: f.standardizedPct >= 80
|
||||
? 'Applicants are being scored consistently.'
|
||||
: `${plural(f.unscreened.length, 'applicant')} never got a score, so those decisions are unstandardized.`,
|
||||
},
|
||||
{
|
||||
label: 'Response speed',
|
||||
value: f.timeToHire ? `${f.timeToHire}d` : '—',
|
||||
ok: f.timeToHire > 0 && f.timeToHire <= 3,
|
||||
note: f.timeToHire && f.timeToHire <= 3
|
||||
? 'Inside the window where good candidates are still available.'
|
||||
: 'Event staff accept other work within days — past 3 days you lose people.',
|
||||
},
|
||||
{
|
||||
label: 'Quality of hire',
|
||||
value: f.hiredAvgScore ? `${f.hiredAvgScore}/100` : '—',
|
||||
ok: f.hiredAvgScore >= 80,
|
||||
note: f.hiredAvgScore >= 80
|
||||
? 'You are hiring from the top of your own pool.'
|
||||
: 'Hires are scoring mid-pack — widen the funnel before lowering the bar.',
|
||||
},
|
||||
{
|
||||
label: 'Pipeline supply',
|
||||
value: `${f.openPositions.length} open`,
|
||||
ok: f.starvedPositions.length === 0,
|
||||
note: f.starvedPositions.length
|
||||
? `${f.starvedPositions.map((p) => p.title).join(', ')} ${verb(f.starvedPositions.length, 'has', 'have')} no applicants.`
|
||||
: 'Every open position has applicants.',
|
||||
},
|
||||
];
|
||||
|
||||
const passing = checks.filter((c) => c.ok).length;
|
||||
|
||||
return compose(
|
||||
heading(`${passing} of ${checks.length} signals healthy`),
|
||||
'',
|
||||
checks.map((c) => `${c.ok ? '✓' : '⚠'} **${c.label}** — ${c.value}\n ${c.note}`).join('\n\n')
|
||||
);
|
||||
};
|
||||
|
||||
const pendingActions = (f) => {
|
||||
const actions = [];
|
||||
|
||||
if (f.stalled.length) {
|
||||
actions.push(`**Move your 80+ candidates.** ${f.stalled.slice(0, 3).map((a) => `${a.applicant_name} (${a.ai_score})`).join(', ')} ${verb(f.stalled.length, 'is', 'are')} screened and waiting on you.`);
|
||||
}
|
||||
if (f.unscreened.length) {
|
||||
actions.push(`**Screen the backlog.** ${plural(f.unscreened.length, 'applicant')} ${verb(f.unscreened.length, 'has', 'have')} no score. One pass clears it.`);
|
||||
}
|
||||
if (f.starvedPositions.length) {
|
||||
actions.push(`**Fix the postings nobody applies to.** ${f.starvedPositions.map((p) => p.title).join(' and ')} — usually pay range or reach, not the description.`);
|
||||
}
|
||||
if (f.unratedStaff.length) {
|
||||
actions.push(`**Rate your hires.** ${plural(f.unratedStaff.length, 'person', 'people')} ${verb(f.unratedStaff.length, 'has', 'have')} no rating. Ratings sharpen future matching.`);
|
||||
}
|
||||
if (f.elite.length) {
|
||||
actions.push(`**Reach out directly.** ${f.elite.map((p) => p.full_name).join(', ')} ${verb(f.elite.length, 'is', 'are')} Elite and ${verb(f.elite.length, 'has', 'have')} not applied to anything.`);
|
||||
}
|
||||
|
||||
if (!actions.length) return 'Nothing pending. Everyone is screened, strong candidates are actioned, and every open role has applicants.';
|
||||
|
||||
return compose(heading('Pending actions'), '', numbered(actions.slice(0, 4)));
|
||||
};
|
||||
|
||||
const hiringRisks = (f) => {
|
||||
const risks = [];
|
||||
|
||||
if (f.singleCandidateRoles.length) {
|
||||
risks.push(`**Single-candidate roles.** ${f.singleCandidateRoles.map((e) => e.posting.title).join(', ')} ${verb(f.singleCandidateRoles.length, 'rests', 'rest')} on one viable candidate. If they withdraw, the role reopens from zero.`);
|
||||
}
|
||||
if (f.stalled.length) {
|
||||
risks.push(`**Losing strong candidates to delay.** ${plural(f.stalled.length, 'candidate')} at 80+ ${verb(f.stalled.length, 'has', 'have')} been screened but not moved. This is where good hires quietly disappear.`);
|
||||
}
|
||||
if (f.missingCredentials.length) {
|
||||
risks.push(`**Compliance exposure.** ${plural(f.missingCredentials.length, 'scored candidate')} ${verb(f.missingCredentials.length, 'has', 'have')} no certification on file. Placing them on a role that requires one is a real liability.`);
|
||||
}
|
||||
if (f.narrowAvailability.length) {
|
||||
risks.push(`**Availability risk.** ${plural(f.narrowAvailability.length, 'candidate')} listed one slot or none. ${verb(f.narrowAvailability.length, 'This is the placement', 'These are the placements')} most likely to fall through on the day.`);
|
||||
}
|
||||
if (f.starvedPositions.length) {
|
||||
risks.push(`**Unfillable as posted.** ${plural(f.starvedPositions.length, 'open role')} with zero applicants after being live.`);
|
||||
}
|
||||
if (f.flagged.length) {
|
||||
risks.push(`**Interview integrity.** ${plural(f.flagged.length, 'interview')} scored below full integrity. Worth a human re-interview before progressing.`);
|
||||
}
|
||||
|
||||
if (!risks.length) return 'No material risks in the current pipeline: coverage is good, no role depends on a single candidate, and credentials check out.';
|
||||
|
||||
return compose(
|
||||
heading(`${plural(risks.length, 'risk')} to watch`),
|
||||
'',
|
||||
numbered(risks),
|
||||
'',
|
||||
'_Flags to check, not conclusions. Each is a question for a human._'
|
||||
);
|
||||
};
|
||||
|
||||
/* ── Candidates ─────────────────────────────────────────────────────────── */
|
||||
|
||||
const resumeAnalysis = (f) => {
|
||||
const subject = f.top[0];
|
||||
if (!subject) return 'No scored candidates yet. Screen the pipeline and I will analyse the strongest.';
|
||||
|
||||
return compose(
|
||||
heading(`${subject.applicant_name} — ${subject.ai_score}/100`),
|
||||
`${subject.job_title} · ${plural(subject.years_experience, 'year')} experience · ${subject.english_level} English`,
|
||||
'',
|
||||
subject.ai_summary || '',
|
||||
'',
|
||||
subject.ai_strengths?.length ? compose(heading('What holds up'), bullets(subject.ai_strengths)) : '',
|
||||
'',
|
||||
subject.ai_gaps?.length ? compose(heading('What to probe'), bullets(subject.ai_gaps)) : '',
|
||||
'',
|
||||
subject.certifications?.length
|
||||
? `**Credentials:** ${subject.certifications.join(', ')} — verify before the first shift. A lapsed card is the most common surprise.`
|
||||
: '**No credentials on file.** Request these before scheduling.'
|
||||
);
|
||||
};
|
||||
|
||||
const candidateComparison = (f) => {
|
||||
const pair = f.ranked.slice(0, 2);
|
||||
if (pair.length < 2) return 'I need at least two scored candidates to compare.';
|
||||
|
||||
const [a, b] = pair;
|
||||
const gap = a.ai_score - b.ai_score;
|
||||
const dims = ['experience', 'english', 'reliability', 'certifications', 'availability'];
|
||||
|
||||
const rows = dims.map((dim) => {
|
||||
const av = a.score_breakdown?.[dim] ?? 0;
|
||||
const bv = b.score_breakdown?.[dim] ?? 0;
|
||||
const lead = av === bv ? 'tied' : (av > bv ? a : b).applicant_name.split(' ')[0];
|
||||
return `${dim.charAt(0).toUpperCase() + dim.slice(1)} — ${av} vs ${bv} · ${lead}`;
|
||||
});
|
||||
|
||||
const biggest = dims.reduce((best, d) =>
|
||||
Math.abs((a.score_breakdown?.[d] ?? 0) - (b.score_breakdown?.[d] ?? 0)) >
|
||||
Math.abs((a.score_breakdown?.[best] ?? 0) - (b.score_breakdown?.[best] ?? 0)) ? d : best, dims[0]);
|
||||
|
||||
return compose(
|
||||
heading(`${a.applicant_name} (${a.ai_score}) vs ${b.applicant_name} (${b.ai_score})`),
|
||||
'',
|
||||
bullets(rows),
|
||||
'',
|
||||
gap <= 3
|
||||
? `Effectively tied — ${plural(gap, 'point')} apart is inside the noise. Decide on availability and the interview, not the score.`
|
||||
: `${a.applicant_name} leads by ${gap}, driven mostly by ${biggest}.`
|
||||
);
|
||||
};
|
||||
|
||||
const interviewQuestions = (f) => {
|
||||
const subject = f.stalled[0] || f.ranked[0];
|
||||
if (!subject) return 'Screen a candidate and I will build questions around their specific gaps.';
|
||||
|
||||
const gaps = subject.ai_gaps || [];
|
||||
|
||||
return compose(
|
||||
heading(`Questions for ${subject.applicant_name}`),
|
||||
'Built from their gaps rather than a generic list, so the interview tests what the score could not.',
|
||||
'',
|
||||
numbered([
|
||||
`Walk me through your busiest ${subject.job_title?.toLowerCase() || 'shift'}. What happened, and what did you decide?`,
|
||||
gaps[0]
|
||||
? `I noticed ${gaps[0].toLowerCase()}. How have you handled that in practice?`
|
||||
: 'Tell me about a shift that went wrong. What was your part in it?',
|
||||
'Something breaks mid-service and your lead is unreachable. What are your next three moves?',
|
||||
subject.certifications?.length
|
||||
? `Your ${subject.certifications[0]} is on file — when did you last apply it on a live shift?`
|
||||
: 'Which certifications are you working toward, and why that one?',
|
||||
'What does your real availability look like over the next eight weeks?',
|
||||
])
|
||||
);
|
||||
};
|
||||
|
||||
const skillGapAnalysis = (f) => {
|
||||
if (!f.scored.length) return 'No scored candidates yet, so there are no gaps to analyse.';
|
||||
|
||||
const tally = {};
|
||||
f.scored.forEach((a) => (a.ai_gaps || []).forEach((gap) => {
|
||||
const key = gap.replace(/^Missing certifications?:\s*/i, 'Missing credential: ');
|
||||
tally[key] = (tally[key] || 0) + 1;
|
||||
}));
|
||||
|
||||
const ranked = Object.entries(tally).sort((a, b) => b[1] - a[1]).slice(0, 5);
|
||||
if (!ranked.length) return 'No recurring gaps across your scored candidates — the pool matches your postings well.';
|
||||
|
||||
return compose(
|
||||
heading('Recurring gaps'),
|
||||
'',
|
||||
bullets(ranked.map(([gap, count]) => `${gap} — ${count} of ${f.scored.length}`)),
|
||||
'',
|
||||
f.missingCredentials.length >= 2
|
||||
? `${f.missingCredentials.length} candidates are short a required credential. That is usually a posting problem: if the certification is trainable, requiring it up front filters out people you could hire this week.`
|
||||
: 'Gaps are spread thin, which means your requirements are well matched to who is applying.'
|
||||
);
|
||||
};
|
||||
|
||||
const hiringRecommendation = (f) => {
|
||||
if (!f.scored.length) return 'Nothing scored yet, so I have no basis for a recommendation.';
|
||||
|
||||
const open = (a) => !['hired', 'rejected'].includes(a.status);
|
||||
const shortlist = f.ranked.filter((a) => a.ai_score >= 80 && open(a));
|
||||
const interview = f.ranked.filter((a) => a.ai_score >= 60 && a.ai_score < 80 && open(a));
|
||||
const pass = f.weak.filter(open);
|
||||
|
||||
return compose(
|
||||
heading('Recommendation'),
|
||||
'',
|
||||
shortlist.length
|
||||
? `**Shortlist** — ${shortlist.map((a) => `${a.applicant_name} (${a.ai_score})`).join(', ')}`
|
||||
: '**Shortlist** — nobody currently clears 80.',
|
||||
'',
|
||||
interview.length ? `**Interview** — ${interview.map((a) => `${a.applicant_name} (${a.ai_score})`).join(', ')}` : '',
|
||||
'',
|
||||
pass.length ? `**Likely pass** — ${pass.map((a) => `${a.applicant_name} (${a.ai_score})`).join(', ')}` : '',
|
||||
'',
|
||||
'_A recommendation, not a decision. I score what is on file — I have not met these people._'
|
||||
);
|
||||
};
|
||||
|
||||
/* ── Analytics ──────────────────────────────────────────────────────────── */
|
||||
|
||||
const explainCharts = (f) => compose(
|
||||
heading('What these charts are saying'),
|
||||
'',
|
||||
`**Pipeline Stages** — ${f.total} applied, ${f.screened.length} screened, ${f.hired.length} hired. Bars are cumulative, so "AI Screened" includes everyone who moved past it.`,
|
||||
'',
|
||||
`**Candidate Scores** — ${plural(f.scored.length, 'candidate')} scored, averaging ${f.avgScore}. The 80–100 bucket holds ${f.ranked.filter((a) => a.ai_score >= 80).length}; that bucket is your real hiring pool.`,
|
||||
'',
|
||||
`**Interview Completion** — ${f.completedInterviews.length} of ${f.interviews.length} fully scored (${f.interviewCompletion}%). Unscored means started and abandoned.`,
|
||||
'',
|
||||
`**Cost Saved** — $${f.costSaved.toLocaleString()}, from ${plural(f.scored.length, 'screen')} at $45 and ${plural(f.interviews.length, 'interview')} at $80. Industry per-unit costs for doing it manually, so treat it as a floor.`
|
||||
);
|
||||
|
||||
const forecastHiring = (f) => {
|
||||
const available = f.ranked.filter((a) => a.ai_score >= 60 && !['hired', 'rejected'].includes(a.status));
|
||||
const conversion = f.hireRate || 14;
|
||||
const expected = Math.round((available.length * conversion) / 100) || (available.length >= 3 ? 1 : 0);
|
||||
const short = f.openPositions.length - expected;
|
||||
|
||||
return compose(
|
||||
heading('Forecast'),
|
||||
'',
|
||||
bullets([
|
||||
`${plural(available.length, 'candidate')} at 60+ still in play`,
|
||||
`At your ${conversion}% hire rate that projects to about ${plural(expected, 'hire')}`,
|
||||
f.timeToHire ? `At a ${f.timeToHire}-day average, those land within a week of you deciding` : 'No time-to-hire baseline yet',
|
||||
`${plural(f.openPositions.length, 'open position')} to fill`,
|
||||
]),
|
||||
'',
|
||||
short > 0
|
||||
? `That leaves you roughly ${plural(short, 'role')} short. Two ways to close it: screen the ${f.unscreened.length} unscored applicants, or source directly from the ${plural(f.profiles.length, 'profile')} in the talent pool.`
|
||||
: 'That covers your open roles, assuming the strong candidates do not go elsewhere first.',
|
||||
'',
|
||||
'_Projected from your own conversion rate, not a promise — small pools move a lot._'
|
||||
);
|
||||
};
|
||||
|
||||
const departmentInsights = (f) => {
|
||||
if (!f.byRole.length) return 'No open positions to break down.';
|
||||
|
||||
const byCategory = {};
|
||||
f.byRole.forEach((r) => {
|
||||
byCategory[r.category] ||= { applied: 0, hired: 0, qualified: 0, roles: [] };
|
||||
byCategory[r.category].applied += r.applied;
|
||||
byCategory[r.category].hired += r.hired;
|
||||
byCategory[r.category].qualified += r.qualified;
|
||||
byCategory[r.category].roles.push(r.title);
|
||||
});
|
||||
|
||||
const entries = Object.entries(byCategory).sort((a, b) => b[1].applied - a[1].applied);
|
||||
const starved = entries.filter(([, d]) => d.applied === 0);
|
||||
|
||||
return compose(
|
||||
heading('By role category'),
|
||||
'',
|
||||
entries.map(([category, d]) =>
|
||||
`**${category}** — ${plural(d.applied, 'applicant')}, ${d.qualified} at 70+, ${plural(d.hired, 'hire')} across ${plural(d.roles.length, 'role')}`
|
||||
).join('\n'),
|
||||
'',
|
||||
starved.length
|
||||
? `${starved.map(([c]) => c).join(' and ')} ${verb(starved.length, 'is', 'are')} attracting nobody. Compare the pay range against the categories that are filling.`
|
||||
: 'Every category has applicants, so your reach is working across the board.'
|
||||
);
|
||||
};
|
||||
|
||||
const generateReports = (f) => {
|
||||
const stamp = f.today.toISOString().slice(0, 10);
|
||||
|
||||
return compose(
|
||||
heading(`Hiring report — ${stamp}`),
|
||||
'',
|
||||
heading('Volume'),
|
||||
bullets([
|
||||
`${plural(f.total, 'applicant')} received`,
|
||||
`${f.screened.length} screened (${f.standardizedPct}% coverage)`,
|
||||
`${plural(f.interviews.length, 'AI interview')} conducted, ${f.completedInterviews.length} scored`,
|
||||
`${plural(f.hired.length, 'hire')} closed`,
|
||||
]),
|
||||
'',
|
||||
heading('Quality'),
|
||||
bullets([
|
||||
`Average candidate score ${f.avgScore}`,
|
||||
`Average hire score ${f.hiredAvgScore}`,
|
||||
`Hire rate ${f.hireRate}%`,
|
||||
`Average time-to-hire ${f.timeToHire ? `${f.timeToHire} days` : 'no baseline'}`,
|
||||
]),
|
||||
'',
|
||||
heading('Efficiency'),
|
||||
bullets([
|
||||
`$${f.costSaved.toLocaleString()} saved against manual screening and interviewing`,
|
||||
`≈${f.hoursSaved} recruiter hours returned`,
|
||||
]),
|
||||
'',
|
||||
heading('By role'),
|
||||
bullets(f.byRole.map((r) => `${r.title} — ${r.applied} applied, ${r.qualified} at 70+, ${r.hired} hired`)),
|
||||
'',
|
||||
f.bottleneck
|
||||
? `**Headline finding.** The ${f.bottleneck.from} → ${f.bottleneck.to} step passes only ${f.bottleneck.rate}%, losing ${plural(f.bottleneck.lost, 'candidate')}. That is the single biggest recoverable loss in the funnel.`
|
||||
: '**Headline finding.** The funnel is passing through evenly with no single bottleneck.',
|
||||
'',
|
||||
'_Copy this into your own template — export to PDF and CSV is not wired up in this build._'
|
||||
);
|
||||
};
|
||||
|
||||
/* ── Free-text responders ───────────────────────────────────────────────── */
|
||||
|
||||
const has = (q, ...words) => words.some((w) => q.includes(w));
|
||||
|
||||
const stateOfPlay = (f) =>
|
||||
`I do not have a specific read on that. What I can see: ${plural(f.total, 'applicant')} across ${plural(f.openPositions.length, 'open position')}, ${f.screened.length} screened, ${plural(f.interviewing.length, 'candidate')} interviewing, ${plural(f.hired.length, 'hire')}. Ask about any of those and I will go deeper.`;
|
||||
|
||||
const bottleneckAnswer = (f) => {
|
||||
if (!f.bottleneck) return 'Not enough pipeline movement yet to locate a bottleneck.';
|
||||
const b = f.bottleneck;
|
||||
const diagnosis = {
|
||||
'AI Screened': `${plural(f.unscreened.length, 'applicant')} ${verb(f.unscreened.length, 'has', 'have')} never been scored — the cheapest gap in the funnel to close.`,
|
||||
Shortlisted: 'Candidates are screened but not shortlisted. Either the scores are not being read, or the bar is above what the pool can deliver.',
|
||||
Interviewed: 'Shortlisted candidates are not reaching interview — usually scheduling friction rather than a decision.',
|
||||
Hired: 'Interviews happen but offers are not closing. Check pay against market and how long the decision takes.',
|
||||
}[b.to];
|
||||
|
||||
return compose(
|
||||
heading(`Bottleneck: ${b.from} → ${b.to}`),
|
||||
`Only ${b.rate}% pass through, losing ${plural(b.lost, 'candidate')}.`,
|
||||
'',
|
||||
diagnosis || '',
|
||||
'',
|
||||
bullets(f.transitions.map((t) => `${t.from} → ${t.to}: ${t.rate}%${t.lost ? ` (−${t.lost})` : ''}`))
|
||||
);
|
||||
};
|
||||
|
||||
export const respondOverview = (question, f) => {
|
||||
const q = question.toLowerCase();
|
||||
if (has(q, 'risk', 'exposure', 'worry', 'concern')) return hiringRisks(f);
|
||||
if (has(q, 'attention', 'urgent', 'priorit', 'pending', 'what should i', 'next')) return pendingActions(f);
|
||||
if (has(q, 'health', 'how are we', 'how is hiring', 'doing')) return hiringHealth(f);
|
||||
if (has(q, 'summary', 'today', 'brief', 'catch me up', 'overview')) return hiringSummary(f);
|
||||
if (has(q, 'pipeline', 'funnel', 'stuck', 'bottleneck', 'drop')) return bottleneckAnswer(f);
|
||||
if (has(q, 'position', 'posting', 'role', 'opening')) return departmentInsights(f);
|
||||
return stateOfPlay(f);
|
||||
};
|
||||
|
||||
export const respondCandidates = (question, f) => {
|
||||
const q = question.toLowerCase();
|
||||
if (has(q, 'resume', 'résumé', 'cv', 'analyse', 'analyze')) return resumeAnalysis(f);
|
||||
if (has(q, 'compare', 'versus', ' vs ', 'between')) return candidateComparison(f);
|
||||
if (has(q, 'question', 'ask them', 'interview prep')) return interviewQuestions(f);
|
||||
if (has(q, 'gap', 'missing', 'weakness', 'skill')) return skillGapAnalysis(f);
|
||||
if (has(q, 'recommend', 'should i hire', 'who should', 'shortlist', 'pass', 'interview next')) return hiringRecommendation(f);
|
||||
if (has(q, 'unscreened', 'not screened', 'pending')) {
|
||||
return f.unscreened.length
|
||||
? compose(
|
||||
heading(`${plural(f.unscreened.length, 'applicant')} unscreened`),
|
||||
'',
|
||||
bullets(f.unscreened.slice(0, 8).map((a) => `${a.applicant_name} — ${a.job_title}`)),
|
||||
f.unscreened.length > 8 ? `…and ${f.unscreened.length - 8} more` : ''
|
||||
)
|
||||
: 'Everyone has been screened.';
|
||||
}
|
||||
return stateOfPlay(f);
|
||||
};
|
||||
|
||||
export const respondAnalytics = (question, f) => {
|
||||
const q = question.toLowerCase();
|
||||
if (has(q, 'explain', 'what does', 'mean', 'chart', 'graph')) return explainCharts(f);
|
||||
if (has(q, 'forecast', 'predict', 'projection', 'will i')) return forecastHiring(f);
|
||||
if (has(q, 'department', 'category', 'role', 'team')) return departmentInsights(f);
|
||||
if (has(q, 'report', 'export', 'summar', 'download')) return generateReports(f);
|
||||
if (has(q, 'bottleneck', 'drop', 'stuck', 'funnel', 'lose')) return bottleneckAnswer(f);
|
||||
if (has(q, 'hire rate', 'conversion')) {
|
||||
return `${f.hireRate}% — ${plural(f.hired.length, 'hire')} from ${plural(f.total, 'applicant')}. Healthy for event staffing; the norm sits between 2% and 8% because most applicants are never properly screened.`;
|
||||
}
|
||||
if (has(q, 'cost', 'saved', 'roi', 'money')) {
|
||||
return `$${f.costSaved.toLocaleString()} — ${plural(f.scored.length, 'AI screen')} at the $45 a manual screen costs, plus ${plural(f.interviews.length, 'automated interview')} at $80 each. Also about ${f.hoursSaved} recruiter hours you did not spend.`;
|
||||
}
|
||||
return stateOfPlay(f);
|
||||
};
|
||||
|
||||
/* ── Capability manifests ───────────────────────────────────────────────── */
|
||||
|
||||
export const OVERVIEW_CAPABILITIES = [
|
||||
{ id: 'hiring-summary', label: 'Hiring Summary', icon: Sparkles, run: hiringSummary },
|
||||
{ id: 'hiring-health', label: 'Hiring Health', icon: HeartPulse, run: hiringHealth },
|
||||
{ id: 'pending-actions', label: 'Pending Actions', icon: ListChecks, run: pendingActions },
|
||||
{ id: 'hiring-risks', label: 'Hiring Risks', icon: AlertTriangle, run: hiringRisks },
|
||||
];
|
||||
|
||||
export const CANDIDATE_CAPABILITIES = [
|
||||
{ id: 'resume-analysis', label: 'Resume Analysis', icon: FileText, run: resumeAnalysis },
|
||||
{ id: 'candidate-comparison', label: 'Candidate Comparison', icon: GitCompare, run: candidateComparison },
|
||||
{ id: 'interview-questions', label: 'Interview Question Generator', icon: MessageSquareQuote, run: interviewQuestions },
|
||||
{ id: 'skill-gap', label: 'Skill Gap Analysis', icon: Target, run: skillGapAnalysis },
|
||||
{ id: 'hiring-recommendation', label: 'Hiring Recommendation', icon: ClipboardCheck, run: hiringRecommendation },
|
||||
];
|
||||
|
||||
export const ANALYTICS_CAPABILITIES = [
|
||||
{ id: 'explain-charts', label: 'Explain Charts', icon: BarChart3, run: explainCharts },
|
||||
{ id: 'forecast-hiring', label: 'Forecast Hiring', icon: LineChart, run: forecastHiring },
|
||||
{ id: 'department-insights', label: 'Department Insights', icon: Layers, run: departmentInsights },
|
||||
{ id: 'generate-reports', label: 'Generate Reports', icon: FileSpreadsheet, run: generateReports },
|
||||
];
|
||||
157
src/components/ai-assistant/capabilities/talent.js
Normal file
157
src/components/ai-assistant/capabilities/talent.js
Normal file
@@ -0,0 +1,157 @@
|
||||
import { BookOpen, Compass, FileText } from 'lucide-react';
|
||||
import { bullets, compose, heading, numbered, plural } from '../insights';
|
||||
import { getScoreBand, toFICO } from '@/lib/talentHome';
|
||||
|
||||
/**
|
||||
* Talent capabilities — My Portal.
|
||||
*
|
||||
* The register shifts here: an employer wants a read on a pipeline, a worker
|
||||
* wants to know what to do next to earn more. Answers name the specific course
|
||||
* or step, because "improve your skills" helps nobody.
|
||||
*/
|
||||
|
||||
const scoreOf = (p) => Number(p?.krow_score) || 0;
|
||||
|
||||
const NO_PROFILE = 'I do not have your profile yet. Once it exists I can give you specifics instead of generalities.';
|
||||
|
||||
const resumeReview = (f) => {
|
||||
const p = f.profile;
|
||||
if (!p) return NO_PROFILE;
|
||||
|
||||
const missing = [];
|
||||
if (!p.phone) missing.push('phone number');
|
||||
if (!p.address) missing.push('location');
|
||||
if (!(p.skills || []).length) missing.push('skills');
|
||||
if (!(p.experience || []).length) missing.push('work history');
|
||||
if (!(p.certifications || []).length) missing.push('certifications');
|
||||
if (!p.career_goals) missing.push('career goals');
|
||||
if (!(p.availability || []).length) missing.push('availability');
|
||||
if (!p.selfie_url) missing.push('photo');
|
||||
|
||||
const present = [];
|
||||
if ((p.experience || []).length) present.push(`${plural(p.experience.length, 'role')} of work history`);
|
||||
if ((p.skills || []).length) present.push(`${plural(p.skills.length, 'skill')} listed`);
|
||||
if ((p.certifications || []).length) present.push(p.certifications.join(', '));
|
||||
if ((p.capabilities || []).length) present.push(`${plural(p.capabilities.length, 'capability')} verified by evidence`);
|
||||
|
||||
return compose(
|
||||
heading(`Your profile is ${p.profile_completion || 0}% complete`),
|
||||
'',
|
||||
present.length ? compose(heading('What is working'), bullets(present)) : '',
|
||||
'',
|
||||
missing.length
|
||||
? compose(
|
||||
heading('What is missing'),
|
||||
bullets(missing),
|
||||
'',
|
||||
'Employers filter on availability and certifications before they read anything else. If you fix two things, fix those.'
|
||||
)
|
||||
: compose(heading('Nothing is missing'), 'From here, more verified evidence moves you — not more fields.'),
|
||||
'',
|
||||
(p.capabilities || []).length
|
||||
? '_Your verified capabilities are the part employers actually trust — those came with evidence attached, not a checkbox._'
|
||||
: '_Nothing is verified yet. One completed challenge is worth more than ten claimed skills._'
|
||||
);
|
||||
};
|
||||
|
||||
const careerGuidance = (f) => {
|
||||
const p = f.profile;
|
||||
if (!p) return NO_PROFILE;
|
||||
|
||||
const score = scoreOf(p);
|
||||
const band = getScoreBand(score);
|
||||
const target = score >= 90 ? null : score >= 75 ? 90 : score >= 60 ? 75 : 60;
|
||||
|
||||
return compose(
|
||||
heading(`You are at ${toFICO(score)} — ${band.label}`),
|
||||
'',
|
||||
bullets([
|
||||
`Reliability ${p.reliability_score || 0}/100`,
|
||||
`Attendance ${p.attendance_score ?? 0}/100`,
|
||||
`${plural((p.completed_courses || []).length, 'course')} completed, ${plural((p.earned_badges || []).length, 'badge')} earned`,
|
||||
`${plural((p.capabilities || []).length, 'verified capability')}`,
|
||||
`${plural(p.shifts_completed || 0, 'shift')} worked`,
|
||||
]),
|
||||
'',
|
||||
target
|
||||
? `**To reach ${target}:** verified work is the fastest lever. Courses move the score; verified shifts move it more, because employers weight what someone else confirmed over what you claimed.`
|
||||
: '**You are in the top band.** From here it is about holding it — the score decays if shifts stop.',
|
||||
'',
|
||||
p.desired_position
|
||||
? `You want to be ${p.desired_position}. ${score >= 75 ? 'You are already scoring in the range employers shortlist for that.' : 'The gap to that role is verification, not ambition.'}`
|
||||
: 'You have not set a target role. Setting one lets me tell you which courses matter and which are noise.',
|
||||
'',
|
||||
(p.shifts_completed || 0) === 0
|
||||
? '**One concrete step:** work a single shift. Attendance, ratings and reliability all need a first data point before anything else compounds.'
|
||||
: ''
|
||||
);
|
||||
};
|
||||
|
||||
const learningRecommendations = (f) => {
|
||||
const p = f.profile;
|
||||
if (!p) return NO_PROFILE;
|
||||
|
||||
const completedIds = new Set((p.completed_courses || []).map((c) => c.course_id));
|
||||
const done = (p.completed_courses || []).length;
|
||||
const available = (f.courses || []).filter((c) => c.status === 'active' && !completedIds.has(c.id));
|
||||
|
||||
if (!available.length) {
|
||||
return compose(
|
||||
heading('You have completed everything available'),
|
||||
`${plural(done, 'course')} done, ${plural((p.earned_badges || []).length, 'badge')} earned, ${p.xp || 0} XP.`,
|
||||
'',
|
||||
'The next lever is verified shifts — that is what employers weight most.'
|
||||
);
|
||||
}
|
||||
|
||||
const byValue = [...available].sort((a, b) => (b.xp || 0) - (a.xp || 0));
|
||||
const shifts = p.shifts_completed || 0;
|
||||
|
||||
return compose(
|
||||
heading('What to learn next'),
|
||||
'',
|
||||
numbered(byValue.slice(0, 3).map((c) => {
|
||||
const gate = c.unlock_requirements?.min_shifts || 0;
|
||||
const locked = gate > shifts;
|
||||
return `**${c.title}** — ${c.difficulty}, ${c.estimated_minutes} min, +${c.xp} XP\n Proves: ${c.proof_skill || c.title}.${locked ? ` Locked until ${plural(gate, 'shift')} (you have ${shifts}).` : ' Available now.'}`;
|
||||
})),
|
||||
'',
|
||||
heading('Where you stand'),
|
||||
bullets([
|
||||
`${plural(done, 'course')} completed`,
|
||||
`${plural((p.earned_badges || []).length, 'badge')} earned`,
|
||||
`${p.xp || 0} XP`,
|
||||
`${plural((p.capabilities || []).length, 'capability')} verified`,
|
||||
]),
|
||||
'',
|
||||
done === 0
|
||||
? 'Start with the shortest one. The point of the first course is not the XP — it is getting one verified capability on the board.'
|
||||
: done < 3
|
||||
? `Three completions is where the score starts moving noticeably, because education carries real weight in the formula. You are at ${done}.`
|
||||
: 'You are past the easy gains. Advanced courses are worth double the XP and unlock supervisor-tier roles.',
|
||||
'',
|
||||
'Each of these ends in a challenge, not a quiz — you demonstrate the skill and the evidence gets scored. That is why the badge means something to an employer.'
|
||||
);
|
||||
};
|
||||
|
||||
/* ── Free-text responder ────────────────────────────────────────────────── */
|
||||
|
||||
const has = (q, ...words) => words.some((w) => q.includes(w));
|
||||
|
||||
export const respondTalent = (question, f) => {
|
||||
const q = question.toLowerCase();
|
||||
if (has(q, 'resume', 'résumé', 'cv', 'profile')) return resumeReview(f);
|
||||
if (has(q, 'learn', 'course', 'training', 'study', 'skill', 'badge')) return learningRecommendations(f);
|
||||
if (has(q, 'career', 'earn more', 'pay', 'score', 'guidance', 'promotion', 'next')) return careerGuidance(f);
|
||||
|
||||
const p = f.profile;
|
||||
return p
|
||||
? `I can help with your career score (currently ${toFICO(scoreOf(p))}), your profile, or what to learn next. Where would you like to start?`
|
||||
: NO_PROFILE;
|
||||
};
|
||||
|
||||
export const TALENT_CAPABILITIES = [
|
||||
{ id: 'resume-review', label: 'Resume Review', icon: FileText, run: resumeReview },
|
||||
{ id: 'career-guidance', label: 'Career Guidance', icon: Compass, run: careerGuidance },
|
||||
{ id: 'learning-recommendations', label: 'Learning Recommendations', icon: BookOpen, run: learningRecommendations },
|
||||
];
|
||||
163
src/components/ai-assistant/contexts.js
Normal file
163
src/components/ai-assistant/contexts.js
Normal file
@@ -0,0 +1,163 @@
|
||||
import {
|
||||
ANALYTICS_CAPABILITIES, CANDIDATE_CAPABILITIES, OVERVIEW_CAPABILITIES,
|
||||
respondAnalytics, respondCandidates, respondOverview,
|
||||
} from './capabilities/employer';
|
||||
import {
|
||||
ACTIVITY_CAPABILITIES, ADMIN_CANDIDATE_CAPABILITIES, CONTROL_CENTER_CAPABILITIES,
|
||||
respondActivity, respondAdminCandidates, respondControlCenter,
|
||||
} from './capabilities/admin';
|
||||
import { TALENT_CAPABILITIES, respondTalent } from './capabilities/talent';
|
||||
|
||||
/**
|
||||
* Page contexts for the Krow AI Assistant.
|
||||
*
|
||||
* The component is identical on every page. Only what is in this file changes:
|
||||
* the supporting line, the placeholder examples, the suggested chips, the
|
||||
* capabilities and the responder.
|
||||
*
|
||||
* Chips are phrased as things a person would actually type, because clicking one
|
||||
* fills the input rather than firing an action. The user always sees the
|
||||
* question before it is asked, so the assistant reads as a conversation rather
|
||||
* than a menu of buttons.
|
||||
*
|
||||
* `capability` binds a chip to a specific capability, so a chip gets a precise
|
||||
* answer while an equivalent typed question still resolves through the
|
||||
* responder's keyword matching.
|
||||
*
|
||||
* `needsWorkerProfile` gates the one query with a side effect, so employer and
|
||||
* admin sessions never touch worker-profile records.
|
||||
*/
|
||||
export const ASSISTANT_CONTEXTS = {
|
||||
'employer.overview': {
|
||||
id: 'employer.overview',
|
||||
page: 'Overview',
|
||||
tagline: 'Find insights from your hiring data, spot what needs attention, and ask questions about this page.',
|
||||
placeholders: [
|
||||
"Ask about today's hiring…",
|
||||
'What should I do first?',
|
||||
'Which roles are at risk?',
|
||||
'How healthy is my pipeline?',
|
||||
],
|
||||
chips: [
|
||||
{ label: "Today's Hiring", prompt: 'Give me a summary of hiring today', capability: 'hiring-summary' },
|
||||
{ label: 'Pipeline Health', prompt: 'How healthy is my hiring pipeline?', capability: 'hiring-health' },
|
||||
{ label: 'Pending Actions', prompt: 'What is pending and needs my attention?', capability: 'pending-actions' },
|
||||
{ label: 'Hiring Risks', prompt: 'What risks should I know about?', capability: 'hiring-risks' },
|
||||
],
|
||||
capabilities: OVERVIEW_CAPABILITIES,
|
||||
respond: respondOverview,
|
||||
},
|
||||
|
||||
'employer.candidates': {
|
||||
id: 'employer.candidates',
|
||||
page: 'Candidates',
|
||||
tagline: 'Analyse résumés, compare candidates, generate interview questions, and decide who to move forward.',
|
||||
placeholders: [
|
||||
'Compare two candidates…',
|
||||
'Who should I interview next?',
|
||||
'What are this candidate’s gaps?',
|
||||
'Draft interview questions…',
|
||||
],
|
||||
chips: [
|
||||
{ label: 'Top Candidates', prompt: 'Analyse my strongest candidate', capability: 'resume-analysis' },
|
||||
{ label: 'Compare Candidates', prompt: 'Compare my top two candidates', capability: 'candidate-comparison' },
|
||||
{ label: 'Interview Questions', prompt: 'Generate interview questions for my top candidate', capability: 'interview-questions' },
|
||||
{ label: 'Skill Gap Analysis', prompt: 'What skill gaps run across my candidates?', capability: 'skill-gap' },
|
||||
{ label: 'Hiring Recommendation', prompt: 'Who should I hire, interview, or pass on?', capability: 'hiring-recommendation' },
|
||||
],
|
||||
capabilities: CANDIDATE_CAPABILITIES,
|
||||
respond: respondCandidates,
|
||||
},
|
||||
|
||||
'employer.analytics': {
|
||||
id: 'employer.analytics',
|
||||
page: 'Analytics',
|
||||
tagline: 'Explain the numbers on this page, forecast what your pipeline will yield, and generate reports.',
|
||||
placeholders: [
|
||||
'Explain this chart…',
|
||||
'Generate a hiring report…',
|
||||
'Where do candidates drop off?',
|
||||
'How many hires can I expect?',
|
||||
],
|
||||
chips: [
|
||||
{ label: 'Explain Charts', prompt: 'Explain the charts on this page', capability: 'explain-charts' },
|
||||
{ label: 'Forecast Hiring', prompt: 'Forecast my hiring from the current pipeline', capability: 'forecast-hiring' },
|
||||
{ label: 'Department Insights', prompt: 'Break down performance by role category', capability: 'department-insights' },
|
||||
{ label: 'Generate Report', prompt: 'Generate a hiring report', capability: 'generate-reports' },
|
||||
],
|
||||
capabilities: ANALYTICS_CAPABILITIES,
|
||||
respond: respondAnalytics,
|
||||
},
|
||||
|
||||
'admin.controlCenter': {
|
||||
id: 'admin.controlCenter',
|
||||
page: 'Control Center',
|
||||
tagline: 'Check platform health, reconcile the data, and see what needs intervention across every account.',
|
||||
placeholders: [
|
||||
'Is anything broken?',
|
||||
'What should I fix first?',
|
||||
'How is the platform performing?',
|
||||
],
|
||||
chips: [
|
||||
{ label: 'Platform Health', prompt: 'Run a platform health check', capability: 'platform-health' },
|
||||
{ label: 'Recommendations', prompt: 'What should I fix first?', capability: 'recommendations' },
|
||||
],
|
||||
capabilities: CONTROL_CENTER_CAPABILITIES,
|
||||
respond: respondControlCenter,
|
||||
},
|
||||
|
||||
'admin.candidates': {
|
||||
id: 'admin.candidates',
|
||||
page: 'Candidates Analysis',
|
||||
tagline: 'Analyse talent supply across the platform and see where the risk sits in the candidate pool.',
|
||||
placeholders: [
|
||||
'How healthy is talent supply?',
|
||||
'Any candidate risk flags?',
|
||||
'What are the recruitment trends?',
|
||||
],
|
||||
chips: [
|
||||
{ label: 'Recruitment Insights', prompt: 'Show me recruitment insights across the platform', capability: 'recruitment-insights' },
|
||||
{ label: 'Candidate Risk', prompt: 'Analyse risk across the candidate pool', capability: 'candidate-risk' },
|
||||
],
|
||||
capabilities: ADMIN_CANDIDATE_CAPABILITIES,
|
||||
respond: respondAdminCandidates,
|
||||
},
|
||||
|
||||
'admin.activity': {
|
||||
id: 'admin.activity',
|
||||
page: 'Activity',
|
||||
tagline: 'Summarize the audit trail, reconcile it against real records, and see how accounts are behaving.',
|
||||
placeholders: [
|
||||
'Summarize the audit trail…',
|
||||
'Who is most active?',
|
||||
'Are there any gaps in the log?',
|
||||
],
|
||||
chips: [
|
||||
{ label: 'Audit Summary', prompt: 'Summarize the audit trail', capability: 'audit-summary' },
|
||||
{ label: 'User Activity', prompt: 'Which accounts are most active?', capability: 'user-activity' },
|
||||
],
|
||||
capabilities: ACTIVITY_CAPABILITIES,
|
||||
respond: respondActivity,
|
||||
},
|
||||
|
||||
'talent.portal': {
|
||||
id: 'talent.portal',
|
||||
page: 'My Portal',
|
||||
tagline: 'Understand your score, improve your profile, and find the next thing worth learning.',
|
||||
placeholders: [
|
||||
'How do I earn more?',
|
||||
'Review my profile…',
|
||||
'What should I learn next?',
|
||||
],
|
||||
chips: [
|
||||
{ label: 'Review My Profile', prompt: 'Review my profile and tell me what is missing', capability: 'resume-review' },
|
||||
{ label: 'Career Guidance', prompt: 'How do I raise my career score?', capability: 'career-guidance' },
|
||||
{ label: 'What To Learn Next', prompt: 'What should I learn next?', capability: 'learning-recommendations' },
|
||||
],
|
||||
capabilities: TALENT_CAPABILITIES,
|
||||
respond: respondTalent,
|
||||
needsWorkerProfile: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const getContext = (id) => ASSISTANT_CONTEXTS[id] ?? null;
|
||||
22
src/components/ai-assistant/index.jsx
Normal file
22
src/components/ai-assistant/index.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Krow AI Assistant — the dashboard's contextual productivity assistant.
|
||||
*
|
||||
* 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
|
||||
* KrowAssistant the one assistant UI, reused on every page
|
||||
* provider.js the transport seam — local today, HTTP later
|
||||
*
|
||||
* Independent of Owliver, which remains a separate product. Nothing here imports
|
||||
* Owliver code, and the two never share UI.
|
||||
*/
|
||||
|
||||
export { AssistantPanelProvider, useAssistantPanel } from './AssistantPanelContext';
|
||||
export { AssistantTrigger } from './AssistantTrigger';
|
||||
export { AssistantPanel } from './AssistantPanel';
|
||||
export { default as KrowAssistant } from './KrowAssistant';
|
||||
export { ASSISTANT_CONTEXTS, getContext } from './contexts';
|
||||
export { EXCLUDED_ROUTES, enabledRoutes, resolveAssistantContext } from './placement';
|
||||
export { createHttpProvider, createLocalProvider } from './provider';
|
||||
186
src/components/ai-assistant/insights.js
Normal file
186
src/components/ai-assistant/insights.js
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Dashboard fact sheet.
|
||||
*
|
||||
* Every assistant answer is derived from here, using the same formulas the
|
||||
* dashboard components use — so the assistant can never state a figure that
|
||||
* contradicts the card or chart sitting next to it.
|
||||
*
|
||||
* This module is pure: dashboard data in, derived facts out. No UI, no
|
||||
* transport, no dependency on any AI product.
|
||||
*/
|
||||
|
||||
export const pct = (n, d) => (d ? Math.round((n / d) * 100) : 0);
|
||||
|
||||
export const plural = (n, word, irregular) =>
|
||||
`${n} ${n === 1 ? word : irregular || `${word}s`}`;
|
||||
|
||||
export const verb = (n, singular, plural_) => (n === 1 ? singular : plural_);
|
||||
|
||||
const avg = (values) =>
|
||||
values.length ? Math.round(values.reduce((a, b) => a + b, 0) / values.length) : 0;
|
||||
|
||||
const DAY_MS = 1000 * 60 * 60 * 24;
|
||||
|
||||
/** `today` is injected rather than read from the clock so answers are stable. */
|
||||
export function buildFacts({
|
||||
applications = [],
|
||||
postings = [],
|
||||
interviews = [],
|
||||
staff = [],
|
||||
profiles = [],
|
||||
activity = [],
|
||||
courses = [],
|
||||
profile = null,
|
||||
today = new Date(),
|
||||
}) {
|
||||
const scored = applications.filter((a) => a.ai_score > 0);
|
||||
const ranked = [...scored].sort((a, b) => b.ai_score - a.ai_score);
|
||||
const byStatus = (...statuses) => applications.filter((a) => statuses.includes(a.status));
|
||||
|
||||
const unscreened = byStatus('applied');
|
||||
const screened = byStatus('ai_screened', 'shortlisted', 'interview', 'hired');
|
||||
const interviewing = byStatus('interview');
|
||||
const hired = byStatus('hired');
|
||||
|
||||
const openPositions = postings.filter((p) => p.status === 'active');
|
||||
const applicationsFor = (id) => applications.filter((a) => a.job_posting_id === id);
|
||||
const completedInterviews = interviews.filter((i) => i.overall_interview_score > 0);
|
||||
|
||||
/* Time-to-hire — the created→updated span ImpactMetrics uses. */
|
||||
const timeToHire = hired.length
|
||||
? Math.round(avg(hired.map((a) =>
|
||||
Math.max(1, (new Date(a.updated_date) - new Date(a.created_date)) / DAY_MS))))
|
||||
: 0;
|
||||
|
||||
/* Open roles attracting nobody. */
|
||||
const starvedPositions = openPositions.filter((p) => applicationsFor(p.id).length === 0);
|
||||
|
||||
/* Roles resting on a single candidate — one withdrawal and the role reopens. */
|
||||
const singleCandidateRoles = openPositions
|
||||
.map((p) => ({ posting: p, viable: applicationsFor(p.id).filter((a) => a.ai_score >= 60) }))
|
||||
.filter((e) => e.viable.length === 1);
|
||||
|
||||
/* Strong candidates nobody has moved on — the most expensive kind of delay. */
|
||||
const stalled = ranked.filter((a) => a.ai_score >= 80 && a.status === 'ai_screened');
|
||||
|
||||
const funnel = [
|
||||
{ key: 'applied', label: 'Applied', count: applications.length },
|
||||
{ key: 'ai_screened', label: 'AI Screened', count: screened.length },
|
||||
{ key: 'shortlisted', label: 'Shortlisted', count: byStatus('shortlisted', 'interview', 'hired').length },
|
||||
{ key: 'interview', label: 'Interviewed', count: interviewing.length + hired.length },
|
||||
{ key: 'hired', label: 'Hired', count: hired.length },
|
||||
];
|
||||
|
||||
const transitions = funnel.slice(1).map((stage, i) => ({
|
||||
from: funnel[i].label,
|
||||
to: stage.label,
|
||||
rate: pct(stage.count, funnel[i].count),
|
||||
lost: Math.max(0, funnel[i].count - stage.count),
|
||||
}));
|
||||
|
||||
const bottleneck = [...transitions].sort((a, b) => a.rate - b.rate)[0] || null;
|
||||
|
||||
const byRole = openPositions.map((p) => {
|
||||
const apps = applicationsFor(p.id);
|
||||
const roleScored = apps.filter((a) => a.ai_score > 0);
|
||||
return {
|
||||
posting: p,
|
||||
title: p.title,
|
||||
category: p.role_category || 'Uncategorized',
|
||||
applied: apps.length,
|
||||
screened: apps.filter((a) => a.status !== 'applied').length,
|
||||
hired: apps.filter((a) => a.status === 'hired').length,
|
||||
avgScore: avg(roleScored.map((a) => a.ai_score)),
|
||||
qualified: roleScored.filter((a) => a.ai_score >= 70).length,
|
||||
};
|
||||
});
|
||||
|
||||
const since = (days) => {
|
||||
const cutoff = today.getTime() - days * DAY_MS;
|
||||
return activity.filter((e) => new Date(e.created_date).getTime() >= cutoff);
|
||||
};
|
||||
|
||||
const eventCounts = activity.reduce((acc, e) => {
|
||||
acc[e.event_type] = (acc[e.event_type] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return {
|
||||
today,
|
||||
|
||||
/* Applications */
|
||||
applications,
|
||||
total: applications.length,
|
||||
scored,
|
||||
ranked,
|
||||
top: ranked.slice(0, 3),
|
||||
weak: ranked.filter((a) => a.ai_score > 0 && a.ai_score < 40),
|
||||
unscreened,
|
||||
screened,
|
||||
interviewing,
|
||||
hired,
|
||||
stalled,
|
||||
avgScore: avg(scored.map((a) => a.ai_score)),
|
||||
hiredAvgScore: avg(hired.map((a) => a.ai_score)),
|
||||
missingCredentials: scored.filter((a) => !(a.certifications || []).length),
|
||||
narrowAvailability: scored.filter((a) => (a.availability || []).length <= 1),
|
||||
|
||||
/* Positions */
|
||||
postings,
|
||||
openPositions,
|
||||
starvedPositions,
|
||||
singleCandidateRoles,
|
||||
byRole,
|
||||
applicationsFor,
|
||||
|
||||
/* Interviews */
|
||||
interviews,
|
||||
completedInterviews,
|
||||
avgInterviewScore: avg(completedInterviews.map((i) => i.overall_interview_score)),
|
||||
interviewCompletion: pct(completedInterviews.length, interviews.length),
|
||||
flagged: interviews.filter((i) => (i.integrity_score ?? 100) < 100),
|
||||
|
||||
/* Hires */
|
||||
staff,
|
||||
unratedStaff: staff.filter((s) => !s.client_rating),
|
||||
timeToHire,
|
||||
hireRate: pct(hired.length, applications.length),
|
||||
|
||||
/* Funnel */
|
||||
funnel,
|
||||
transitions,
|
||||
bottleneck,
|
||||
|
||||
/* Talent pool */
|
||||
profiles,
|
||||
elite: profiles.filter((p) => (p.krow_score || 0) >= 90),
|
||||
unscoredProfiles: profiles.filter((p) => !(p.krow_score > 0)),
|
||||
|
||||
/* Activity */
|
||||
activity,
|
||||
activity7d: since(7),
|
||||
activity24h: since(1),
|
||||
eventCounts,
|
||||
|
||||
/* Signed-in worker (talent context only) */
|
||||
profile,
|
||||
courses,
|
||||
|
||||
/* Economics — mirrors ImpactMetrics */
|
||||
costSaved: scored.length * 45 + interviews.length * 80,
|
||||
hoursSaved: Math.round((scored.length * 15 + interviews.length * 30) / 60),
|
||||
standardizedPct: pct(scored.length, applications.length),
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Answer formatting ──────────────────────────────────────────────────── */
|
||||
|
||||
export const bullets = (items) => items.filter(Boolean).map((t) => `• ${t}`).join('\n');
|
||||
|
||||
export const numbered = (items) =>
|
||||
items.filter(Boolean).map((t, i) => `${i + 1}. ${t}`).join('\n\n');
|
||||
|
||||
export const heading = (text) => `**${text}**`;
|
||||
|
||||
/** Joins answer blocks, dropping empties so templates can use conditionals. */
|
||||
export const compose = (...blocks) => blocks.filter((b) => b !== null && b !== undefined && b !== '').join('\n');
|
||||
57
src/components/ai-assistant/placement.js
Normal file
57
src/components/ai-assistant/placement.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import { ASSISTANT_CONTEXTS } from './contexts';
|
||||
|
||||
/**
|
||||
* Where the Krow AI Assistant 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
|
||||
* decision lives in one table rather than being scattered across pages.
|
||||
*
|
||||
* Matching is exact, not prefix-based: `/candidates` must not leak an assistant
|
||||
* onto a future `/candidates/:id`, and `/positions/:id` must not inherit one
|
||||
* 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
|
||||
},
|
||||
talent: {
|
||||
'/employee': 'talent.portal', // My Portal
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 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: everything except My Portal.
|
||||
*/
|
||||
export const EXCLUDED_ROUTES = [
|
||||
'/positions', '/positions/new', '/positions/:id',
|
||||
'/hired', '/talent-pool', '/profile', '/apply',
|
||||
'/university', '/university/:id', '/me', '/identity',
|
||||
'/owliver', '/design-system',
|
||||
];
|
||||
|
||||
/** Returns the context for a role and path, or `null` to render no assistant. */
|
||||
export function resolveAssistantContext(role, pathname) {
|
||||
const id = PLACEMENT[role]?.[pathname];
|
||||
return id ? ASSISTANT_CONTEXTS[id] : null;
|
||||
}
|
||||
|
||||
/** Every enabled role/route pair — used by the placement verification. */
|
||||
export function enabledRoutes() {
|
||||
return Object.entries(PLACEMENT).flatMap(([role, routes]) =>
|
||||
Object.entries(routes).map(([path, contextId]) => ({ role, path, contextId }))
|
||||
);
|
||||
}
|
||||
139
src/components/ai-assistant/provider.js
Normal file
139
src/components/ai-assistant/provider.js
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Provider seam for the Krow AI Assistant.
|
||||
*
|
||||
* The UI never generates an answer and never knows where one came from. It calls
|
||||
* `provider.stream(request)` and renders the chunks. That single boundary is
|
||||
* what makes the backend swappable: today a local provider computes answers from
|
||||
* dashboard data, and later an HTTP provider can call a real service without a
|
||||
* single change to the component.
|
||||
*
|
||||
* ── Contract ────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* provider.id: string
|
||||
* provider.stream(request): AsyncIterable<string> // yields text deltas
|
||||
*
|
||||
* request = {
|
||||
* contextId: string, // e.g. 'employer.overview'
|
||||
* capability: string | null, // capability id, or null for free text
|
||||
* question: string, // what the user typed, or the capability label
|
||||
* facts: object, // the dashboard fact sheet (see insights.js)
|
||||
* signal: AbortSignal, // aborts an in-flight response
|
||||
* }
|
||||
*
|
||||
* Deltas rather than a whole string is deliberate: it is the shape every
|
||||
* streaming completion API returns, so the local provider and a future remote
|
||||
* one are interchangeable without the UI learning a second pattern.
|
||||
*/
|
||||
|
||||
import { getContext } from './contexts';
|
||||
|
||||
/** Words per delta. Chunking by word keeps reflow to once per word. */
|
||||
const WORDS_PER_CHUNK = 3;
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/**
|
||||
* Local provider — answers computed from the dashboard's own data.
|
||||
*
|
||||
* Deterministic: the same question against the same data returns the same
|
||||
* answer, which is what makes the assistant demonstrable and testable.
|
||||
*/
|
||||
export function createLocalProvider({ latency = 420, chunkDelay = 26 } = {}) {
|
||||
return {
|
||||
id: 'local',
|
||||
|
||||
async *stream({ contextId, capability, question, facts, signal }) {
|
||||
const context = getContext(contextId);
|
||||
if (!context) throw new Error(`Unknown assistant context: ${contextId}`);
|
||||
|
||||
const answer = capability
|
||||
? context.capabilities.find((c) => c.id === capability)?.run(facts)
|
||||
?? context.respond(question, facts)
|
||||
: context.respond(question, facts);
|
||||
|
||||
// A brief pause before the first delta, so the answer reads as considered
|
||||
// rather than precomputed.
|
||||
await sleep(latency);
|
||||
if (signal?.aborted) return;
|
||||
|
||||
const tokens = String(answer).split(/(\s+)/);
|
||||
for (let i = 0; i < tokens.length; i += WORDS_PER_CHUNK * 2) {
|
||||
if (signal?.aborted) return;
|
||||
yield tokens.slice(i, i + WORDS_PER_CHUNK * 2).join('');
|
||||
await sleep(chunkDelay);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP provider — the path to a real backend.
|
||||
*
|
||||
* Reads Server-Sent-Event style `data:` lines from a streaming endpoint and
|
||||
* yields the text deltas. Unused today; it exists so the shape of the
|
||||
* integration is settled rather than guessed at later. Swapping providers is a
|
||||
* one-line change in `AssistantProvider`.
|
||||
*
|
||||
* The request body sends `contextId`, `capability` and `question` — not the
|
||||
* fact sheet. Dashboard data should be fetched server-side from the caller's own
|
||||
* session rather than posted from the browser, so the client cannot ask about
|
||||
* records it is not entitled to see.
|
||||
*/
|
||||
export function createHttpProvider({ endpoint, headers = {} }) {
|
||||
if (!endpoint) throw new Error('createHttpProvider requires an endpoint');
|
||||
|
||||
return {
|
||||
id: 'http',
|
||||
|
||||
async *stream({ contextId, capability, question, signal }) {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
body: JSON.stringify({ contextId, capability, question }),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`Assistant request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
// The final element may be a partial line; keep it for the next read.
|
||||
buffer = lines.pop() ?? '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data:')) continue;
|
||||
const payload = line.slice(5).trim();
|
||||
if (!payload || payload === '[DONE]') continue;
|
||||
try {
|
||||
const parsed = JSON.parse(payload);
|
||||
if (parsed.delta) yield parsed.delta;
|
||||
} catch {
|
||||
// A non-JSON data line is treated as a raw delta.
|
||||
yield payload;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider the app uses.
|
||||
*
|
||||
* Local by default. Point `VITE_ASSISTANT_ENDPOINT` at a streaming endpoint to
|
||||
* switch, with no other code change.
|
||||
*/
|
||||
export function createAssistantProvider() {
|
||||
const endpoint = import.meta.env?.VITE_ASSISTANT_ENDPOINT;
|
||||
return endpoint ? createHttpProvider({ endpoint }) : createLocalProvider();
|
||||
}
|
||||
156
src/components/ai-assistant/useAssistant.js
Normal file
156
src/components/ai-assistant/useAssistant.js
Normal file
@@ -0,0 +1,156 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
useApplications, useCourses, useInterviews, useJobPostings, useStaff,
|
||||
useUserActivity, useWorkerProfile, useWorkerProfiles,
|
||||
} from '@/lib/krowHooks';
|
||||
import { buildFacts } from './insights';
|
||||
import { createAssistantProvider } from './provider';
|
||||
|
||||
/** One provider instance for the app's lifetime. */
|
||||
const provider = createAssistantProvider();
|
||||
|
||||
/**
|
||||
* Reads the same React Query caches the pages render from, so the assistant
|
||||
* costs no extra requests and cannot be looking at a different snapshot than the
|
||||
* card beside it.
|
||||
*/
|
||||
export function useAssistantFacts({ needsWorkerProfile = false } = {}) {
|
||||
const { data: applications = [] } = useApplications();
|
||||
const { data: postings = [] } = useJobPostings();
|
||||
const { data: interviews = [] } = useInterviews();
|
||||
const { data: staff = [] } = useStaff();
|
||||
const { data: profiles = [] } = useWorkerProfiles();
|
||||
const { data: activity = [] } = useUserActivity();
|
||||
const { data: courses = [] } = useCourses();
|
||||
|
||||
// Gated: this query creates the record if absent, so it must not run for
|
||||
// employer or admin sessions.
|
||||
const { data: profile = null } = useWorkerProfile({ enabled: needsWorkerProfile });
|
||||
|
||||
return React.useMemo(
|
||||
() => buildFacts({
|
||||
applications, postings, interviews, staff, profiles, activity, courses,
|
||||
profile: needsWorkerProfile ? profile : null,
|
||||
}),
|
||||
[applications, postings, interviews, staff, profiles, activity, courses, profile, needsWorkerProfile]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Conversation state for one page context.
|
||||
*
|
||||
* Owns the thread, the in-flight response, and abort handling. The thread is
|
||||
* kept per context in sessionStorage so navigating away from Candidates and back
|
||||
* does not silently discard the conversation — while a stale thread from last
|
||||
* week is still not resurrected.
|
||||
*/
|
||||
export function useConversation({ contextId, facts }) {
|
||||
const storageKey = `krow_assistant:${contextId}`;
|
||||
|
||||
const [messages, setMessages] = React.useState([]);
|
||||
const [pending, setPending] = React.useState(null); // { question, text, thinking }
|
||||
const [error, setError] = React.useState(null);
|
||||
const abortRef = React.useRef(null);
|
||||
|
||||
/* Load the stored thread, and reload when the context changes. */
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(storageKey);
|
||||
setMessages(raw ? JSON.parse(raw) : []);
|
||||
} catch {
|
||||
setMessages([]);
|
||||
}
|
||||
setPending(null);
|
||||
setError(null);
|
||||
}, [storageKey]);
|
||||
|
||||
const persist = React.useCallback((next) => {
|
||||
setMessages(next);
|
||||
try {
|
||||
sessionStorage.setItem(storageKey, JSON.stringify(next));
|
||||
} catch {
|
||||
// Quota or private mode — the thread stays in memory only.
|
||||
}
|
||||
}, [storageKey]);
|
||||
|
||||
/* Abort any in-flight response when the context changes or we unmount. */
|
||||
React.useEffect(() => () => abortRef.current?.abort(), [storageKey]);
|
||||
|
||||
// The stream callback runs outside render, so it needs the latest thread
|
||||
// rather than the value captured when the request started.
|
||||
const messagesRef = React.useRef(messages);
|
||||
messagesRef.current = messages;
|
||||
|
||||
const send = React.useCallback(async ({ question, capability = null }) => {
|
||||
const text = String(question).trim();
|
||||
if (!text) return;
|
||||
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
const withUser = [...messagesRef.current, { role: 'user', text }];
|
||||
messagesRef.current = withUser;
|
||||
persist(withUser);
|
||||
setError(null);
|
||||
setPending({ question: text, text: '', thinking: true });
|
||||
|
||||
try {
|
||||
let assembled = '';
|
||||
for await (const delta of provider.stream({
|
||||
contextId, capability, question: text, facts, signal: controller.signal,
|
||||
})) {
|
||||
if (controller.signal.aborted) break;
|
||||
assembled += delta;
|
||||
setPending({ question: text, text: assembled, thinking: false });
|
||||
}
|
||||
|
||||
if (controller.signal.aborted) {
|
||||
// Keep whatever streamed before the user stopped it — discarding a
|
||||
// half-written answer loses information the user was already reading.
|
||||
if (assembled) {
|
||||
const next = [...messagesRef.current, { role: 'assistant', text: assembled, stopped: true }];
|
||||
messagesRef.current = next;
|
||||
persist(next);
|
||||
}
|
||||
} else {
|
||||
const next = [...messagesRef.current, { role: 'assistant', text: assembled }];
|
||||
messagesRef.current = next;
|
||||
persist(next);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e?.name !== 'AbortError') {
|
||||
setError('I could not complete that. Try again in a moment.');
|
||||
}
|
||||
} finally {
|
||||
setPending(null);
|
||||
abortRef.current = null;
|
||||
}
|
||||
}, [contextId, facts, persist]);
|
||||
|
||||
const stop = React.useCallback(() => abortRef.current?.abort(), []);
|
||||
|
||||
const reset = React.useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
messagesRef.current = [];
|
||||
setMessages([]);
|
||||
setPending(null);
|
||||
setError(null);
|
||||
try {
|
||||
sessionStorage.removeItem(storageKey);
|
||||
} catch {
|
||||
// Ignore.
|
||||
}
|
||||
}, [storageKey]);
|
||||
|
||||
return {
|
||||
messages,
|
||||
pending,
|
||||
error,
|
||||
busy: Boolean(pending),
|
||||
send,
|
||||
stop,
|
||||
reset,
|
||||
providerId: provider.id,
|
||||
};
|
||||
}
|
||||
100
src/components/ds/ActivityCard.jsx
Normal file
100
src/components/ds/ActivityCard.jsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Avatar } from '@/components/ds/Avatar';
|
||||
import { Surface } from '@/components/ds/Surface';
|
||||
import { StatusBadge } from '@/components/ds/StatusBadge';
|
||||
|
||||
/**
|
||||
* ActivityCard — one event in a feed: who did what, when.
|
||||
*
|
||||
* Renders as a row by default (feeds are dense) and as a bordered card when
|
||||
* `variant="card"`. Either way the anatomy is fixed — actor, sentence, meta,
|
||||
* trailing slot — so a feed built from mixed event types stays aligned.
|
||||
*/
|
||||
export function ActivityCard({
|
||||
/** Person or system that acted. */
|
||||
actor,
|
||||
actorSrc,
|
||||
/** Icon shown instead of an avatar, for system events. */
|
||||
icon: Icon,
|
||||
iconTone = 'soft',
|
||||
/** What happened — "Screened Sofia Mendez". */
|
||||
title,
|
||||
/** Supporting detail. */
|
||||
description,
|
||||
timestamp,
|
||||
status,
|
||||
/** Right-aligned slot — a score, a button. */
|
||||
trailing,
|
||||
variant = 'row',
|
||||
onClick,
|
||||
className,
|
||||
}) {
|
||||
const iconTones = {
|
||||
soft: 'bg-krow-blue-tint text-krow-blue',
|
||||
brand: 'bg-krow-blue text-white',
|
||||
success: 'bg-success-muted text-success',
|
||||
warning: 'bg-warning-muted text-warning',
|
||||
destructive: 'bg-destructive-muted text-destructive',
|
||||
neutral: 'bg-surface-sunken text-ink-3',
|
||||
};
|
||||
|
||||
const body = (
|
||||
<>
|
||||
{Icon ? (
|
||||
<span className={cn('grid place-items-center w-9 h-9 rounded-xl shrink-0', iconTones[iconTone])}>
|
||||
<Icon className="w-4 h-4" aria-hidden="true" />
|
||||
</span>
|
||||
) : (
|
||||
<Avatar name={actor} src={actorSrc} size="default" />
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-body-sm text-ink-1">
|
||||
{actor && <span className="font-semibold">{actor}</span>}
|
||||
{actor && title && ' '}
|
||||
{title && <span className={actor ? 'text-ink-2' : 'font-medium'}>{title}</span>}
|
||||
</p>
|
||||
{description && <p className="text-caption text-ink-3 mt-0.5 truncate-2">{description}</p>}
|
||||
{timestamp && <p className="text-caption text-ink-4 mt-1">{timestamp}</p>}
|
||||
</div>
|
||||
|
||||
{(status || trailing) && (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{status && <StatusBadge status={status} size="sm" />}
|
||||
{trailing}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (variant === 'card') {
|
||||
return (
|
||||
<Surface
|
||||
as={onClick ? 'button' : 'div'}
|
||||
onClick={onClick}
|
||||
interactive={onClick ? true : undefined}
|
||||
variant="solid"
|
||||
radius="lg"
|
||||
padding="sm"
|
||||
className={cn('flex items-start gap-3 w-full text-left', className)}
|
||||
>
|
||||
{body}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
const Comp = onClick ? 'button' : 'div';
|
||||
return (
|
||||
<Comp
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex items-start gap-3 w-full text-left rounded-xl p-2.5 transition-colors duration-fast',
|
||||
onClick && 'hover:bg-white/60 cursor-pointer focus-ring',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{body}
|
||||
</Comp>
|
||||
);
|
||||
}
|
||||
71
src/components/ds/Alert.jsx
Normal file
71
src/components/ds/Alert.jsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import * as React from 'react';
|
||||
import { AlertCircle, CheckCircle2, Info, TriangleAlert, X } from 'lucide-react';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const alertVariants = cva('flex items-start gap-3 rounded-xl border p-4 animate-slide-down', {
|
||||
variants: {
|
||||
tone: {
|
||||
info: 'bg-info-muted border-krow-blue/20 text-ink-2',
|
||||
success: 'bg-success-muted border-success/20 text-ink-2',
|
||||
warning: 'bg-warning-muted border-warning/25 text-ink-2',
|
||||
destructive: 'bg-destructive-muted border-destructive/20 text-ink-2',
|
||||
neutral: 'bg-surface-subtle border-border text-ink-2',
|
||||
},
|
||||
},
|
||||
defaultVariants: { tone: 'info' },
|
||||
});
|
||||
|
||||
const ICONS = {
|
||||
info: Info,
|
||||
success: CheckCircle2,
|
||||
warning: TriangleAlert,
|
||||
destructive: AlertCircle,
|
||||
neutral: Info,
|
||||
};
|
||||
|
||||
const ICON_COLORS = {
|
||||
info: 'text-krow-blue',
|
||||
success: 'text-success',
|
||||
warning: 'text-warning',
|
||||
destructive: 'text-destructive',
|
||||
neutral: 'text-ink-4',
|
||||
};
|
||||
|
||||
/**
|
||||
* Alert — an inline, persistent message about the state of the page.
|
||||
*
|
||||
* For transient confirmation of something the user just did, use `toast`
|
||||
* instead: an Alert that appears and disappears is easy to miss, and a toast
|
||||
* that persists is noise.
|
||||
*/
|
||||
export function Alert({ tone = 'info', title, children, icon, onDismiss, action, className, ...props }) {
|
||||
const Icon = icon || ICONS[tone];
|
||||
|
||||
return (
|
||||
<div role="alert" className={cn(alertVariants({ tone }), className)} {...props}>
|
||||
{Icon && <Icon className={cn('w-4.5 h-4.5 shrink-0 mt-0.5', ICON_COLORS[tone])} aria-hidden="true" />}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{title && <p className="text-body-sm font-semibold text-ink-1">{title}</p>}
|
||||
{children && (
|
||||
<div className={cn('text-body-sm text-ink-2 leading-relaxed', title && 'mt-0.5')}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
{action && <div className="mt-3">{action}</div>}
|
||||
</div>
|
||||
|
||||
{onDismiss && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
aria-label="Dismiss"
|
||||
className="grid place-items-center w-6 h-6 rounded-md text-ink-4 hover:text-ink-1 hover:bg-black/5 transition-colors shrink-0 focus-ring-inset"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
119
src/components/ds/Avatar.jsx
Normal file
119
src/components/ds/Avatar.jsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import * as React from 'react';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const SIZES = {
|
||||
xs: 'w-6 h-6 text-[9px]',
|
||||
sm: 'w-7 h-7 text-[10px]',
|
||||
default: 'w-9 h-9 text-[12px]',
|
||||
lg: 'w-11 h-11 text-body-sm',
|
||||
xl: 'w-16 h-16 text-title',
|
||||
'2xl': 'w-20 h-20 text-title-lg',
|
||||
};
|
||||
|
||||
const avatarVariants = cva(
|
||||
'relative inline-grid place-items-center shrink-0 overflow-hidden font-semibold select-none',
|
||||
{
|
||||
variants: {
|
||||
shape: { circle: 'rounded-full', rounded: 'rounded-xl' },
|
||||
tone: {
|
||||
brand: 'bg-krow-blue text-white',
|
||||
navy: 'bg-krow-navy text-white',
|
||||
soft: 'bg-krow-blue-tint text-krow-blue',
|
||||
neutral: 'bg-surface-sunken text-ink-3',
|
||||
},
|
||||
},
|
||||
defaultVariants: { shape: 'circle', tone: 'brand' },
|
||||
}
|
||||
);
|
||||
|
||||
/** First letters of the first two words — "Chef Antoine Dubois" → "CA". */
|
||||
export function initialsFrom(name = '') {
|
||||
const words = String(name).trim().split(/\s+/).filter(Boolean);
|
||||
if (words.length === 0) return '?';
|
||||
return words.slice(0, 2).map((w) => w[0]).join('').toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Avatar — a photo when one exists, initials otherwise.
|
||||
*
|
||||
* The app shows people constantly and most have no photo, so initials are a
|
||||
* first-class state rather than a fallback afterthought. A failed image load
|
||||
* degrades to initials instead of a broken-image icon.
|
||||
*/
|
||||
export const Avatar = React.forwardRef(
|
||||
({ name, src, size = 'default', shape, tone, status, className, ...props }, ref) => {
|
||||
const [failed, setFailed] = React.useState(false);
|
||||
const showImage = src && !failed;
|
||||
|
||||
// Reset when the subject changes, so a new src gets its own chance to load.
|
||||
React.useEffect(() => setFailed(false), [src]);
|
||||
|
||||
return (
|
||||
<span ref={ref} className={cn('relative inline-block shrink-0', className)} {...props}>
|
||||
<span
|
||||
className={cn(avatarVariants({ shape, tone }), SIZES[size] || SIZES.default)}
|
||||
aria-hidden={showImage ? 'true' : undefined}
|
||||
>
|
||||
{showImage ? (
|
||||
<img
|
||||
src={src}
|
||||
alt={name ? `${name}` : ''}
|
||||
onError={() => setFailed(true)}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span aria-label={name || undefined}>{initialsFrom(name)}</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{status && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute bottom-0 right-0 block rounded-full ring-2 ring-white',
|
||||
size === 'xs' || size === 'sm' ? 'w-2 h-2' : 'w-2.5 h-2.5',
|
||||
status === 'online' && 'bg-success',
|
||||
status === 'busy' && 'bg-warning',
|
||||
status === 'offline' && 'bg-ink-4'
|
||||
)}
|
||||
aria-label={status}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
);
|
||||
Avatar.displayName = 'Avatar';
|
||||
|
||||
/**
|
||||
* AvatarGroup — overlapping avatars with a "+N" remainder, for showing several
|
||||
* people in a tight row (applicants on a position, endorsers on a skill).
|
||||
*/
|
||||
export function AvatarGroup({ people = [], max = 4, size = 'sm', className }) {
|
||||
const visible = people.slice(0, max);
|
||||
const overflow = people.length - visible.length;
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center', className)}>
|
||||
{visible.map((person, i) => (
|
||||
<Avatar
|
||||
key={person.id ?? person.name ?? i}
|
||||
name={person.name}
|
||||
src={person.src}
|
||||
size={size}
|
||||
className={cn('ring-2 ring-white', i > 0 && '-ml-2')}
|
||||
/>
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
'grid place-items-center rounded-full bg-surface-sunken text-ink-3 font-semibold ring-2 ring-white -ml-2',
|
||||
SIZES[size] || SIZES.default
|
||||
)}
|
||||
>
|
||||
+{overflow}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
172
src/components/ds/ChartContainer.jsx
Normal file
172
src/components/ds/ChartContainer.jsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import * as React from 'react';
|
||||
import { ResponsiveContainer } from 'recharts';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Surface } from '@/components/ds/Surface';
|
||||
import { SectionHeader } from '@/components/ds/SectionHeader';
|
||||
import { EmptyState } from '@/components/ds/EmptyState';
|
||||
import { SkeletonChart } from '@/components/ds/Skeleton';
|
||||
import { BarChart3 } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* The chart palette. Recharts needs literal colours, not Tailwind classes, so
|
||||
* the brand values are mirrored here — this array is the only place a chart
|
||||
* should get a colour from.
|
||||
*/
|
||||
export const CHART_COLORS = ['#0A39DF', '#F9E547', '#333F48', '#F8E08E', '#D1E0D7', '#082EB4'];
|
||||
|
||||
export const CHART_TONES = {
|
||||
brand: '#0A39DF',
|
||||
brandDark: '#082EB4',
|
||||
accent: '#F9E547',
|
||||
accentPale: '#F8E08E',
|
||||
navy: '#333F48',
|
||||
mint: '#D1E0D7',
|
||||
grid: 'hsl(150 8% 87%)',
|
||||
axis: 'hsl(220 9% 64%)',
|
||||
};
|
||||
|
||||
/** Shared axis props so every chart's ticks match. */
|
||||
export const AXIS_PROPS = {
|
||||
stroke: CHART_TONES.axis,
|
||||
tick: { fontSize: 11, fill: CHART_TONES.axis },
|
||||
tickLine: false,
|
||||
axisLine: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* A tooltip matching the design system's surfaces. Recharts' default is a plain
|
||||
* white box that ignores the token set.
|
||||
*/
|
||||
export function ChartTooltip({ active, payload, label, valueFormatter, labelFormatter }) {
|
||||
if (!active || !payload?.length) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-surface px-3 py-2 shadow-lg">
|
||||
{label != null && (
|
||||
<p className="text-caption font-semibold text-ink-1 mb-1">
|
||||
{labelFormatter ? labelFormatter(label) : label}
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-0.5">
|
||||
{payload.map((entry, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-caption">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full shrink-0"
|
||||
style={{ backgroundColor: entry.color || entry.fill }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-ink-3">{entry.name}</span>
|
||||
<span className="ml-auto font-semibold text-ink-1 tabular-nums">
|
||||
{valueFormatter ? valueFormatter(entry.value) : entry.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A legend that reads as part of the card rather than as chart furniture.
|
||||
* Items: `{ label, color, value? }`.
|
||||
*/
|
||||
export function ChartLegend({ items = [], className }) {
|
||||
return (
|
||||
<div className={cn('flex flex-wrap items-center gap-x-4 gap-y-2', className)}>
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-sm shrink-0"
|
||||
style={{ backgroundColor: item.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-caption text-ink-3">{item.label}</span>
|
||||
{item.value != null && (
|
||||
<span className="text-caption font-semibold text-ink-1 tabular-nums">{item.value}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ChartContainer — the card a chart lives in.
|
||||
*
|
||||
* Owns the title row, the fixed plot height, the loading skeleton, the empty
|
||||
* state, and an optional headline figure. Charts inside it are pure Recharts
|
||||
* children, so swapping a bar chart for a line chart changes nothing else.
|
||||
*/
|
||||
export function ChartContainer({
|
||||
title,
|
||||
subtitle,
|
||||
icon,
|
||||
/** A big number in the top-right — an average, a total. */
|
||||
headline,
|
||||
headlineLabel,
|
||||
/** Right-aligned controls, e.g. a SegmentedToggle for the range. */
|
||||
actions,
|
||||
legend,
|
||||
height = 260,
|
||||
loading = false,
|
||||
isEmpty = false,
|
||||
emptyMessage = 'No data for this period',
|
||||
children,
|
||||
/** Skips ResponsiveContainer for children that are not Recharts charts. */
|
||||
raw = false,
|
||||
footer,
|
||||
className,
|
||||
}) {
|
||||
if (loading) return <SkeletonChart className={className} height={height} />;
|
||||
|
||||
return (
|
||||
<Surface radius="xl" padding="lg" className={cn('flex flex-col', className)}>
|
||||
{(title || headline || actions) && (
|
||||
<div className="flex items-start justify-between gap-4 mb-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
{title && (
|
||||
<SectionHeader
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
icon={icon}
|
||||
className={cn(subtitle ? 'mb-0' : 'mb-0')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 shrink-0">
|
||||
{actions}
|
||||
{headline != null && (
|
||||
<div className="text-right">
|
||||
<div className="text-title-lg font-heading font-bold text-krow-blue tabular-nums leading-none">
|
||||
{headline}
|
||||
</div>
|
||||
{headlineLabel && (
|
||||
<div className="text-[10px] uppercase tracking-wide text-ink-4 mt-1">
|
||||
{headlineLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{legend && <ChartLegend items={legend} className="mb-4 mt-3" />}
|
||||
|
||||
<div className={cn('flex-1 min-w-0', !legend && 'mt-4')} style={{ minHeight: height }}>
|
||||
{isEmpty ? (
|
||||
<EmptyState icon={BarChart3} variant="filtered" title="Nothing to chart" description={emptyMessage} size="sm" />
|
||||
) : raw ? (
|
||||
children
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
{children}
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{footer && <div className="mt-4 pt-4 border-t border-border">{footer}</div>}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
309
src/components/ds/DataTable.jsx
Normal file
309
src/components/ds/DataTable.jsx
Normal file
@@ -0,0 +1,309 @@
|
||||
import * as React from 'react';
|
||||
import { ArrowDown, ArrowUp, ChevronsUpDown, Inbox } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Surface } from '@/components/ds/Surface';
|
||||
import { EmptyState } from '@/components/ds/EmptyState';
|
||||
import { SkeletonTable } from '@/components/ds/Skeleton';
|
||||
import { LoadingOverlay } from '@/components/ds/LoadingState';
|
||||
import { Pagination } from '@/components/ds/Pagination';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
|
||||
/**
|
||||
* DataTable — the app's one table.
|
||||
*
|
||||
* Columns are declared as data:
|
||||
*
|
||||
* { key, header, accessor?, sortValue?, cell?, align?, width?, sortable?, hideBelow? }
|
||||
*
|
||||
* Sorting, selection and pagination are handled internally by default, but each
|
||||
* can be lifted by passing the corresponding value + handler — so the same
|
||||
* component serves a simple static list and a server-paginated view.
|
||||
*
|
||||
* `hideBelow: 'md'` drops a column on small screens, which is what keeps the
|
||||
* table usable on mobile without a horizontal scroll for every view.
|
||||
*/
|
||||
export function DataTable({
|
||||
columns = [],
|
||||
rows = [],
|
||||
/** Stable row identity. Falls back to `row.id`, then the index. */
|
||||
getRowId = (row, i) => row?.id ?? i,
|
||||
|
||||
loading = false,
|
||||
/** Dims existing rows instead of replacing them — for refetches. */
|
||||
refreshing = false,
|
||||
|
||||
/* Sorting — omit `sort` to let the table own it */
|
||||
sort: controlledSort,
|
||||
onSortChange,
|
||||
defaultSort,
|
||||
|
||||
/* Selection — pass `selectedIds` to enable */
|
||||
selectedIds,
|
||||
onSelectionChange,
|
||||
|
||||
/* Pagination — pass `pageSize` to enable */
|
||||
pageSize: initialPageSize,
|
||||
page: controlledPage,
|
||||
onPageChange,
|
||||
totalItems,
|
||||
pageSizeOptions,
|
||||
|
||||
onRowClick,
|
||||
emptyState,
|
||||
/** True when filters are active, so the empty copy can say so. */
|
||||
isFiltered = false,
|
||||
onClearFilters,
|
||||
|
||||
caption,
|
||||
className,
|
||||
rowClassName,
|
||||
stickyHeader = false,
|
||||
}) {
|
||||
/* ── Sorting ─────────────────────────────────────────────────────────── */
|
||||
const [internalSort, setInternalSort] = React.useState(defaultSort ?? null);
|
||||
const sort = controlledSort !== undefined ? controlledSort : internalSort;
|
||||
|
||||
const setSort = (next) => {
|
||||
if (onSortChange) onSortChange(next);
|
||||
if (controlledSort === undefined) setInternalSort(next);
|
||||
};
|
||||
|
||||
const toggleSort = (column) => {
|
||||
if (sort?.key !== column.key) return setSort({ key: column.key, direction: 'asc' });
|
||||
if (sort.direction === 'asc') return setSort({ key: column.key, direction: 'desc' });
|
||||
// Third click clears sorting, so the original order is reachable.
|
||||
return setSort(null);
|
||||
};
|
||||
|
||||
const valueFor = (column, row) => {
|
||||
if (column.sortValue) return column.sortValue(row);
|
||||
if (column.accessor) return column.accessor(row);
|
||||
return row?.[column.key];
|
||||
};
|
||||
|
||||
const sortedRows = React.useMemo(() => {
|
||||
// A controlled `sort` means the parent already ordered the rows.
|
||||
if (!sort || controlledSort !== undefined) return rows;
|
||||
const column = columns.find((c) => c.key === sort.key);
|
||||
if (!column) return rows;
|
||||
|
||||
const factor = sort.direction === 'desc' ? -1 : 1;
|
||||
return [...rows].sort((a, b) => {
|
||||
const av = valueFor(column, a);
|
||||
const bv = valueFor(column, b);
|
||||
if (av == null && bv == null) return 0;
|
||||
if (av == null) return 1;
|
||||
if (bv == null) return -1;
|
||||
if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * factor;
|
||||
return String(av).localeCompare(String(bv)) * factor;
|
||||
});
|
||||
}, [rows, sort, columns, controlledSort]);
|
||||
|
||||
/* ── Pagination ──────────────────────────────────────────────────────── */
|
||||
const paginated = initialPageSize != null;
|
||||
const [internalPage, setInternalPage] = React.useState(1);
|
||||
const [pageSize, setPageSize] = React.useState(initialPageSize ?? 25);
|
||||
const page = controlledPage !== undefined ? controlledPage : internalPage;
|
||||
|
||||
const setPage = (next) => {
|
||||
if (onPageChange) onPageChange(next);
|
||||
if (controlledPage === undefined) setInternalPage(next);
|
||||
};
|
||||
|
||||
// Snap back to page 1 whenever the result set changes underneath us,
|
||||
// otherwise filtering to 3 rows while on page 4 shows an empty table.
|
||||
const rowCount = rows.length;
|
||||
React.useEffect(() => {
|
||||
if (controlledPage === undefined) setInternalPage(1);
|
||||
}, [rowCount, sort, controlledPage]);
|
||||
|
||||
const serverPaginated = totalItems != null && onPageChange != null;
|
||||
const pageCount = paginated
|
||||
? Math.max(1, Math.ceil((totalItems ?? sortedRows.length) / pageSize))
|
||||
: 1;
|
||||
|
||||
const visibleRows = React.useMemo(() => {
|
||||
if (!paginated || serverPaginated) return sortedRows;
|
||||
const start = (page - 1) * pageSize;
|
||||
return sortedRows.slice(start, start + pageSize);
|
||||
}, [sortedRows, paginated, serverPaginated, page, pageSize]);
|
||||
|
||||
/* ── Selection ───────────────────────────────────────────────────────── */
|
||||
const selectable = selectedIds !== undefined;
|
||||
const selected = React.useMemo(() => new Set(selectedIds || []), [selectedIds]);
|
||||
const pageIds = visibleRows.map((row, i) => getRowId(row, i));
|
||||
const allOnPageSelected = pageIds.length > 0 && pageIds.every((id) => selected.has(id));
|
||||
const someOnPageSelected = pageIds.some((id) => selected.has(id));
|
||||
|
||||
const toggleAll = () => {
|
||||
if (allOnPageSelected) {
|
||||
onSelectionChange?.(selectedIds.filter((id) => !pageIds.includes(id)));
|
||||
} else {
|
||||
onSelectionChange?.([...new Set([...selectedIds, ...pageIds])]);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRow = (id) => {
|
||||
onSelectionChange?.(
|
||||
selected.has(id) ? selectedIds.filter((s) => s !== id) : [...selectedIds, id]
|
||||
);
|
||||
};
|
||||
|
||||
/* ── Render ──────────────────────────────────────────────────────────── */
|
||||
const hideBelowClass = { sm: 'hidden sm:table-cell', md: 'hidden md:table-cell', lg: 'hidden lg:table-cell' };
|
||||
const alignClass = { right: 'text-right', center: 'text-center' };
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Surface padding="none" radius="xl" className={cn('overflow-hidden', className)}>
|
||||
<SkeletonTable rows={initialPageSize && initialPageSize < 8 ? initialPageSize : 6} columns={columns.length} />
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
if (sortedRows.length === 0) {
|
||||
return (
|
||||
<Surface padding="none" radius="xl" className={cn('overflow-hidden', className)}>
|
||||
{emptyState || (
|
||||
<EmptyState
|
||||
icon={Inbox}
|
||||
variant={isFiltered ? 'filtered' : 'empty'}
|
||||
title={isFiltered ? 'No matching results' : 'Nothing here yet'}
|
||||
description={
|
||||
isFiltered
|
||||
? 'No rows match the current filters. Try widening or clearing them.'
|
||||
: 'Rows will appear here once there is data.'
|
||||
}
|
||||
action={isFiltered && onClearFilters ? { label: 'Clear filters', onClick: onClearFilters } : undefined}
|
||||
/>
|
||||
)}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
<Surface padding="none" radius="xl" className="relative overflow-hidden">
|
||||
<LoadingOverlay show={refreshing} />
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
{caption && <caption className="sr-only">{caption}</caption>}
|
||||
|
||||
<thead className={cn(stickyHeader && 'sticky top-0 z-10')}>
|
||||
<tr className="bg-surface-subtle border-b border-border">
|
||||
{selectable && (
|
||||
<th scope="col" className="w-10 px-4 py-3">
|
||||
<Checkbox
|
||||
checked={allOnPageSelected ? true : someOnPageSelected ? 'indeterminate' : false}
|
||||
onCheckedChange={toggleAll}
|
||||
aria-label="Select all rows on this page"
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
|
||||
{columns.map((column) => {
|
||||
const active = sort?.key === column.key;
|
||||
const SortIcon = !active ? ChevronsUpDown : sort.direction === 'asc' ? ArrowUp : ArrowDown;
|
||||
|
||||
return (
|
||||
<th
|
||||
key={column.key}
|
||||
scope="col"
|
||||
style={column.width ? { width: column.width } : undefined}
|
||||
aria-sort={active ? (sort.direction === 'asc' ? 'ascending' : 'descending') : undefined}
|
||||
className={cn(
|
||||
'px-4 py-3 text-overline uppercase text-ink-4 font-heading whitespace-nowrap',
|
||||
column.align ? alignClass[column.align] : 'text-left',
|
||||
column.hideBelow && hideBelowClass[column.hideBelow]
|
||||
)}
|
||||
>
|
||||
{column.sortable ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSort(column)}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-sm transition-colors hover:text-ink-2 focus-ring-inset',
|
||||
active && 'text-krow-blue'
|
||||
)}
|
||||
>
|
||||
{column.header}
|
||||
<SortIcon className="w-3 h-3" aria-hidden="true" />
|
||||
</button>
|
||||
) : (
|
||||
column.header
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{visibleRows.map((row, i) => {
|
||||
const id = getRowId(row, i);
|
||||
const isSelected = selected.has(id);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={id}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
className={cn(
|
||||
'border-b border-border last:border-0 transition-colors duration-fast',
|
||||
onRowClick && 'cursor-pointer',
|
||||
isSelected ? 'bg-krow-blue-tint' : 'hover:bg-surface-subtle',
|
||||
typeof rowClassName === 'function' ? rowClassName(row) : rowClassName
|
||||
)}
|
||||
>
|
||||
{selectable && (
|
||||
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggleRow(id)}
|
||||
aria-label={`Select row ${i + 1}`}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
|
||||
{columns.map((column) => (
|
||||
<td
|
||||
key={column.key}
|
||||
className={cn(
|
||||
'px-4 py-3.5 text-body-sm text-ink-2 align-middle',
|
||||
column.align ? alignClass[column.align] : 'text-left',
|
||||
column.hideBelow && hideBelowClass[column.hideBelow],
|
||||
column.className
|
||||
)}
|
||||
>
|
||||
{column.cell
|
||||
? column.cell(row)
|
||||
: column.accessor
|
||||
? column.accessor(row)
|
||||
: row?.[column.key] ?? '—'}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Surface>
|
||||
|
||||
{paginated && (
|
||||
<Pagination
|
||||
page={page}
|
||||
pageCount={pageCount}
|
||||
onPageChange={setPage}
|
||||
totalItems={totalItems ?? sortedRows.length}
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={(size) => {
|
||||
setPageSize(size);
|
||||
setPage(1);
|
||||
}}
|
||||
pageSizeOptions={pageSizeOptions}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
src/components/ds/Drawer.jsx
Normal file
75
src/components/ds/Drawer.jsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
|
||||
const WIDTHS = {
|
||||
sm: 'w-full sm:w-80',
|
||||
default: 'w-full sm:w-[28rem]',
|
||||
lg: 'w-full sm:w-[36rem]',
|
||||
xl: 'w-full sm:w-[44rem]',
|
||||
};
|
||||
|
||||
/**
|
||||
* Drawer — an edge-anchored panel for detail views, filters and mobile nav.
|
||||
*
|
||||
* Same header/body/footer contract as Modal, so the two are interchangeable:
|
||||
* a detail view can be a Drawer on desktop and a bottom sheet on mobile without
|
||||
* its contents changing.
|
||||
*/
|
||||
export function Drawer({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
side = 'right',
|
||||
size = 'default',
|
||||
footer,
|
||||
children,
|
||||
className,
|
||||
/** Hides the header for a fully custom panel (the mobile nav does this). */
|
||||
bare = false,
|
||||
}) {
|
||||
const isVertical = side === 'top' || side === 'bottom';
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side={side}
|
||||
className={cn(
|
||||
'p-0 gap-0 flex flex-col',
|
||||
isVertical ? 'h-auto max-h-[85vh]' : WIDTHS[size],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{!bare && (
|
||||
<SheetHeader className="px-5 pt-5 pb-4 border-b border-border shrink-0 space-y-0 text-left">
|
||||
<div className="flex items-start gap-3">
|
||||
{Icon && (
|
||||
<span className="grid place-items-center w-9 h-9 rounded-xl bg-krow-blue-tint text-krow-blue shrink-0">
|
||||
<Icon className="w-4.5 h-4.5" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<SheetTitle className="text-title font-heading text-ink-1">{title}</SheetTitle>
|
||||
{description && (
|
||||
<SheetDescription className="text-body-sm text-ink-3 mt-1">
|
||||
{description}
|
||||
</SheetDescription>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-5 py-5">{children}</div>
|
||||
|
||||
{footer && (
|
||||
<div className="px-5 py-4 border-t border-border bg-surface-subtle shrink-0 flex items-center justify-end gap-2">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
75
src/components/ds/EmptyState.jsx
Normal file
75
src/components/ds/EmptyState.jsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
/**
|
||||
* EmptyState — what a region shows when it has nothing to show.
|
||||
*
|
||||
* Deliberately distinguishes two situations that need different copy and
|
||||
* different actions:
|
||||
*
|
||||
* - `empty` — nothing exists yet. Offer the action that creates the first one.
|
||||
* - `filtered` — things exist but none match. Offer to clear the filters.
|
||||
*
|
||||
* Pages that conflate them end up telling a user with 22 candidates that they
|
||||
* have no candidates.
|
||||
*/
|
||||
export function EmptyState({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
/** Primary action — a <Button>, or `{ label, onClick }`. */
|
||||
action,
|
||||
secondaryAction,
|
||||
variant = 'empty',
|
||||
size = 'default',
|
||||
className,
|
||||
}) {
|
||||
const renderAction = (spec, buttonVariant) => {
|
||||
if (!spec) return null;
|
||||
if (React.isValidElement(spec)) return spec;
|
||||
return (
|
||||
<Button variant={buttonVariant} size="sm" onClick={spec.onClick}>
|
||||
{spec.icon && <spec.icon aria-hidden="true" />}
|
||||
{spec.label}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center text-center',
|
||||
size === 'sm' ? 'py-8 px-4' : size === 'lg' ? 'py-20 px-6' : 'py-14 px-6',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{Icon && (
|
||||
<span
|
||||
className={cn(
|
||||
'grid place-items-center rounded-full mb-4',
|
||||
variant === 'filtered' ? 'bg-surface-sunken text-ink-4' : 'bg-krow-blue-tint text-krow-blue',
|
||||
size === 'sm' ? 'w-10 h-10' : 'w-14 h-14'
|
||||
)}
|
||||
>
|
||||
<Icon className={size === 'sm' ? 'w-5 h-5' : 'w-6 h-6'} aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
<h3 className={cn('font-heading text-ink-1', size === 'sm' ? 'text-body' : 'text-title')}>
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{description && (
|
||||
<p className="text-body-sm text-ink-3 mt-1.5 max-w-sm leading-relaxed">{description}</p>
|
||||
)}
|
||||
|
||||
{(action || secondaryAction) && (
|
||||
<div className="flex items-center gap-2 mt-5">
|
||||
{renderAction(action, 'default')}
|
||||
{renderAction(secondaryAction, 'outline')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
src/components/ds/Field.jsx
Normal file
68
src/components/ds/Field.jsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Field — the label / hint / error scaffolding every form control shares.
|
||||
*
|
||||
* Controls stay presentational and Field owns the surrounding text, so label
|
||||
* spacing, required markers and error styling are identical across the app.
|
||||
* It wires `id`, `aria-describedby` and `aria-invalid` onto its child.
|
||||
*/
|
||||
export function Field({
|
||||
label,
|
||||
hint,
|
||||
error,
|
||||
required,
|
||||
htmlFor,
|
||||
/** Lays the label beside the control instead of above it (toggles, checkboxes). */
|
||||
inline = false,
|
||||
className,
|
||||
children,
|
||||
}) {
|
||||
const reactId = React.useId();
|
||||
const id = htmlFor || reactId;
|
||||
const hintId = hint ? `${id}-hint` : undefined;
|
||||
const errorId = error ? `${id}-error` : undefined;
|
||||
const describedBy = [errorId, hintId].filter(Boolean).join(' ') || undefined;
|
||||
|
||||
// Only clone when the child is a single element that can accept the wiring.
|
||||
const control = React.isValidElement(children)
|
||||
? React.cloneElement(children, {
|
||||
id: children.props.id || id,
|
||||
'aria-describedby': children.props['aria-describedby'] || describedBy,
|
||||
'aria-invalid': error ? true : children.props['aria-invalid'],
|
||||
})
|
||||
: children;
|
||||
|
||||
if (inline) {
|
||||
return (
|
||||
<div className={cn('flex items-start gap-3', className)}>
|
||||
{control}
|
||||
<div className="min-w-0">
|
||||
{label && (
|
||||
<label htmlFor={id} className="text-body-sm font-medium text-ink-2 cursor-pointer">
|
||||
{label}
|
||||
{required && <span className="text-destructive ml-0.5">*</span>}
|
||||
</label>
|
||||
)}
|
||||
{hint && !error && <p id={hintId} className="text-caption text-ink-3 mt-0.5">{hint}</p>}
|
||||
{error && <p id={errorId} className="text-caption text-destructive mt-0.5">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-1.5', className)}>
|
||||
{label && (
|
||||
<label htmlFor={id} className="block text-body-sm font-medium text-ink-2">
|
||||
{label}
|
||||
{required && <span className="text-destructive ml-0.5">*</span>}
|
||||
</label>
|
||||
)}
|
||||
{control}
|
||||
{hint && !error && <p id={hintId} className="text-caption text-ink-3">{hint}</p>}
|
||||
{error && <p id={errorId} className="text-caption text-destructive">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
189
src/components/ds/FilterBar.jsx
Normal file
189
src/components/ds/FilterBar.jsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import * as React from 'react';
|
||||
import { SlidersHorizontal, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { SearchInput } from '@/components/ds/SearchInput';
|
||||
import { MultiSelect } from '@/components/ds/MultiSelect';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
|
||||
/**
|
||||
* FilterBar — the search + filters row above a list, table or grid.
|
||||
*
|
||||
* Filters are declared as data so every list page gets the same layout,
|
||||
* the same responsive behaviour, and the same "Clear all" affordance:
|
||||
*
|
||||
* { key, label, type: 'select' | 'multi', options, placeholder, width }
|
||||
*
|
||||
* On mobile the filters collapse behind a "Filters" toggle with an active count,
|
||||
* because four side-by-side selects are unusable at 375px.
|
||||
*/
|
||||
export function FilterBar({
|
||||
search,
|
||||
onSearchChange,
|
||||
searchPlaceholder = 'Search...',
|
||||
|
||||
filters = [],
|
||||
/** `{ [key]: string | string[] }` */
|
||||
values = {},
|
||||
onChange,
|
||||
|
||||
/** Right-aligned actions — primary buttons, view switchers. */
|
||||
actions,
|
||||
/** Rendered under the bar, e.g. a result count. */
|
||||
meta,
|
||||
|
||||
/** Value that means "no filter" for select-type filters. */
|
||||
allValue = 'all',
|
||||
className,
|
||||
}) {
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
|
||||
const isActive = (filter) => {
|
||||
const value = values[filter.key];
|
||||
if (filter.type === 'multi') return Array.isArray(value) && value.length > 0;
|
||||
return value != null && value !== allValue && value !== '';
|
||||
};
|
||||
|
||||
const activeFilters = filters.filter(isActive);
|
||||
const activeCount = activeFilters.length + (search ? 1 : 0);
|
||||
|
||||
const set = (key, value) => onChange?.({ ...values, [key]: value });
|
||||
|
||||
const clearAll = () => {
|
||||
const cleared = {};
|
||||
filters.forEach((f) => {
|
||||
cleared[f.key] = f.type === 'multi' ? [] : allValue;
|
||||
});
|
||||
onChange?.(cleared);
|
||||
onSearchChange?.('');
|
||||
};
|
||||
|
||||
const clearOne = (filter) => set(filter.key, filter.type === 'multi' ? [] : allValue);
|
||||
|
||||
const labelFor = (filter) => {
|
||||
const value = values[filter.key];
|
||||
if (filter.type === 'multi') {
|
||||
return `${filter.label}: ${value.length}`;
|
||||
}
|
||||
const option = (filter.options || [])
|
||||
.map((o) => (typeof o === 'string' ? { value: o, label: o } : o))
|
||||
.find((o) => o.value === value);
|
||||
return `${filter.label}: ${option?.label ?? value}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-3', className)}>
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3">
|
||||
{onSearchChange && (
|
||||
<SearchInput
|
||||
value={search}
|
||||
onChange={onSearchChange}
|
||||
placeholder={searchPlaceholder}
|
||||
containerClassName="flex-1 min-w-0"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile: collapse the filter controls behind a toggle. */}
|
||||
{filters.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
shape="rounded"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="sm:hidden justify-between"
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<SlidersHorizontal aria-hidden="true" />
|
||||
Filters
|
||||
</span>
|
||||
{activeFilters.length > 0 && (
|
||||
<Badge variant="default" size="sm">{activeFilters.length}</Badge>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col sm:flex-row gap-3 sm:items-center',
|
||||
expanded ? 'flex' : 'hidden sm:flex'
|
||||
)}
|
||||
>
|
||||
{filters.map((filter) => {
|
||||
const value = values[filter.key];
|
||||
|
||||
if (filter.type === 'multi') {
|
||||
return (
|
||||
<MultiSelect
|
||||
key={filter.key}
|
||||
options={filter.options}
|
||||
value={Array.isArray(value) ? value : []}
|
||||
onChange={(next) => set(filter.key, next)}
|
||||
placeholder={filter.placeholder || filter.label}
|
||||
className={cn('sm:w-auto', filter.width || 'sm:min-w-44')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const options = (filter.options || []).map((o) =>
|
||||
typeof o === 'string' ? { value: o, label: o } : o
|
||||
);
|
||||
|
||||
return (
|
||||
<Select
|
||||
key={filter.key}
|
||||
value={value ?? allValue}
|
||||
onValueChange={(next) => set(filter.key, next)}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={filter.label}
|
||||
className={cn('sm:w-auto', filter.width || 'sm:min-w-40')}
|
||||
>
|
||||
<SelectValue placeholder={filter.placeholder || filter.label} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{actions && <div className="flex items-center gap-2 sm:ml-auto shrink-0">{actions}</div>}
|
||||
</div>
|
||||
|
||||
{/* Active filter chips — the escape hatch when a list looks wrong and the
|
||||
user cannot see why. */}
|
||||
{(activeCount > 0 || meta) && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{meta && <span className="text-caption text-ink-3 mr-1">{meta}</span>}
|
||||
|
||||
{activeFilters.map((filter) => (
|
||||
<button
|
||||
key={filter.key}
|
||||
type="button"
|
||||
onClick={() => clearOne(filter)}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-krow-blue-tint px-2.5 py-1 text-caption font-medium text-krow-blue hover:bg-[#DBEAFE] transition-colors focus-ring"
|
||||
>
|
||||
{labelFor(filter)}
|
||||
<X className="w-3 h-3" aria-hidden="true" />
|
||||
</button>
|
||||
))}
|
||||
|
||||
{activeCount > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearAll}
|
||||
className="text-caption font-medium text-ink-3 hover:text-ink-1 hover:underline transition-colors focus-ring rounded-sm"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
src/components/ds/IconButton.jsx
Normal file
39
src/components/ds/IconButton.jsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import * as React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
/**
|
||||
* IconButton — a square Button carrying only an icon.
|
||||
*
|
||||
* It delegates entirely to Button so the two can never diverge, and requires a
|
||||
* `label`: an icon-only control with no accessible name is a bug, so the API
|
||||
* makes it impossible to omit. The label doubles as the tooltip.
|
||||
*/
|
||||
export const IconButton = React.forwardRef(
|
||||
({ icon: Icon, label, tooltip = true, size = 'default', side = 'top', ...props }, ref) => {
|
||||
const sizeMap = {
|
||||
xs: 'icon-xs',
|
||||
sm: 'icon-sm',
|
||||
default: 'icon',
|
||||
lg: 'icon-lg',
|
||||
};
|
||||
|
||||
const button = (
|
||||
<Button ref={ref} size={sizeMap[size] || 'icon'} aria-label={label} {...props}>
|
||||
<Icon aria-hidden="true" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (!tooltip) return button;
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent side={side}>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
);
|
||||
IconButton.displayName = 'IconButton';
|
||||
177
src/components/ds/KpiCard.jsx
Normal file
177
src/components/ds/KpiCard.jsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import * as React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowDownRight, ArrowUpRight, Minus } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Surface } from '@/components/ds/Surface';
|
||||
import { Skeleton } from '@/components/ds/Skeleton';
|
||||
|
||||
const TONES = {
|
||||
brand: { tile: 'bg-krow-blue text-white', glow: 'shadow-brand', value: 'text-krow-blue' },
|
||||
navy: { tile: 'bg-krow-navy text-white', glow: 'shadow-md', value: 'text-krow-blue' },
|
||||
accent: { tile: 'bg-krow-yellow text-krow-blue', glow: 'shadow-[0_0_24px_rgba(249,229,71,0.35)]', value: 'text-krow-blue' },
|
||||
success: { tile: 'bg-success-muted text-success', glow: 'shadow-xs', value: 'text-ink-1' },
|
||||
warning: { tile: 'bg-warning-muted text-warning', glow: 'shadow-xs', value: 'text-ink-1' },
|
||||
neutral: { tile: 'bg-surface-sunken text-ink-3', glow: 'shadow-xs', value: 'text-ink-1' },
|
||||
};
|
||||
|
||||
/**
|
||||
* KpiCard — one headline number in a dashboard row.
|
||||
*
|
||||
* Deliberately minimal: icon tile, value, label. When a number needs context
|
||||
* (a target, a breakdown, a sparkline) use `MetricCard`; keeping the two
|
||||
* separate stops KPI rows from drifting into inconsistent heights.
|
||||
*/
|
||||
export function KpiCard({
|
||||
icon: Icon,
|
||||
value,
|
||||
label,
|
||||
tone = 'brand',
|
||||
/** Stagger position in a row — drives the entrance delay. */
|
||||
index = 0,
|
||||
loading = false,
|
||||
onClick,
|
||||
className,
|
||||
}) {
|
||||
const t = TONES[tone] || TONES.brand;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Surface radius="xl" padding="sm" className={className}>
|
||||
<Skeleton className="w-10 h-10 rounded-xl mb-3" />
|
||||
<Skeleton className="h-7 w-14 mb-2" />
|
||||
<Skeleton className="h-2.5 w-20" />
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.06, duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
|
||||
whileHover={{ y: -4, transition: { duration: 0.2 } }}
|
||||
>
|
||||
<Surface
|
||||
as={onClick ? 'button' : 'div'}
|
||||
onClick={onClick}
|
||||
interactive={onClick ? true : undefined}
|
||||
radius="xl"
|
||||
padding="sm"
|
||||
className={cn('w-full text-left hover:shadow-lg', className)}
|
||||
>
|
||||
{Icon && (
|
||||
<div className={cn('grid place-items-center w-10 h-10 rounded-xl mb-3', t.tile, t.glow)}>
|
||||
<Icon className="w-5 h-5" aria-hidden="true" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keyed so the number re-animates when it changes, which draws the eye
|
||||
to the figure that actually moved. */}
|
||||
<motion.div
|
||||
key={value}
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: index * 0.06 + 0.2, type: 'spring', stiffness: 200 }}
|
||||
className={cn('text-display font-heading font-bold tabular-nums', t.value)}
|
||||
>
|
||||
{value}
|
||||
</motion.div>
|
||||
|
||||
<div className="text-overline text-ink-4 mt-0.5 normal-case tracking-wide">{label}</div>
|
||||
</Surface>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Formats a delta as a signed percentage: 12 → "+12%". */
|
||||
const formatDelta = (delta, suffix = '%') =>
|
||||
`${delta > 0 ? '+' : ''}${delta}${suffix}`;
|
||||
|
||||
/**
|
||||
* MetricCard — a number with supporting context: a trend, a target, a
|
||||
* sub-caption, or a small chart.
|
||||
*
|
||||
* `invertTrend` exists because "up" is not always good — time-to-hire rising is
|
||||
* bad, and colouring it green would be actively misleading.
|
||||
*/
|
||||
export function MetricCard({
|
||||
label,
|
||||
value,
|
||||
/** Signed number; sign drives the arrow and colour. */
|
||||
delta,
|
||||
deltaSuffix = '%',
|
||||
deltaLabel,
|
||||
invertTrend = false,
|
||||
sub,
|
||||
icon: Icon,
|
||||
tone = 'neutral',
|
||||
/** A sparkline or mini chart, rendered under the value. */
|
||||
chart,
|
||||
loading = false,
|
||||
onClick,
|
||||
className,
|
||||
}) {
|
||||
const t = TONES[tone] || TONES.neutral;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Surface radius="xl" padding="default" className={className}>
|
||||
<Skeleton className="h-2.5 w-24 mb-3" />
|
||||
<Skeleton className="h-8 w-20 mb-2" />
|
||||
<Skeleton className="h-2.5 w-32" />
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
const hasDelta = typeof delta === 'number' && delta !== 0;
|
||||
const isGood = invertTrend ? delta < 0 : delta > 0;
|
||||
const TrendIcon = !hasDelta ? Minus : delta > 0 ? ArrowUpRight : ArrowDownRight;
|
||||
|
||||
return (
|
||||
<Surface
|
||||
as={onClick ? 'button' : 'div'}
|
||||
onClick={onClick}
|
||||
interactive={onClick ? true : undefined}
|
||||
radius="xl"
|
||||
padding="default"
|
||||
className={cn('w-full text-left', className)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<p className="text-body-sm text-ink-3 font-medium">{label}</p>
|
||||
{Icon && (
|
||||
<span className={cn('grid place-items-center w-8 h-8 rounded-lg shrink-0', t.tile)}>
|
||||
<Icon className="w-4 h-4" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-end gap-2.5 flex-wrap">
|
||||
<span className="text-display font-heading font-bold text-ink-1 tabular-nums leading-none">
|
||||
{value}
|
||||
</span>
|
||||
|
||||
{(hasDelta || delta === 0) && (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-0.5 text-caption font-semibold rounded-full px-1.5 py-0.5 mb-0.5',
|
||||
!hasDelta
|
||||
? 'bg-neutral-muted text-ink-3'
|
||||
: isGood
|
||||
? 'bg-success-muted text-success'
|
||||
: 'bg-destructive-muted text-destructive'
|
||||
)}
|
||||
>
|
||||
<TrendIcon className="w-3 h-3" aria-hidden="true" />
|
||||
{hasDelta ? formatDelta(delta, deltaSuffix) : 'No change'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(deltaLabel || sub) && (
|
||||
<p className="text-caption text-ink-4 mt-2">{deltaLabel || sub}</p>
|
||||
)}
|
||||
|
||||
{chart && <div className="mt-4">{chart}</div>}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
78
src/components/ds/Layout.jsx
Normal file
78
src/components/ds/Layout.jsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Layout helpers.
|
||||
*
|
||||
* These exist so vertical rhythm and grid behaviour are declared once. The
|
||||
* reference pages each invented their own spacing (`space-y-5`, `space-y-6`,
|
||||
* `space-y-8` on sibling pages), which is exactly the drift a design system is
|
||||
* supposed to prevent.
|
||||
*/
|
||||
|
||||
const GAPS = { sm: 'gap-3', default: 'gap-4', lg: 'gap-6' };
|
||||
|
||||
/**
|
||||
* Grid — responsive column layouts with the app's standard breakpoints.
|
||||
*
|
||||
* `cols` is the count at the largest breakpoint; the intermediate steps are
|
||||
* chosen to avoid orphan cards (6 → 2/3/6 rather than 1/3/6).
|
||||
*/
|
||||
export function Grid({ cols = 3, gap = 'default', className, children, ...props }) {
|
||||
const columns = {
|
||||
1: 'grid-cols-1',
|
||||
2: 'grid-cols-1 sm:grid-cols-2',
|
||||
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
|
||||
4: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4',
|
||||
5: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-5',
|
||||
6: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-6',
|
||||
}[cols];
|
||||
|
||||
return (
|
||||
<div className={cn('grid', columns, GAPS[gap], className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Stack — vertical rhythm. `space` maps to the two spacings pages should use. */
|
||||
export function Stack({ space = 'default', className, children, ...props }) {
|
||||
const spacing = {
|
||||
xs: 'space-y-2',
|
||||
sm: 'space-y-3',
|
||||
default: 'space-y-5',
|
||||
lg: 'space-y-6',
|
||||
xl: 'space-y-8',
|
||||
}[space];
|
||||
|
||||
return (
|
||||
<div className={cn(spacing, className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PageSection — a titled region of a page. Pairs a SectionHeader-style label
|
||||
* with its content at consistent spacing.
|
||||
*/
|
||||
export function PageSection({ title, subtitle, actions, level = 'overline', className, children }) {
|
||||
return (
|
||||
<section className={cn('space-y-3', className)}>
|
||||
{title && (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{level === 'overline' ? (
|
||||
<h2 className="text-overline uppercase text-ink-4 font-heading">{title}</h2>
|
||||
) : (
|
||||
<div>
|
||||
<h2 className="text-title-lg font-heading text-ink-1">{title}</h2>
|
||||
{subtitle && <p className="text-body-sm text-ink-3 mt-0.5">{subtitle}</p>}
|
||||
</div>
|
||||
)}
|
||||
{actions && <div className="flex items-center gap-2 shrink-0">{actions}</div>}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
74
src/components/ds/LoadingState.jsx
Normal file
74
src/components/ds/LoadingState.jsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import * as React from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Spinner — the only spinner in the system.
|
||||
*/
|
||||
export function Spinner({ size = 'default', className, label = 'Loading' }) {
|
||||
const sizes = { xs: 'w-3.5 h-3.5', sm: 'w-4 h-4', default: 'w-6 h-6', lg: 'w-8 h-8' };
|
||||
return (
|
||||
<Loader2
|
||||
role="status"
|
||||
aria-label={label}
|
||||
className={cn('animate-spin text-ink-4', sizes[size] || sizes.default, className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* LoadingState — a centred spinner with optional copy, for a region whose
|
||||
* shape is not known ahead of time.
|
||||
*
|
||||
* Prefer `Skeleton*` when the shape *is* known: a skeleton that matches the
|
||||
* final layout prevents the content jump a spinner always causes.
|
||||
*/
|
||||
export function LoadingState({ message, size = 'default', className }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-3',
|
||||
size === 'sm' ? 'py-8' : size === 'lg' ? 'py-24' : 'py-16',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Spinner size={size === 'sm' ? 'default' : 'lg'} />
|
||||
{message && <p className="text-body-sm text-ink-3">{message}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* InlineLoading — a spinner and label on one line, for inside buttons rows,
|
||||
* card headers, or beside a control that is refreshing.
|
||||
*/
|
||||
export function InlineLoading({ message = 'Loading…', className }) {
|
||||
return (
|
||||
<span className={cn('inline-flex items-center gap-2 text-body-sm text-ink-3', className)}>
|
||||
<Spinner size="sm" />
|
||||
{message}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* LoadingOverlay — covers content that is being replaced, keeping layout
|
||||
* height stable so the page does not jump. Used by DataTable when refetching.
|
||||
*/
|
||||
export function LoadingOverlay({ show, message, className }) {
|
||||
if (!show) return null;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 z-10 grid place-items-center rounded-[inherit]',
|
||||
'bg-white/60 backdrop-blur-[2px] animate-fade-in',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Spinner />
|
||||
{message && <p className="text-caption text-ink-3">{message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
src/components/ds/Modal.jsx
Normal file
120
src/components/ds/Modal.jsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const WIDTHS = {
|
||||
sm: 'sm:max-w-sm',
|
||||
default: 'sm:max-w-lg',
|
||||
lg: 'sm:max-w-2xl',
|
||||
xl: 'sm:max-w-4xl',
|
||||
};
|
||||
|
||||
/**
|
||||
* Modal — the standard dialog shape: header, scrollable body, footer.
|
||||
*
|
||||
* Owning the scroll boundary here is the point. A dialog that lets its whole
|
||||
* body scroll pushes the title and the confirm button off-screen on a laptop;
|
||||
* this keeps both pinned and scrolls only the middle.
|
||||
*/
|
||||
export function Modal({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
size = 'default',
|
||||
/** Footer content. Omit for a dialog whose body carries its own actions. */
|
||||
footer,
|
||||
children,
|
||||
className,
|
||||
/** Blocks closing — use while a submit is in flight. */
|
||||
busy = false,
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={busy ? undefined : onOpenChange}>
|
||||
<DialogContent
|
||||
className={cn('p-0 gap-0 overflow-hidden max-h-[90vh] flex flex-col', WIDTHS[size], className)}
|
||||
onInteractOutside={busy ? (e) => e.preventDefault() : undefined}
|
||||
onEscapeKeyDown={busy ? (e) => e.preventDefault() : undefined}
|
||||
>
|
||||
<DialogHeader className="px-6 pt-6 pb-4 border-b border-border shrink-0 space-y-0">
|
||||
<div className="flex items-start gap-3">
|
||||
{Icon && (
|
||||
<span className="grid place-items-center w-9 h-9 rounded-xl bg-krow-blue-tint text-krow-blue shrink-0">
|
||||
<Icon className="w-4.5 h-4.5" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 text-left">
|
||||
<DialogTitle className="text-title font-heading text-ink-1">{title}</DialogTitle>
|
||||
{description && (
|
||||
<DialogDescription className="text-body-sm text-ink-3 mt-1">
|
||||
{description}
|
||||
</DialogDescription>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5">{children}</div>
|
||||
|
||||
{footer && (
|
||||
<DialogFooter className="px-6 py-4 border-t border-border bg-surface-subtle shrink-0">
|
||||
{footer}
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ConfirmModal — a destructive or irreversible confirmation.
|
||||
*
|
||||
* Defaults to the destructive tone because that is what confirmation is almost
|
||||
* always for, and it labels the action with the verb ("Delete") rather than
|
||||
* "OK", so the button says what will happen.
|
||||
*/
|
||||
export function ConfirmModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
title = 'Are you sure?',
|
||||
description,
|
||||
confirmLabel = 'Confirm',
|
||||
cancelLabel = 'Cancel',
|
||||
onConfirm,
|
||||
tone = 'destructive',
|
||||
icon,
|
||||
busy = false,
|
||||
}) {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={title}
|
||||
description={description}
|
||||
icon={icon}
|
||||
size="sm"
|
||||
busy={busy}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => onOpenChange?.(false)} disabled={busy}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button variant={tone === 'destructive' ? 'destructive' : 'default'} onClick={onConfirm} loading={busy}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
171
src/components/ds/MultiSelect.jsx
Normal file
171
src/components/ds/MultiSelect.jsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import * as React from 'react';
|
||||
import { Check, ChevronDown, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { SearchInput } from '@/components/ds/SearchInput';
|
||||
|
||||
/**
|
||||
* MultiSelect — pick several values from a list.
|
||||
*
|
||||
* Selections render as removable chips in the trigger. Above `searchThreshold`
|
||||
* options a filter field appears, so short lists stay uncluttered and long ones
|
||||
* stay usable. Options may be strings or `{ value, label, count }`.
|
||||
*/
|
||||
export function MultiSelect({
|
||||
options = [],
|
||||
value = [],
|
||||
onChange,
|
||||
placeholder = 'Select...',
|
||||
/** Chips shown before collapsing into "+N more". */
|
||||
maxChips = 2,
|
||||
searchThreshold = 8,
|
||||
disabled,
|
||||
className,
|
||||
align = 'start',
|
||||
emptyMessage = 'No options',
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [query, setQuery] = React.useState('');
|
||||
|
||||
const normalized = React.useMemo(
|
||||
() => options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o)),
|
||||
[options]
|
||||
);
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
if (!query) return normalized;
|
||||
const q = query.toLowerCase();
|
||||
return normalized.filter((o) => o.label.toLowerCase().includes(q));
|
||||
}, [normalized, query]);
|
||||
|
||||
const labelFor = (v) => normalized.find((o) => o.value === v)?.label ?? v;
|
||||
|
||||
const toggle = (optionValue) => {
|
||||
const next = value.includes(optionValue)
|
||||
? value.filter((v) => v !== optionValue)
|
||||
: [...value, optionValue];
|
||||
onChange?.(next);
|
||||
};
|
||||
|
||||
const remove = (optionValue, event) => {
|
||||
// The chip's X sits inside the trigger — don't open the popover.
|
||||
event.stopPropagation();
|
||||
onChange?.(value.filter((v) => v !== optionValue));
|
||||
};
|
||||
|
||||
const visible = value.slice(0, maxChips);
|
||||
const overflow = value.length - visible.length;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'flex min-h-10 w-full items-center gap-1.5 rounded-lg border border-border bg-surface px-3 py-1.5 text-left shadow-xs',
|
||||
'transition-all duration-base ease-out',
|
||||
'outline-none focus-visible:border-krow-blue focus-visible:ring-2 focus-visible:ring-krow-blue/20',
|
||||
'disabled:cursor-not-allowed disabled:bg-surface-sunken disabled:opacity-60',
|
||||
open && 'border-krow-blue ring-2 ring-krow-blue/20',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="flex flex-1 flex-wrap items-center gap-1 min-w-0">
|
||||
{value.length === 0 && <span className="text-body-sm text-ink-4">{placeholder}</span>}
|
||||
{visible.map((v) => (
|
||||
<Badge key={v} variant="soft" size="lg" className="max-w-[160px] pr-1">
|
||||
<span className="truncate">{labelFor(v)}</span>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
aria-label={`Remove ${labelFor(v)}`}
|
||||
onClick={(e) => remove(v, e)}
|
||||
className="grid place-items-center w-3.5 h-3.5 rounded-full hover:bg-krow-blue/20 shrink-0"
|
||||
>
|
||||
<X className="w-2.5 h-2.5" aria-hidden="true" />
|
||||
</span>
|
||||
</Badge>
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<Badge variant="neutral" size="lg">+{overflow} more</Badge>
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'w-4 h-4 text-ink-4 shrink-0 transition-transform duration-base',
|
||||
open && 'rotate-180'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent align={align} className="w-[--radix-popover-trigger-width] min-w-52 p-0">
|
||||
{normalized.length >= searchThreshold && (
|
||||
<div className="p-2 border-b border-border">
|
||||
<SearchInput
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder="Filter options"
|
||||
size="sm"
|
||||
debounce={0}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-h-64 overflow-y-auto p-1.5">
|
||||
{filtered.length === 0 && (
|
||||
<p className="px-2 py-6 text-center text-body-sm text-ink-3">{emptyMessage}</p>
|
||||
)}
|
||||
{filtered.map((option) => {
|
||||
const selected = value.includes(option.value);
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
onClick={() => toggle(option.value)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left text-body-sm transition-colors duration-fast',
|
||||
'hover:bg-krow-blue-tint focus-ring-inset',
|
||||
selected ? 'text-krow-blue font-medium' : 'text-ink-2'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'grid place-items-center w-4 h-4 rounded border shrink-0 transition-colors duration-fast',
|
||||
selected ? 'bg-krow-blue border-krow-blue text-white' : 'border-border'
|
||||
)}
|
||||
>
|
||||
{selected && <Check className="w-3 h-3" aria-hidden="true" />}
|
||||
</span>
|
||||
<span className="flex-1 truncate">{option.label}</span>
|
||||
{option.count != null && (
|
||||
<span className="text-caption text-ink-4 tabular-nums">{option.count}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{value.length > 0 && (
|
||||
<div className="border-t border-border p-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange?.([])}
|
||||
className="w-full rounded-md px-2 py-1.5 text-body-sm text-ink-3 hover:bg-surface-sunken hover:text-ink-1 transition-colors focus-ring-inset"
|
||||
>
|
||||
Clear selection
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
87
src/components/ds/PageHeader.jsx
Normal file
87
src/components/ds/PageHeader.jsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import * as React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* PageHeader — the top of every page: optional icon, title, subtitle, and a
|
||||
* right-aligned action slot. Owning the entrance animation here is what keeps
|
||||
* page transitions feeling like one product.
|
||||
*/
|
||||
export function PageHeader({
|
||||
title,
|
||||
subtitle,
|
||||
icon: Icon,
|
||||
/** Right-aligned actions — buttons, counts, filters. */
|
||||
actions,
|
||||
/** Rendered above the title, e.g. a Breadcrumb. */
|
||||
breadcrumb,
|
||||
/** Rendered below the subtitle, e.g. a Tabs row. */
|
||||
children,
|
||||
/** `display` for dashboard heroes, `title` for standard pages. */
|
||||
size = 'title',
|
||||
animate = true,
|
||||
className,
|
||||
}) {
|
||||
const Wrapper = animate ? motion.div : 'div';
|
||||
const motionProps = animate
|
||||
? {
|
||||
initial: { opacity: 0, y: -12 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
transition: { duration: 0.4, ease: [0.16, 1, 0.3, 1] },
|
||||
}
|
||||
: {};
|
||||
|
||||
return (
|
||||
<Wrapper {...motionProps} className={cn('space-y-4', className)}>
|
||||
{breadcrumb}
|
||||
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
{Icon && (
|
||||
<span className="grid place-items-center w-10 h-10 rounded-xl bg-krow-blue text-white shadow-brand shrink-0">
|
||||
<Icon className="w-5 h-5" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<h1
|
||||
className={cn(
|
||||
'font-heading text-ink-1 truncate',
|
||||
size === 'display' ? 'text-display' : 'text-title-lg'
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle && <p className="text-body-sm text-ink-3 mt-1">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{actions && <div className="flex items-center gap-2 shrink-0">{actions}</div>}
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A dashboard-style greeting header where part of the title is emphasized —
|
||||
* "Good day. Let's hire smarter."
|
||||
*/
|
||||
export function HeroHeader({ lead, highlight, subtitle, actions, className }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
||||
className={cn('flex items-center justify-between flex-wrap gap-4', className)}
|
||||
>
|
||||
<div>
|
||||
<h1 className="text-display font-heading text-ink-1">
|
||||
{lead} {highlight && <span className="text-krow-blue">{highlight}</span>}
|
||||
</h1>
|
||||
{subtitle && <p className="text-body text-ink-3 mt-1">{subtitle}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
127
src/components/ds/Pagination.jsx
Normal file
127
src/components/ds/Pagination.jsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import * as React from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
/**
|
||||
* Builds a compact page list with ellipses: 1 … 4 5 6 … 20.
|
||||
*
|
||||
* Always keeps the first page, the last page, and a window around the current
|
||||
* one, so the control's width stays stable as the user pages through.
|
||||
*/
|
||||
export function buildPageRange(current, total, siblings = 1) {
|
||||
const totalSlots = siblings * 2 + 5;
|
||||
if (total <= totalSlots) return Array.from({ length: total }, (_, i) => i + 1);
|
||||
|
||||
const left = Math.max(current - siblings, 1);
|
||||
const right = Math.min(current + siblings, total);
|
||||
const showLeftEllipsis = left > 2;
|
||||
const showRightEllipsis = right < total - 1;
|
||||
|
||||
const pages = [1];
|
||||
if (showLeftEllipsis) pages.push('…');
|
||||
for (let p = Math.max(2, left); p <= Math.min(total - 1, right); p += 1) pages.push(p);
|
||||
if (showRightEllipsis) pages.push('…');
|
||||
pages.push(total);
|
||||
return pages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pagination — page navigation with an optional result summary and page-size
|
||||
* control. Renders nothing when there is only one page.
|
||||
*/
|
||||
export function Pagination({
|
||||
page = 1,
|
||||
pageCount = 1,
|
||||
onPageChange,
|
||||
/** Total rows across all pages — enables the "Showing x–y of z" summary. */
|
||||
totalItems,
|
||||
pageSize,
|
||||
onPageSizeChange,
|
||||
pageSizeOptions = [10, 25, 50, 100],
|
||||
className,
|
||||
}) {
|
||||
if (pageCount <= 1 && !onPageSizeChange) return null;
|
||||
|
||||
const from = totalItems != null && pageSize ? (page - 1) * pageSize + 1 : null;
|
||||
const to = totalItems != null && pageSize ? Math.min(page * pageSize, totalItems) : null;
|
||||
const range = buildPageRange(page, pageCount);
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Pagination"
|
||||
className={cn('flex flex-wrap items-center justify-between gap-3', className)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{from != null && (
|
||||
<p className="text-caption text-ink-3 tabular-nums">
|
||||
Showing <span className="font-medium text-ink-2">{from}–{to}</span> of{' '}
|
||||
<span className="font-medium text-ink-2">{totalItems}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{onPageSizeChange && (
|
||||
<label className="flex items-center gap-1.5 text-caption text-ink-3">
|
||||
<span className="hidden sm:inline">Rows</span>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||
className="h-7 rounded-md border border-border bg-surface pl-2 pr-6 text-caption text-ink-2 focus-ring-inset cursor-pointer"
|
||||
>
|
||||
{pageSizeOptions.map((size) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pageCount > 1 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
shape="rounded"
|
||||
aria-label="Previous page"
|
||||
disabled={page <= 1}
|
||||
onClick={() => onPageChange?.(page - 1)}
|
||||
>
|
||||
<ChevronLeft aria-hidden="true" />
|
||||
</Button>
|
||||
|
||||
{range.map((entry, i) =>
|
||||
entry === '…' ? (
|
||||
<span key={`gap-${i}`} className="px-1.5 text-caption text-ink-4" aria-hidden="true">
|
||||
…
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
key={entry}
|
||||
variant={entry === page ? 'default' : 'ghost'}
|
||||
size="icon-sm"
|
||||
shape="rounded"
|
||||
aria-label={`Page ${entry}`}
|
||||
aria-current={entry === page ? 'page' : undefined}
|
||||
onClick={() => onPageChange?.(entry)}
|
||||
className="tabular-nums"
|
||||
>
|
||||
{entry}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
shape="rounded"
|
||||
aria-label="Next page"
|
||||
disabled={page >= pageCount}
|
||||
onClick={() => onPageChange?.(page + 1)}
|
||||
>
|
||||
<ChevronRight aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
255
src/components/ds/Progress.jsx
Normal file
255
src/components/ds/Progress.jsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import * as React from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const clamp = (n) => Math.max(0, Math.min(100, Number(n) || 0));
|
||||
|
||||
const TRACK_TONES = {
|
||||
brand: 'bg-krow-blue',
|
||||
navy: 'bg-krow-navy',
|
||||
accent: 'bg-krow-yellow',
|
||||
success: 'bg-success',
|
||||
warning: 'bg-warning',
|
||||
destructive: 'bg-destructive',
|
||||
};
|
||||
|
||||
/**
|
||||
* Score-band colour, shared by every progress component.
|
||||
*
|
||||
* The app scores people constantly, and the band boundaries (80/60/40) are the
|
||||
* same ones the pipeline tiers use — so a 72 is the same colour on a bar, a ring
|
||||
* and a leaderboard row.
|
||||
*/
|
||||
export function toneForScore(score) {
|
||||
const s = clamp(score);
|
||||
if (s >= 80) return 'brand';
|
||||
if (s >= 60) return 'success';
|
||||
if (s >= 40) return 'warning';
|
||||
return 'destructive';
|
||||
}
|
||||
|
||||
/**
|
||||
* ProgressBar — a horizontal track.
|
||||
*
|
||||
* `tone="score"` picks the colour from the value, which is what most usages in
|
||||
* this app want.
|
||||
*/
|
||||
export function ProgressBar({
|
||||
value,
|
||||
max = 100,
|
||||
tone = 'brand',
|
||||
size = 'default',
|
||||
label,
|
||||
showValue = false,
|
||||
valueSuffix = '',
|
||||
className,
|
||||
trackClassName,
|
||||
}) {
|
||||
const pct = clamp((Number(value) / max) * 100);
|
||||
const resolvedTone = tone === 'score' ? toneForScore(pct) : tone;
|
||||
|
||||
const heights = { xs: 'h-1', sm: 'h-1.5', default: 'h-2', lg: 'h-2.5' };
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{(label || showValue) && (
|
||||
<div className="flex items-center justify-between gap-2 mb-1.5">
|
||||
{label && <span className="text-caption font-medium text-ink-2">{label}</span>}
|
||||
{showValue && (
|
||||
<span className="text-caption font-semibold text-ink-1 tabular-nums">
|
||||
{value}
|
||||
{valueSuffix}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuenow={clamp(value)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={max}
|
||||
aria-label={label || undefined}
|
||||
className={cn(
|
||||
'w-full overflow-hidden rounded-full bg-surface-sunken',
|
||||
heights[size] || heights.default,
|
||||
trackClassName
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all duration-slow ease-out',
|
||||
TRACK_TONES[resolvedTone]
|
||||
)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ProgressRing — a circular gauge.
|
||||
*
|
||||
* Supports the app's two scales: 0–100 percentages and the 300–850 career
|
||||
* score, via `min`/`max`, so the KROW ID gauge and a completion ring are the
|
||||
* same component.
|
||||
*/
|
||||
export function ProgressRing({
|
||||
value,
|
||||
min = 0,
|
||||
max = 100,
|
||||
size = 88,
|
||||
strokeWidth = 8,
|
||||
tone = 'brand',
|
||||
/** Big text in the centre. Defaults to the value. */
|
||||
label,
|
||||
/** Small text above the label. */
|
||||
caption,
|
||||
/** Small text below the label. */
|
||||
footnote,
|
||||
className,
|
||||
}) {
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const ratio = Math.max(0, Math.min(1, (Number(value) - min) / (max - min)));
|
||||
const resolvedTone = tone === 'score' ? toneForScore(ratio * 100) : tone;
|
||||
|
||||
const strokeColor = {
|
||||
brand: '#0A39DF',
|
||||
navy: '#333F48',
|
||||
accent: '#F9E547',
|
||||
success: 'hsl(142 71% 36%)',
|
||||
warning: 'hsl(32 95% 44%)',
|
||||
destructive: 'hsl(0 84% 60%)',
|
||||
}[resolvedTone];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('relative inline-grid place-items-center shrink-0', className)}
|
||||
style={{ width: size, height: size }}
|
||||
role="img"
|
||||
aria-label={`${label ?? value} ${caption ?? ''}`.trim()}
|
||||
>
|
||||
<svg width={size} height={size} className="-rotate-90">
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
className="text-surface-sunken"
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={strokeColor}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${ratio * circumference} ${circumference}`}
|
||||
className="transition-[stroke-dasharray] duration-slow ease-out"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-center leading-none">
|
||||
{caption && (
|
||||
<span className="text-[8px] uppercase tracking-wide text-ink-4 font-medium">{caption}</span>
|
||||
)}
|
||||
<span
|
||||
className="font-heading font-bold text-ink-1 tabular-nums"
|
||||
style={{ fontSize: Math.max(12, size * 0.24) }}
|
||||
>
|
||||
{label ?? value}
|
||||
</span>
|
||||
{footnote && <span className="text-[8px] text-ink-4 font-medium mt-0.5">{footnote}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ProgressSteps — a linear multi-step indicator for wizards.
|
||||
*
|
||||
* Completed steps show a check rather than their number, so "done" is readable
|
||||
* at a glance without relying on colour.
|
||||
*/
|
||||
export function ProgressSteps({ steps = [], current = 0, className, onStepClick }) {
|
||||
const normalized = steps.map((s) => (typeof s === 'string' ? { label: s } : s));
|
||||
|
||||
return (
|
||||
<ol className={cn('flex items-center gap-2', className)}>
|
||||
{normalized.map((step, i) => {
|
||||
const done = i < current;
|
||||
const active = i === current;
|
||||
const clickable = onStepClick && i <= current;
|
||||
|
||||
return (
|
||||
<li key={step.label ?? i} className="flex items-center gap-2 flex-1 last:flex-none">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!clickable}
|
||||
onClick={clickable ? () => onStepClick(i) : undefined}
|
||||
aria-current={active ? 'step' : undefined}
|
||||
className={cn(
|
||||
'flex items-center gap-2 min-w-0',
|
||||
clickable ? 'cursor-pointer' : 'cursor-default'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'grid place-items-center w-6 h-6 rounded-full text-[11px] font-bold shrink-0 transition-colors duration-base',
|
||||
done && 'bg-krow-blue text-white',
|
||||
active && 'bg-krow-blue-tint text-krow-blue ring-2 ring-krow-blue',
|
||||
!done && !active && 'bg-surface-sunken text-ink-4'
|
||||
)}
|
||||
>
|
||||
{done ? <Check className="w-3 h-3" aria-hidden="true" /> : i + 1}
|
||||
</span>
|
||||
{step.label && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-caption font-medium truncate hidden sm:block',
|
||||
active ? 'text-ink-1' : done ? 'text-ink-2' : 'text-ink-4'
|
||||
)}
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{i < normalized.length - 1 && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'h-px flex-1 min-w-4 transition-colors duration-base',
|
||||
done ? 'bg-krow-blue' : 'bg-border'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ScoreMeter — a labelled bar row used in score breakdowns, where many
|
||||
* dimensions are listed with a weight and a value.
|
||||
*/
|
||||
export function ScoreMeter({ label, value, weight, tone = 'score', className }) {
|
||||
return (
|
||||
<div className={cn('space-y-1.5', className)}>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-body-sm text-ink-2 truncate">{label}</span>
|
||||
<span className="flex items-baseline gap-1.5 shrink-0">
|
||||
{weight != null && <span className="text-[10px] text-ink-4">{weight}% weight</span>}
|
||||
<span className="text-body-sm font-semibold text-ink-1 tabular-nums">{value}</span>
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar value={value} tone={tone} size="sm" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
src/components/ds/SearchInput.jsx
Normal file
101
src/components/ds/SearchInput.jsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import * as React from 'react';
|
||||
import { Search, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { inputVariants } from '@/components/ui/input';
|
||||
|
||||
/**
|
||||
* SearchInput — a text input with a leading search icon and a clear button.
|
||||
*
|
||||
* Debounces `onChange` by default so pages can filter on every keystroke
|
||||
* without re-running expensive work, while the field itself stays instantly
|
||||
* responsive. Pass `debounce={0}` for immediate updates.
|
||||
*/
|
||||
export const SearchInput = React.forwardRef(
|
||||
(
|
||||
{
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Search...',
|
||||
debounce = 200,
|
||||
size = 'default',
|
||||
shape = 'rounded',
|
||||
className,
|
||||
containerClassName,
|
||||
onClear,
|
||||
autoFocus,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
// Local state keeps typing immediate; the debounced value is what escapes.
|
||||
const [local, setLocal] = React.useState(value ?? '');
|
||||
const onChangeRef = React.useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
// Follow controlled resets (a Clear all in a FilterBar) without fighting typing.
|
||||
React.useEffect(() => {
|
||||
setLocal((prev) => (value !== undefined && value !== prev ? value : prev));
|
||||
}, [value]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!debounce) return undefined;
|
||||
if (local === (value ?? '')) return undefined;
|
||||
const timer = setTimeout(() => onChangeRef.current?.(local), debounce);
|
||||
return () => clearTimeout(timer);
|
||||
}, [local, debounce, value]);
|
||||
|
||||
const handleChange = (event) => {
|
||||
const next = event.target.value;
|
||||
setLocal(next);
|
||||
if (!debounce) onChange?.(next);
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
setLocal('');
|
||||
onChange?.('');
|
||||
onClear?.();
|
||||
};
|
||||
|
||||
const iconInset = size === 'sm' ? 'left-2.5' : 'left-3.5';
|
||||
const padLeft = size === 'sm' ? 'pl-8' : 'pl-10';
|
||||
|
||||
return (
|
||||
<div className={cn('relative', containerClassName)}>
|
||||
<Search
|
||||
className={cn(
|
||||
'absolute top-1/2 -translate-y-1/2 w-4 h-4 text-ink-4 pointer-events-none',
|
||||
iconInset
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
ref={ref}
|
||||
type="search"
|
||||
role="searchbox"
|
||||
value={local}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
autoFocus={autoFocus}
|
||||
className={cn(
|
||||
inputVariants({ size, shape }),
|
||||
padLeft,
|
||||
'pr-9 [&::-webkit-search-cancel-button]:hidden',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{local && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clear}
|
||||
aria-label="Clear search"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 grid place-items-center w-5 h-5 rounded-full text-ink-4 hover:text-ink-2 hover:bg-surface-sunken transition-colors focus-ring-inset"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
SearchInput.displayName = 'SearchInput';
|
||||
75
src/components/ds/SectionHeader.jsx
Normal file
75
src/components/ds/SectionHeader.jsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import * as React from 'react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* SectionHeader — the label row above a section or inside a card. Three levels
|
||||
* so the hierarchy below a PageHeader stays predictable:
|
||||
*
|
||||
* - `overline` — a small all-caps group label ("INTERVIEW COMPLETION")
|
||||
* - `card` — a card's own title row
|
||||
* - `section` — a page-level section title
|
||||
*/
|
||||
export function SectionHeader({
|
||||
title,
|
||||
subtitle,
|
||||
icon: Icon,
|
||||
level = 'card',
|
||||
actions,
|
||||
/** Renders a "View all ›" affordance on the right. */
|
||||
onViewAll,
|
||||
viewAllLabel = 'View all',
|
||||
className,
|
||||
}) {
|
||||
if (level === 'overline') {
|
||||
return (
|
||||
<div className={cn('flex items-center justify-between gap-3 mb-3', className)}>
|
||||
<h2 className="text-overline uppercase text-ink-4 font-heading">{title}</h2>
|
||||
{actions}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start justify-between gap-3',
|
||||
level === 'section' ? 'mb-4' : 'mb-5',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2.5 min-w-0">
|
||||
{Icon && (
|
||||
<span className="grid place-items-center w-8 h-8 rounded-lg bg-krow-blue-tint text-krow-blue shrink-0">
|
||||
<Icon className="w-4 h-4" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<h3
|
||||
className={cn(
|
||||
'font-heading text-ink-1 truncate',
|
||||
level === 'section' ? 'text-title-lg' : 'text-title'
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
{subtitle && <p className="text-caption text-ink-3 mt-0.5">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{actions}
|
||||
{onViewAll && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onViewAll}
|
||||
className="inline-flex items-center gap-1 text-caption font-medium text-krow-blue hover:underline focus-ring rounded-sm"
|
||||
>
|
||||
{viewAllLabel}
|
||||
<ChevronRight className="w-3 h-3" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
140
src/components/ds/Skeleton.jsx
Normal file
140
src/components/ds/Skeleton.jsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Skeleton — a loading placeholder.
|
||||
*
|
||||
* Uses a travelling shimmer rather than a pulse: on the app's translucent glass
|
||||
* surfaces a pulsing block reads as a rendering glitch, while a shimmer reads as
|
||||
* progress. The shimmer is disabled automatically under
|
||||
* `prefers-reduced-motion` (see index.css).
|
||||
*/
|
||||
export function Skeleton({ className, radius = 'md', ...props }) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'relative overflow-hidden bg-surface-sunken',
|
||||
radius === 'full' ? 'rounded-full' : radius === 'lg' ? 'rounded-xl' : 'rounded-md',
|
||||
'after:absolute after:inset-0 after:shimmer',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The composed patterns below exist so pages never hand-assemble skeletons.
|
||||
* Each mirrors the real component's geometry, which is what makes the swap from
|
||||
* loading to loaded invisible.
|
||||
*/
|
||||
|
||||
/** Paragraph placeholder. The last line is short, as real text is. */
|
||||
export function SkeletonText({ lines = 3, className }) {
|
||||
return (
|
||||
<div className={cn('space-y-2', className)} role="status" aria-label="Loading content">
|
||||
{Array.from({ length: lines }).map((_, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
className={cn('h-3', i === lines - 1 && lines > 1 ? 'w-2/3' : 'w-full')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Matches KpiCard: icon tile, big number, caption. */
|
||||
export function SkeletonKpi({ className }) {
|
||||
return (
|
||||
<div className={cn('glass-card border border-white/60 rounded-2xl p-4 shadow-sm', className)}>
|
||||
<Skeleton className="w-10 h-10 rounded-xl mb-3" />
|
||||
<Skeleton className="h-7 w-14 mb-2" />
|
||||
<Skeleton className="h-2.5 w-20" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Matches a list row: avatar, two lines of text, trailing value. */
|
||||
export function SkeletonRow({ className }) {
|
||||
return (
|
||||
<div className={cn('flex items-center gap-3 py-3', className)}>
|
||||
<Skeleton className="w-9 h-9" radius="full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-3 w-40" />
|
||||
<Skeleton className="h-2.5 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-10" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Matches a card in a grid: title, meta, stat row. */
|
||||
export function SkeletonCard({ className }) {
|
||||
return (
|
||||
<div className={cn('glass-card border border-white/60 rounded-2xl p-5 shadow-sm', className)}>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Skeleton className="w-10 h-10 rounded-xl" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-3.5 w-32" />
|
||||
<Skeleton className="h-2.5 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-2.5 w-28 mb-4" />
|
||||
<div className="grid grid-cols-3 gap-3 pt-4 border-t border-border">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Skeleton className="h-5 w-8" />
|
||||
<Skeleton className="h-2 w-12" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Matches DataTable: header row plus n body rows. */
|
||||
export function SkeletonTable({ rows = 5, columns = 4, className }) {
|
||||
return (
|
||||
<div className={cn('overflow-hidden', className)} role="status" aria-label="Loading table">
|
||||
<div className="flex items-center gap-4 px-4 py-3 border-b border-border bg-surface-subtle">
|
||||
{Array.from({ length: columns }).map((_, i) => (
|
||||
<Skeleton key={i} className={cn('h-2.5', i === 0 ? 'w-32' : 'flex-1 max-w-24')} />
|
||||
))}
|
||||
</div>
|
||||
{Array.from({ length: rows }).map((_, r) => (
|
||||
<div key={r} className="flex items-center gap-4 px-4 py-3.5 border-b border-border last:border-0">
|
||||
{Array.from({ length: columns }).map((_, c) => (
|
||||
<Skeleton key={c} className={cn('h-3', c === 0 ? 'w-32' : 'flex-1 max-w-24')} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Matches ChartContainer: title, legend, plot area. */
|
||||
export function SkeletonChart({ className, height = 240 }) {
|
||||
return (
|
||||
<div className={cn('glass-card border border-white/60 rounded-2xl p-6 shadow-sm', className)}>
|
||||
<Skeleton className="h-3.5 w-40 mb-2" />
|
||||
<Skeleton className="h-2.5 w-56 mb-6" />
|
||||
<div className="flex items-end gap-3" style={{ height }}>
|
||||
{[0.5, 0.8, 0.35, 0.95, 0.6, 0.75].map((h, i) => (
|
||||
<Skeleton key={i} className="flex-1" style={{ height: `${h * 100}%` }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** n SkeletonRows inside a surface — the common "loading a list" case. */
|
||||
export function SkeletonList({ rows = 5, className }) {
|
||||
return (
|
||||
<div className={cn('divide-y divide-border', className)} role="status" aria-label="Loading list">
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<SkeletonRow key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
96
src/components/ds/StatusBadge.jsx
Normal file
96
src/components/ds/StatusBadge.jsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
/**
|
||||
* The domain's states, mapped to a tone and a human label in one place.
|
||||
*
|
||||
* This map is the reason StatusBadge exists: `ai_screened` appeared with three
|
||||
* different colours and two different spellings across the reference pages.
|
||||
* Adding a state here makes it correct everywhere at once.
|
||||
*/
|
||||
export const STATUS_MAP = {
|
||||
/* Application lifecycle */
|
||||
applied: { label: 'Applied', tone: 'neutral' },
|
||||
ai_screened: { label: 'AI Screened', tone: 'info' },
|
||||
shortlisted: { label: 'Shortlisted', tone: 'accent' },
|
||||
interview: { label: 'Interviewing', tone: 'warning' },
|
||||
hired: { label: 'Hired', tone: 'success' },
|
||||
rejected: { label: 'Declined', tone: 'destructive' },
|
||||
|
||||
/* Job posting status */
|
||||
active: { label: 'Hiring', tone: 'default' },
|
||||
draft: { label: 'Draft', tone: 'neutral' },
|
||||
paused: { label: 'Paused', tone: 'warning' },
|
||||
closed: { label: 'Closed', tone: 'destructive' },
|
||||
|
||||
/* Staff status */
|
||||
onboarding: { label: 'Onboarding', tone: 'warning' },
|
||||
inactive: { label: 'Inactive', tone: 'neutral' },
|
||||
|
||||
/* Score bands — the same vocabulary talentHome.js uses */
|
||||
elite: { label: 'Elite', tone: 'default' },
|
||||
excellent: { label: 'Excellent', tone: 'success' },
|
||||
solid: { label: 'Solid', tone: 'info' },
|
||||
building: { label: 'Building', tone: 'warning' },
|
||||
new: { label: 'New', tone: 'neutral' },
|
||||
no_score: { label: 'No score yet', tone: 'neutral' },
|
||||
|
||||
/* Interview verdicts */
|
||||
hire: { label: 'Hire', tone: 'success' },
|
||||
maybe: { label: 'Maybe', tone: 'warning' },
|
||||
no: { label: 'No', tone: 'destructive' },
|
||||
|
||||
/* Evidence verdicts */
|
||||
verified: { label: 'Verified', tone: 'success' },
|
||||
needs_work: { label: 'Needs work', tone: 'warning' },
|
||||
failed: { label: 'Failed', tone: 'destructive' },
|
||||
pending: { label: 'Pending', tone: 'neutral' },
|
||||
expired: { label: 'Expired', tone: 'destructive' },
|
||||
|
||||
/* Profile tiers */
|
||||
Skilled: { label: 'Skilled', tone: 'default' },
|
||||
'Cross-Trained': { label: 'Cross-Trained', tone: 'info' },
|
||||
Beginner: { label: 'Beginner', tone: 'neutral' },
|
||||
};
|
||||
|
||||
/** Title-cases an unknown status so it never renders as a raw enum. */
|
||||
const humanize = (status) =>
|
||||
String(status).replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
|
||||
const DOT_COLOR = {
|
||||
default: 'bg-krow-blue',
|
||||
soft: 'bg-krow-blue',
|
||||
accent: 'bg-krow-blue',
|
||||
navy: 'bg-krow-navy',
|
||||
success: 'bg-success',
|
||||
warning: 'bg-warning',
|
||||
destructive: 'bg-destructive',
|
||||
info: 'bg-info',
|
||||
neutral: 'bg-ink-4',
|
||||
outline: 'bg-ink-4',
|
||||
};
|
||||
|
||||
/**
|
||||
* StatusBadge — renders a domain status with its canonical colour and wording.
|
||||
*
|
||||
* `dot` adds a leading indicator for dense rows where colour alone is doing too
|
||||
* much work; it also gives colour-blind users a second cue alongside the label.
|
||||
*/
|
||||
export function StatusBadge({ status, label, tone, dot = false, size = 'default', className, ...props }) {
|
||||
const entry = STATUS_MAP[status] || {};
|
||||
const resolvedTone = tone || entry.tone || 'neutral';
|
||||
const resolvedLabel = label || entry.label || humanize(status);
|
||||
|
||||
return (
|
||||
<Badge variant={resolvedTone} size={size} className={cn('gap-1.5', className)} {...props}>
|
||||
{dot && (
|
||||
<span
|
||||
className={cn('w-1.5 h-1.5 rounded-full shrink-0', DOT_COLOR[resolvedTone])}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{resolvedLabel}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
72
src/components/ds/Surface.jsx
Normal file
72
src/components/ds/Surface.jsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import * as React from 'react';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Surface — the canonical container in the design system.
|
||||
*
|
||||
* Every card, panel and boxed region in the app is a Surface, so radius,
|
||||
* border, elevation and hover behaviour can only be defined in one place.
|
||||
* `glass` is the default because the app sits on a gradient-mesh background
|
||||
* that solid white cards flatten.
|
||||
*/
|
||||
const surfaceVariants = cva(
|
||||
'transition-all duration-base ease-out',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
glass: 'glass-card border border-white/60',
|
||||
solid: 'bg-surface border border-border',
|
||||
subtle: 'bg-surface-subtle border border-border',
|
||||
sunken: 'bg-surface-sunken border border-transparent',
|
||||
outline: 'bg-transparent border border-border',
|
||||
/* The one dark surface — the Owliver panel */
|
||||
panel: 'panel-surface border border-panel-border text-panel-foreground',
|
||||
},
|
||||
elevation: {
|
||||
none: 'shadow-none',
|
||||
xs: 'shadow-xs',
|
||||
sm: 'shadow-sm',
|
||||
md: 'shadow-md',
|
||||
lg: 'shadow-lg',
|
||||
xl: 'shadow-xl',
|
||||
},
|
||||
radius: {
|
||||
md: 'rounded-lg',
|
||||
lg: 'rounded-xl',
|
||||
xl: 'rounded-2xl',
|
||||
},
|
||||
padding: {
|
||||
none: 'p-0',
|
||||
sm: 'p-4',
|
||||
default: 'p-5',
|
||||
lg: 'p-6',
|
||||
},
|
||||
interactive: {
|
||||
true: 'cursor-pointer hover:shadow-lg hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0A39DF]/60 focus-visible:ring-offset-2',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'glass',
|
||||
elevation: 'sm',
|
||||
radius: 'xl',
|
||||
padding: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const Surface = React.forwardRef(
|
||||
({ className, variant, elevation, radius, padding, interactive, as, ...props }, ref) => {
|
||||
const Comp = as || 'div';
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
className={cn(surfaceVariants({ variant, elevation, radius, padding, interactive }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Surface.displayName = 'Surface';
|
||||
|
||||
export { Surface, surfaceVariants };
|
||||
119
src/components/ds/Tabs.jsx
Normal file
119
src/components/ds/Tabs.jsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import * as React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
/**
|
||||
* Tabs — switches between views of the same page.
|
||||
*
|
||||
* Two visual styles:
|
||||
* - `pill` — matches the app's header navigation, with a sliding indicator
|
||||
* - `underline` — quieter, for tabs inside a card
|
||||
*
|
||||
* The sliding indicator uses a shared `layoutId`, so the highlight animates
|
||||
* between tabs the same way the main nav pill does. `layoutGroupId` must be
|
||||
* unique when two tab sets are on screen at once, otherwise the indicator
|
||||
* animates between them.
|
||||
*/
|
||||
export function Tabs({
|
||||
tabs = [],
|
||||
value,
|
||||
onChange,
|
||||
variant = 'pill',
|
||||
size = 'default',
|
||||
layoutGroupId,
|
||||
className,
|
||||
ariaLabel = 'Views',
|
||||
}) {
|
||||
const normalized = tabs.map((t) => (typeof t === 'string' ? { value: t, label: t } : t));
|
||||
const fallbackId = React.useId();
|
||||
const groupId = layoutGroupId || fallbackId;
|
||||
|
||||
if (variant === 'underline') {
|
||||
return (
|
||||
<div role="tablist" aria-label={ariaLabel} className={cn('flex items-center gap-1 border-b border-border', className)}>
|
||||
{normalized.map((tab) => {
|
||||
const active = tab.value === value;
|
||||
return (
|
||||
<button
|
||||
key={tab.value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => onChange?.(tab.value)}
|
||||
className={cn(
|
||||
'relative inline-flex items-center gap-2 px-3 pb-2.5 pt-1.5 font-medium transition-colors duration-base focus-ring-inset rounded-t-md',
|
||||
size === 'sm' ? 'text-body-sm' : 'text-body',
|
||||
active ? 'text-krow-blue' : 'text-ink-3 hover:text-ink-1'
|
||||
)}
|
||||
>
|
||||
{tab.icon && <tab.icon className="w-4 h-4" aria-hidden="true" />}
|
||||
{tab.label}
|
||||
{tab.count != null && (
|
||||
<Badge variant={active ? 'soft' : 'neutral'} size="sm">{tab.count}</Badge>
|
||||
)}
|
||||
{active && (
|
||||
<motion.span
|
||||
layoutId={`tab-underline-${groupId}`}
|
||||
className="absolute left-0 right-0 -bottom-px h-0.5 rounded-full bg-krow-blue"
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-full bg-white/60 backdrop-blur-xl p-1 border border-white/50 shadow-sm',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{normalized.map((tab) => {
|
||||
const active = tab.value === value;
|
||||
return (
|
||||
<button
|
||||
key={tab.value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => onChange?.(tab.value)}
|
||||
className={cn(
|
||||
'relative inline-flex items-center gap-1.5 rounded-full font-medium transition-colors duration-base focus-ring',
|
||||
size === 'sm' ? 'px-3 py-1 text-caption' : 'px-4 py-1.5 text-body-sm',
|
||||
active ? 'text-white' : 'text-ink-3 hover:text-ink-1'
|
||||
)}
|
||||
>
|
||||
{active && (
|
||||
<motion.span
|
||||
layoutId={`tab-pill-${groupId}`}
|
||||
className="absolute inset-0 rounded-full bg-krow-blue"
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-10 inline-flex items-center gap-1.5">
|
||||
{tab.icon && <tab.icon className="w-3.5 h-3.5" aria-hidden="true" />}
|
||||
{tab.label}
|
||||
{tab.count != null && (
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full px-1.5 text-[10px] font-bold tabular-nums',
|
||||
active ? 'bg-white/25 text-white' : 'bg-surface-sunken text-ink-3'
|
||||
)}
|
||||
>
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
src/components/ds/Timeline.jsx
Normal file
99
src/components/ds/Timeline.jsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { StatusBadge } from '@/components/ds/StatusBadge';
|
||||
|
||||
const TONE_DOT = {
|
||||
brand: 'bg-krow-blue ring-krow-blue/20',
|
||||
success: 'bg-success ring-success/20',
|
||||
warning: 'bg-warning ring-warning/20',
|
||||
destructive: 'bg-destructive ring-destructive/20',
|
||||
neutral: 'bg-ink-4 ring-ink-4/20',
|
||||
};
|
||||
|
||||
/**
|
||||
* Timeline — a vertical sequence of events: a candidate's journey, an audit
|
||||
* trail, a career history.
|
||||
*
|
||||
* Items:
|
||||
* { id, title, description, timestamp, icon, tone, status, meta, current }
|
||||
*
|
||||
* The connecting rail is drawn per item rather than as one absolute line, so a
|
||||
* timeline can be nested inside a scrolling card without the rail detaching.
|
||||
*/
|
||||
export function Timeline({ items = [], className, compact = false }) {
|
||||
return (
|
||||
<ol className={cn('relative', className)}>
|
||||
{items.map((item, i) => {
|
||||
const isLast = i === items.length - 1;
|
||||
const Icon = item.icon;
|
||||
const tone = item.tone || (item.current ? 'brand' : 'neutral');
|
||||
|
||||
return (
|
||||
<li key={item.id ?? i} className="relative flex gap-3.5 pb-5 last:pb-0">
|
||||
{/* Rail + node */}
|
||||
<div className="relative flex flex-col items-center shrink-0">
|
||||
{Icon ? (
|
||||
<span
|
||||
className={cn(
|
||||
'grid place-items-center rounded-full ring-4 shrink-0 z-10',
|
||||
compact ? 'w-6 h-6' : 'w-8 h-8',
|
||||
tone === 'neutral' ? 'bg-surface-sunken text-ink-3 ring-white' : 'text-white ring-white',
|
||||
tone !== 'neutral' && TONE_DOT[tone]
|
||||
)}
|
||||
>
|
||||
<Icon className={compact ? 'w-3 h-3' : 'w-4 h-4'} aria-hidden="true" />
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full ring-4 ring-white shrink-0 z-10 mt-1',
|
||||
compact ? 'w-2 h-2' : 'w-2.5 h-2.5',
|
||||
TONE_DOT[tone],
|
||||
item.current && 'animate-pulse-ring'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLast && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="w-px flex-1 bg-border mt-1.5 min-h-4"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={cn('flex-1 min-w-0', compact ? '-mt-0.5' : '-mt-1')}>
|
||||
<div className="flex items-start justify-between gap-2 flex-wrap">
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className={cn(
|
||||
'font-medium text-ink-1',
|
||||
compact ? 'text-body-sm' : 'text-body'
|
||||
)}
|
||||
>
|
||||
{item.title}
|
||||
</p>
|
||||
{item.description && (
|
||||
<p className="text-body-sm text-ink-3 mt-0.5 leading-relaxed">{item.description}</p>
|
||||
)}
|
||||
{item.meta && <p className="text-caption text-ink-4 mt-1">{item.meta}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{item.status && <StatusBadge status={item.status} size="sm" />}
|
||||
{item.timestamp && (
|
||||
<time className="text-caption text-ink-4 whitespace-nowrap">{item.timestamp}</time>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.children}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
112
src/components/ds/Toggle.jsx
Normal file
112
src/components/ds/Toggle.jsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
|
||||
/**
|
||||
* Toggle — a labelled switch row for settings.
|
||||
*
|
||||
* Wraps the Switch primitive with the label/description layout it is almost
|
||||
* always used with, and makes the whole row a label so the hit target is the
|
||||
* full width rather than the 36px control.
|
||||
*/
|
||||
export function Toggle({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
label,
|
||||
description,
|
||||
disabled,
|
||||
/** `end` puts the switch on the right (settings rows); `start` on the left. */
|
||||
align = 'end',
|
||||
className,
|
||||
id: providedId,
|
||||
}) {
|
||||
const reactId = React.useId();
|
||||
const id = providedId || reactId;
|
||||
const descriptionId = description ? `${id}-description` : undefined;
|
||||
|
||||
const control = (
|
||||
<Switch
|
||||
id={id}
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
disabled={disabled}
|
||||
aria-describedby={descriptionId}
|
||||
/>
|
||||
);
|
||||
|
||||
const text = (
|
||||
<span className="min-w-0">
|
||||
<span className="block text-body-sm font-medium text-ink-2">{label}</span>
|
||||
{description && (
|
||||
<span id={descriptionId} className="block text-caption text-ink-3 mt-0.5">
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<label
|
||||
htmlFor={id}
|
||||
className={cn(
|
||||
'flex items-center gap-3 cursor-pointer select-none',
|
||||
disabled && 'cursor-not-allowed opacity-60',
|
||||
align === 'end' && 'justify-between',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{align === 'start' ? (
|
||||
<>
|
||||
{control}
|
||||
{text}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{text}
|
||||
{control}
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* SegmentedToggle — a small pill group for switching between 2–4 mutually
|
||||
* exclusive views. Used where Tabs would be too heavy (chart ranges, EN/ES).
|
||||
*/
|
||||
export function SegmentedToggle({ options = [], value, onChange, size = 'default', className, ariaLabel }) {
|
||||
const normalized = options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o));
|
||||
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-0.5 rounded-full bg-surface-sunken p-0.5 border border-border',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{normalized.map((option) => {
|
||||
const active = option.value === value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
onClick={() => onChange?.(option.value)}
|
||||
className={cn(
|
||||
'rounded-full font-medium transition-all duration-base ease-out focus-ring-inset',
|
||||
size === 'sm' ? 'px-2.5 py-1 text-caption' : 'px-3 py-1.5 text-body-sm',
|
||||
active
|
||||
? 'bg-krow-blue text-white shadow-sm'
|
||||
: 'text-ink-3 hover:text-ink-1'
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
125
src/components/ds/index.js
Normal file
125
src/components/ds/index.js
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* KROW design system — the single import surface for building pages.
|
||||
*
|
||||
* import { PageHeader, KpiCard, DataTable, StatusBadge } from '@/components/ds';
|
||||
*
|
||||
* Two layers sit underneath:
|
||||
*
|
||||
* - `components/ui/*` — Radix-based primitives (the mechanics: focus traps,
|
||||
* portals, keyboard handling). Restyled to the KROW tokens, not re-wrapped.
|
||||
* - `components/ds/*` — the app's own composites, built from those primitives.
|
||||
*
|
||||
* Primitives that need no composition are re-exported here rather than
|
||||
* duplicated, so there is exactly one Tooltip, one Popover and one Checkbox in
|
||||
* the codebase and pages never need to know which layer a component came from.
|
||||
*/
|
||||
|
||||
/* ── Foundation ─────────────────────────────────────────────────────────── */
|
||||
export { Surface, surfaceVariants } from './Surface';
|
||||
export { PageHeader, HeroHeader } from './PageHeader';
|
||||
export { SectionHeader } from './SectionHeader';
|
||||
|
||||
/* ── Actions ────────────────────────────────────────────────────────────── */
|
||||
export { Button, buttonVariants } from '@/components/ui/button';
|
||||
export { IconButton } from './IconButton';
|
||||
|
||||
/* ── Forms & controls ───────────────────────────────────────────────────── */
|
||||
export { Field } from './Field';
|
||||
export { Input, inputVariants } from '@/components/ui/input';
|
||||
export { Textarea } from '@/components/ui/textarea';
|
||||
export { Label } from '@/components/ui/label';
|
||||
export { SearchInput } from './SearchInput';
|
||||
export { MultiSelect } from './MultiSelect';
|
||||
export { Toggle, SegmentedToggle } from './Toggle';
|
||||
export { Checkbox } from '@/components/ui/checkbox';
|
||||
export { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
export { Switch } from '@/components/ui/switch';
|
||||
export { Slider } from '@/components/ui/slider';
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
/* ── Identity & status ──────────────────────────────────────────────────── */
|
||||
export { Avatar, AvatarGroup, initialsFrom } from './Avatar';
|
||||
export { Badge, badgeVariants } from '@/components/ui/badge';
|
||||
export { StatusBadge, STATUS_MAP } from './StatusBadge';
|
||||
|
||||
/* ── Navigation ─────────────────────────────────────────────────────────── */
|
||||
export { Tabs } from './Tabs';
|
||||
export { Pagination, buildPageRange } from './Pagination';
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbEllipsis,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@/components/ui/breadcrumb';
|
||||
|
||||
/* ── Overlays ───────────────────────────────────────────────────────────── */
|
||||
export { Modal, ConfirmModal } from './Modal';
|
||||
export { Drawer } from './Drawer';
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
export { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
export { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
|
||||
|
||||
/* ── Feedback ───────────────────────────────────────────────────────────── */
|
||||
export { Alert } from './Alert';
|
||||
export { toast } from './toast';
|
||||
export { EmptyState } from './EmptyState';
|
||||
export { LoadingState, LoadingOverlay, InlineLoading, Spinner } from './LoadingState';
|
||||
export {
|
||||
Skeleton,
|
||||
SkeletonCard,
|
||||
SkeletonChart,
|
||||
SkeletonKpi,
|
||||
SkeletonList,
|
||||
SkeletonRow,
|
||||
SkeletonTable,
|
||||
SkeletonText,
|
||||
} from './Skeleton';
|
||||
|
||||
/* ── Data display ───────────────────────────────────────────────────────── */
|
||||
export { KpiCard, MetricCard } from './KpiCard';
|
||||
export { DataTable } from './DataTable';
|
||||
export { FilterBar } from './FilterBar';
|
||||
export { Timeline } from './Timeline';
|
||||
export { ActivityCard } from './ActivityCard';
|
||||
export {
|
||||
AXIS_PROPS,
|
||||
CHART_COLORS,
|
||||
CHART_TONES,
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartTooltip,
|
||||
} from './ChartContainer';
|
||||
export {
|
||||
ProgressBar,
|
||||
ProgressRing,
|
||||
ProgressSteps,
|
||||
ScoreMeter,
|
||||
toneForScore,
|
||||
} from './Progress';
|
||||
|
||||
/* ── Layout helpers ─────────────────────────────────────────────────────── */
|
||||
export { Grid, PageSection, Stack } from './Layout';
|
||||
80
src/components/ds/toast.js
Normal file
80
src/components/ds/toast.js
Normal file
@@ -0,0 +1,80 @@
|
||||
import { toast as hotToast } from 'react-hot-toast';
|
||||
|
||||
/**
|
||||
* Toast — transient confirmation of a user action.
|
||||
*
|
||||
* The app already depends on react-hot-toast (13 call sites), so this wraps it
|
||||
* rather than introducing a second toast system. Wrapping buys three things:
|
||||
* one place to set styling so toasts match the design system, a named API
|
||||
* (`toast.success` / `toast.error` / `toast.promise`) instead of ad-hoc option
|
||||
* objects, and a single default duration.
|
||||
*
|
||||
* Rule of thumb: toast for "that worked", `Alert` for "here is a condition you
|
||||
* need to keep seeing", `ConfirmModal` for "are you sure".
|
||||
*/
|
||||
|
||||
const BASE_STYLE = {
|
||||
borderRadius: '12px',
|
||||
background: '#FFFFFF',
|
||||
color: 'hsl(215 25% 27%)',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
padding: '10px 14px',
|
||||
boxShadow: '0 12px 28px -6px rgb(16 24 40 / 0.12), 0 4px 10px -4px rgb(16 24 40 / 0.06)',
|
||||
border: '1px solid hsl(150 8% 87%)',
|
||||
maxWidth: '420px',
|
||||
};
|
||||
|
||||
const DURATIONS = { success: 2600, error: 4200, info: 3000, loading: Infinity };
|
||||
|
||||
const withDefaults = (options = {}) => ({
|
||||
position: 'bottom-center',
|
||||
...options,
|
||||
style: { ...BASE_STYLE, ...(options.style || {}) },
|
||||
});
|
||||
|
||||
export const toast = {
|
||||
success(message, options) {
|
||||
return hotToast.success(message, {
|
||||
duration: DURATIONS.success,
|
||||
iconTheme: { primary: 'hsl(142 71% 36%)', secondary: '#FFFFFF' },
|
||||
...withDefaults(options),
|
||||
});
|
||||
},
|
||||
|
||||
error(message, options) {
|
||||
return hotToast.error(message, {
|
||||
duration: DURATIONS.error,
|
||||
iconTheme: { primary: 'hsl(0 84% 60%)', secondary: '#FFFFFF' },
|
||||
...withDefaults(options),
|
||||
});
|
||||
},
|
||||
|
||||
info(message, options) {
|
||||
return hotToast(message, { duration: DURATIONS.info, ...withDefaults(options) });
|
||||
},
|
||||
|
||||
loading(message, options) {
|
||||
return hotToast.loading(message, { duration: DURATIONS.loading, ...withDefaults(options) });
|
||||
},
|
||||
|
||||
/**
|
||||
* Ties a toast to an async action: loading while it runs, then success or
|
||||
* error. Preferred over a manual loading/dismiss pair, which leaks a spinner
|
||||
* whenever the promise rejects.
|
||||
*/
|
||||
promise(promise, messages, options) {
|
||||
return hotToast.promise(
|
||||
promise,
|
||||
{
|
||||
loading: messages?.loading ?? 'Working…',
|
||||
success: messages?.success ?? 'Done',
|
||||
error: messages?.error ?? 'Something went wrong',
|
||||
},
|
||||
withDefaults(options)
|
||||
);
|
||||
},
|
||||
|
||||
dismiss: hotToast.dismiss,
|
||||
remove: hotToast.remove,
|
||||
};
|
||||
451
src/components/krow/AIInterviewModal.jsx
Normal file
451
src/components/krow/AIInterviewModal.jsx
Normal file
@@ -0,0 +1,451 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Mic, MicOff, Volume2, Loader2, AlertTriangle, CheckCircle2 } from 'lucide-react';
|
||||
import { generateInterviewQuestion, evaluateInterview } from '@/lib/krowAi';
|
||||
import { useCreateInterview, useUpdateApplication } from '@/lib/krowHooks';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const TOTAL_SECONDS = 180;
|
||||
const QUESTION_COUNT = 5;
|
||||
|
||||
export default function AIInterviewModal({ open, onClose, application, job, conductor = 'KROW' }) {
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [phase, setPhase] = useState('intro'); // intro | active | evaluating | done
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const [isSpeaking, setIsSpeaking] = useState(false);
|
||||
const [transcript, setTranscript] = useState('');
|
||||
const [timeLeft, setTimeLeft] = useState(TOTAL_SECONDS);
|
||||
const [evaluation, setEvaluation] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
const [ttsEnabled, setTtsEnabled] = useState(true);
|
||||
const [language, setLanguage] = useState('en'); // 'en' | 'es'
|
||||
|
||||
const recognitionRef = useRef(null);
|
||||
const lastUserMessageTime = useRef(null);
|
||||
const timerRef = useRef(null);
|
||||
const scrollRef = useRef(null);
|
||||
|
||||
const createInterview = useCreateInterview();
|
||||
const updateApplication = useUpdateApplication();
|
||||
|
||||
const jobTitle = job?.title || 'the role';
|
||||
const candidateName = application?.applicant_name || 'Candidate';
|
||||
const isOwliver = conductor === 'Owliver';
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
const speak = useCallback((text) => {
|
||||
return new Promise((resolve) => {
|
||||
if (!ttsEnabled || !window.speechSynthesis) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
window.speechSynthesis.cancel();
|
||||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
const targetLang = language === 'es' ? 'es' : 'en';
|
||||
utterance.lang = language === 'es' ? 'es-ES' : 'en-US';
|
||||
|
||||
// Pick the best available voice for the target language
|
||||
const voices = window.speechSynthesis.getVoices();
|
||||
const langVoices = voices.filter(v => v.lang?.toLowerCase().startsWith(targetLang));
|
||||
if (langVoices.length > 0) {
|
||||
// Prefer native/local voices, then Google voices, then any
|
||||
const preferred =
|
||||
langVoices.find(v => v.localService && v.lang.toLowerCase() === (language === 'es' ? 'es-es' : 'en-us')) ||
|
||||
langVoices.find(v => v.localService) ||
|
||||
langVoices.find(v => v.name.toLowerCase().includes('google')) ||
|
||||
langVoices.find(v => v.lang.toLowerCase() === (language === 'es' ? 'es-es' : 'en-us')) ||
|
||||
langVoices[0];
|
||||
utterance.voice = preferred;
|
||||
}
|
||||
|
||||
utterance.rate = language === 'es' ? 0.98 : 1.05;
|
||||
utterance.pitch = 1.0;
|
||||
utterance.onstart = () => setIsSpeaking(true);
|
||||
utterance.onend = () => { setIsSpeaking(false); resolve(); };
|
||||
utterance.onerror = () => { setIsSpeaking(false); resolve(); };
|
||||
window.speechSynthesis.speak(utterance);
|
||||
});
|
||||
}, [ttsEnabled, language]);
|
||||
|
||||
const stopListening = useCallback(() => {
|
||||
if (recognitionRef.current) {
|
||||
recognitionRef.current.stop();
|
||||
recognitionRef.current = null;
|
||||
}
|
||||
setIsListening(false);
|
||||
}, []);
|
||||
|
||||
const askNextQuestion = useCallback(async (currentMessages) => {
|
||||
const qNumber = currentMessages.filter(m => m.role === 'assistant').length + 1;
|
||||
if (qNumber > QUESTION_COUNT) {
|
||||
await finishInterview(currentMessages);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const question = await generateInterviewQuestion(currentMessages, jobTitle, qNumber, language);
|
||||
const aiMsg = { role: 'assistant', content: question, timestamp: new Date().toISOString() };
|
||||
const newMessages = [...currentMessages, aiMsg];
|
||||
setMessages(newMessages);
|
||||
lastUserMessageTime.current = Date.now();
|
||||
await speak(question);
|
||||
} catch (e) {
|
||||
setError('Interview connection issue. Please retry.');
|
||||
}
|
||||
}, [jobTitle, speak, language]);
|
||||
|
||||
const startListening = useCallback(() => {
|
||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
if (!SpeechRecognition) {
|
||||
setError('Voice input not supported in this browser. Use text mode below.');
|
||||
return;
|
||||
}
|
||||
// Preserve existing transcript so the user can continue speaking
|
||||
const baseTranscript = transcript;
|
||||
if (!baseTranscript) setTranscript('');
|
||||
setError('');
|
||||
const recognition = new SpeechRecognition();
|
||||
recognition.continuous = false;
|
||||
recognition.interimResults = true;
|
||||
recognition.lang = language === 'es' ? 'es-ES' : 'en-US';
|
||||
|
||||
recognition.onresult = (event) => {
|
||||
let text = '';
|
||||
for (let i = 0; i < event.results.length; i++) {
|
||||
text += event.results[i][0].transcript;
|
||||
}
|
||||
setTranscript((baseTranscript ? baseTranscript + ' ' : '') + text.trim());
|
||||
};
|
||||
recognition.onerror = (event) => {
|
||||
if (event.error !== 'no-speech') setError(`Mic error: ${event.error}`);
|
||||
setIsListening(false);
|
||||
};
|
||||
recognition.onend = () => {
|
||||
setIsListening(false);
|
||||
};
|
||||
|
||||
recognitionRef.current = recognition;
|
||||
recognition.start();
|
||||
setIsListening(true);
|
||||
}, [language, transcript]);
|
||||
|
||||
const submitAnswer = useCallback(async () => {
|
||||
if (!transcript.trim()) return;
|
||||
stopListening();
|
||||
const responseTime = lastUserMessageTime.current ? (Date.now() - lastUserMessageTime.current) / 1000 : 0;
|
||||
const userMsg = {
|
||||
role: 'user',
|
||||
content: transcript.trim(),
|
||||
timestamp: new Date().toISOString(),
|
||||
response_time_seconds: Math.round(responseTime * 10) / 10,
|
||||
};
|
||||
const newMessages = [...messages, userMsg];
|
||||
setMessages(newMessages);
|
||||
setTranscript('');
|
||||
// Ask next question
|
||||
setTimeout(() => askNextQuestion(newMessages), 300);
|
||||
}, [transcript, messages, stopListening, askNextQuestion]);
|
||||
|
||||
const finishInterview = useCallback(async (finalMessages) => {
|
||||
setPhase('evaluating');
|
||||
try {
|
||||
const result = await evaluateInterview(finalMessages, job, candidateName, language);
|
||||
const interviewRecord = await createInterview.mutateAsync({
|
||||
application_id: application.id,
|
||||
job_posting_id: application.job_posting_id,
|
||||
job_title: jobTitle,
|
||||
candidate_name: candidateName,
|
||||
messages: finalMessages,
|
||||
overall_interview_score: result.overall_interview_score,
|
||||
verdict: result.verdict,
|
||||
hire_recommendation: result.hire_recommendation,
|
||||
integrity_score: result.integrity_score,
|
||||
ai_flags: result.ai_flags,
|
||||
category_scores: result.category_scores,
|
||||
strengths: result.strengths,
|
||||
concerns: result.concerns,
|
||||
best_fit_roles: result.best_fit_roles,
|
||||
summary: result.summary,
|
||||
reasoning: result.reasoning,
|
||||
});
|
||||
await updateApplication.mutateAsync({ id: application.id, data: {
|
||||
status: 'interview',
|
||||
interview_id: interviewRecord.id,
|
||||
ai_score: result.overall_interview_score,
|
||||
} });
|
||||
setEvaluation(result);
|
||||
setPhase('done');
|
||||
} catch (e) {
|
||||
setError('Evaluation failed. Please try again.');
|
||||
setPhase('active');
|
||||
}
|
||||
}, [application, job, candidateName, createInterview, updateApplication, language]);
|
||||
|
||||
const startInterview = useCallback(() => {
|
||||
setPhase('active');
|
||||
setTimeLeft(TOTAL_SECONDS);
|
||||
setMessages([]);
|
||||
askNextQuestion([]);
|
||||
}, [askNextQuestion]);
|
||||
|
||||
// Timer
|
||||
useEffect(() => {
|
||||
if (phase !== 'active') return;
|
||||
timerRef.current = setInterval(() => {
|
||||
setTimeLeft((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(timerRef.current);
|
||||
stopListening();
|
||||
if (messages.length > 0) finishInterview(messages);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
return () => clearInterval(timerRef.current);
|
||||
}, [phase, messages, finishInterview, stopListening]);
|
||||
|
||||
// Cleanup
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopListening();
|
||||
if (window.speechSynthesis) window.speechSynthesis.cancel();
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
};
|
||||
}, [stopListening]);
|
||||
|
||||
const handleClose = () => {
|
||||
stopListening();
|
||||
if (window.speechSynthesis) window.speechSynthesis.cancel();
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
setPhase('intro');
|
||||
setMessages([]);
|
||||
setEvaluation(null);
|
||||
setError('');
|
||||
setTimeLeft(TOTAL_SECONDS);
|
||||
setLanguage('en');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const mins = Math.floor(timeLeft / 60);
|
||||
const secs = timeLeft % 60;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2">
|
||||
{isOwliver ? (
|
||||
<OwliverAvatar className="w-8 h-8" />
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-[#0A39DF] flex items-center justify-center text-white text-[13px] font-bold">K</div>
|
||||
)}
|
||||
AI Interview with {conductor}
|
||||
</span>
|
||||
{phase === 'active' && (
|
||||
<span className={cn('text-[13px] font-mono px-2 py-1 rounded-lg', timeLeft < 30 ? 'bg-[#FEE2E2] text-[#991B1B]' : 'bg-[#F3F4F6] text-[#374151]')}>
|
||||
{mins}:{secs.toString().padStart(2, '0')}
|
||||
</span>
|
||||
)}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{phase === 'intro' && (
|
||||
<div className="py-6 text-center">
|
||||
{isOwliver ? (
|
||||
<OwliverAvatar className="w-16 h-16 mx-auto mb-4" />
|
||||
) : (
|
||||
<div className="w-16 h-16 rounded-full bg-[#0A39DF] flex items-center justify-center text-white text-2xl font-bold mx-auto mb-4">K</div>
|
||||
)}
|
||||
<h3 className="text-lg font-semibold text-[#111827] mb-2">Hi {candidateName.split(' ')[0]}, I'm {conductor}</h3>
|
||||
<p className="text-[14px] text-[#6B7280] max-w-md mx-auto mb-6">
|
||||
{language === 'es'
|
||||
? `Te haré ${QUESTION_COUNT} preguntas rápidas sobre tu experiencia en roles de ${jobTitle}. Toma unos 3 minutos. Solo habla naturalmente — no hay respuestas correctas o incorrectas.`
|
||||
: `I'll ask you ${QUESTION_COUNT} quick questions about your experience with ${jobTitle} roles. This takes about 3 minutes. Just speak naturally — there are no right or wrong answers.`}
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-3 mb-4 text-[12px] text-[#6B7280]">
|
||||
<span className="inline-flex items-center gap-1"><Volume2 className="w-3.5 h-3.5" /> Voice enabled</span>
|
||||
<button onClick={() => setTtsEnabled(!ttsEnabled)} className="text-[#0A39DF] underline">
|
||||
{ttsEnabled ? 'Mute' : 'Unmute'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Language selector */}
|
||||
<div className="flex items-center justify-center gap-2 mb-6">
|
||||
<span className="text-[12px] text-[#6B7280] font-medium">Interview language:</span>
|
||||
<div className="inline-flex rounded-lg border border-[#E5E7EB] overflow-hidden">
|
||||
<button
|
||||
onClick={() => setLanguage('en')}
|
||||
className={cn(
|
||||
'px-3 py-1.5 text-[12px] font-medium transition-colors',
|
||||
language === 'en' ? 'bg-[#0A39DF] text-white' : 'bg-white text-[#6B7280] hover:bg-[#F3F4F6]'
|
||||
)}
|
||||
>
|
||||
English
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLanguage('es')}
|
||||
className={cn(
|
||||
'px-3 py-1.5 text-[12px] font-medium transition-colors',
|
||||
language === 'es' ? 'bg-[#0A39DF] text-white' : 'bg-white text-[#6B7280] hover:bg-[#F3F4F6]'
|
||||
)}
|
||||
>
|
||||
Español
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={startInterview} className="bg-[#0A39DF] hover:bg-[#082EB4] px-8">
|
||||
<Mic className="w-4 h-4 mr-2" /> Start Interview
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'active' && (
|
||||
<div className="space-y-4">
|
||||
<div ref={scrollRef} className="h-[300px] overflow-y-auto space-y-3 p-2">
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i} className={cn('flex', msg.role === 'user' ? 'justify-end' : 'justify-start')}>
|
||||
<div className={cn(
|
||||
'max-w-[80%] rounded-2xl px-4 py-2.5 text-[14px]',
|
||||
msg.role === 'user'
|
||||
? 'bg-[#0A39DF] text-white rounded-br-sm'
|
||||
: 'bg-[#F3F4F6] text-[#111827] rounded-bl-sm'
|
||||
)}>
|
||||
<p>{msg.content}</p>
|
||||
{msg.response_time_seconds != null && msg.response_time_seconds < 8 && (
|
||||
<p className="text-[10px] mt-1 opacity-70 inline-flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3" /> Fast response flagged
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isSpeaking && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-[#F3F4F6] rounded-2xl rounded-bl-sm px-4 py-3 flex gap-1">
|
||||
<span className="w-2 h-2 bg-[#9CA3AF] rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
||||
<span className="w-2 h-2 bg-[#9CA3AF] rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
||||
<span className="w-2 h-2 bg-[#9CA3AF] rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-[12px] text-[#EF4444]">{error}</p>}
|
||||
|
||||
{/* Answer input */}
|
||||
<div className="border-t border-[#E5E7EB] pt-3">
|
||||
<textarea
|
||||
value={transcript}
|
||||
onChange={(e) => setTranscript(e.target.value)}
|
||||
placeholder={isListening ? 'Listening...' : 'Type your answer or click mic to speak'}
|
||||
className="w-full min-h-[60px] p-3 text-[14px] rounded-lg border border-[#E5E7EB] focus:border-[#0A39DF] focus:outline-none resize-none"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submitAnswer();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<button
|
||||
onClick={isListening ? stopListening : startListening}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 px-4 py-2 rounded-lg text-[13px] font-medium transition-colors',
|
||||
isListening ? 'bg-[#FEE2E2] text-[#991B1B]' : 'bg-[#F3F4F6] text-[#374151] hover:bg-[#E5E7EB]'
|
||||
)}
|
||||
>
|
||||
{isListening ? <MicOff className="w-4 h-4" /> : <Mic className="w-4 h-4" />}
|
||||
{isListening ? 'Stop' : 'Speak'}
|
||||
</button>
|
||||
<Button
|
||||
onClick={submitAnswer}
|
||||
disabled={!transcript.trim()}
|
||||
className="bg-[#0A39DF] hover:bg-[#082EB4]"
|
||||
>
|
||||
Send Answer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'evaluating' && (
|
||||
<div className="py-12 text-center">
|
||||
<Loader2 className="w-10 h-10 text-[#0A39DF] animate-spin mx-auto mb-4" />
|
||||
<p className="text-[14px] text-[#6B7280]">{conductor} is evaluating your interview...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'done' && evaluation && (
|
||||
<div className="py-4 space-y-4">
|
||||
<div className="text-center">
|
||||
<div className={cn(
|
||||
'w-16 h-16 rounded-full mx-auto flex items-center justify-center text-white text-xl font-bold mb-3',
|
||||
evaluation.verdict === 'hire' ? 'bg-[#059669]' : evaluation.verdict === 'maybe' ? 'bg-[#F59E0B]' : 'bg-[#EF4444]'
|
||||
)}>
|
||||
{evaluation.overall_interview_score}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-[#111827] capitalize">Verdict: {evaluation.verdict}</h3>
|
||||
<p className="text-[13px] text-[#6B7280] mt-1">{evaluation.hire_recommendation}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{evaluation.category_scores && Object.entries(evaluation.category_scores).map(([key, val]) => (
|
||||
<div key={key} className="bg-[#F9FAFB] rounded-lg p-3">
|
||||
<div className="text-[11px] text-[#6B7280] capitalize">{key.replace(/_/g, ' ')}</div>
|
||||
<div className="text-lg font-bold text-[#111827]">{val}/100</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{evaluation.integrity_score < 100 && (
|
||||
<div className="flex items-start gap-2 p-3 bg-[#FEF3C7] rounded-lg">
|
||||
<AlertTriangle className="w-4 h-4 text-[#92400E] mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-[12px] font-medium text-[#92400E]">Integrity Score: {evaluation.integrity_score}/100</p>
|
||||
{evaluation.ai_flags?.length > 0 && (
|
||||
<p className="text-[11px] text-[#92400E] mt-1">{evaluation.ai_flags.join(', ')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="text-[12px] font-medium text-[#374151] mb-1">Summary</p>
|
||||
<p className="text-[13px] text-[#6B7280]">{evaluation.summary}</p>
|
||||
</div>
|
||||
|
||||
{evaluation.strengths?.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[12px] font-medium text-[#059669] mb-1 flex items-center gap-1"><CheckCircle2 className="w-3.5 h-3.5" /> Strengths</p>
|
||||
<ul className="text-[13px] text-[#6B7280] list-disc list-inside space-y-0.5">
|
||||
{evaluation.strengths.map((s, i) => <li key={i}>{s}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{evaluation.concerns?.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[12px] font-medium text-[#EF4444] mb-1 flex items-center gap-1"><AlertTriangle className="w-3.5 h-3.5" /> Concerns</p>
|
||||
<ul className="text-[13px] text-[#6B7280] list-disc list-inside space-y-0.5">
|
||||
{evaluation.concerns.map((c, i) => <li key={i}>{c}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button onClick={handleClose} className="w-full bg-[#0A39DF] hover:bg-[#082EB4]">Done</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
107
src/components/krow/ActivityCharts.jsx
Normal file
107
src/components/krow/ActivityCharts.jsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
BarChart,
|
||||
Bar,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
|
||||
const PIE_COLORS = ['#0A39DF', '#059669', '#333F48', '#D97706', '#DC2626', '#0891B2', '#DB2777'];
|
||||
|
||||
function last7Days() {
|
||||
const days = [];
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - i);
|
||||
days.push(d.toISOString().split('T')[0]);
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
export default function ActivityCharts({ activities }) {
|
||||
const timeSeries = useMemo(() => {
|
||||
const days = last7Days();
|
||||
return days.map((day) => {
|
||||
const dayActivities = activities.filter((a) => (a.created_date || '').slice(0, 10) === day);
|
||||
return {
|
||||
date: day.slice(5),
|
||||
signups: dayActivities.filter((a) => a.event_type === 'signup').length,
|
||||
logins: dayActivities.filter((a) => a.event_type === 'login').length,
|
||||
actions: dayActivities.length,
|
||||
};
|
||||
});
|
||||
}, [activities]);
|
||||
|
||||
const byType = useMemo(() => {
|
||||
const counts = {};
|
||||
activities.forEach((a) => {
|
||||
counts[a.event_type] = (counts[a.event_type] || 0) + 1;
|
||||
});
|
||||
return Object.entries(counts).map(([name, value]) => ({ name, value }));
|
||||
}, [activities]);
|
||||
|
||||
const byRole = useMemo(() => {
|
||||
const counts = {};
|
||||
activities.forEach((a) => {
|
||||
counts[a.account_type] = (counts[a.account_type] || 0) + 1;
|
||||
});
|
||||
return Object.entries(counts).map(([name, value]) => ({ name, value }));
|
||||
}, [activities]);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="bg-white rounded-xl border border-[#E5E7EB] p-5">
|
||||
<h3 className="text-[14px] font-semibold text-[#111827] mb-4">Activity (Last 7 Days)</h3>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<LineChart data={timeSeries}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#F3F4F6" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 11 }} stroke="#9CA3AF" />
|
||||
<YAxis tick={{ fontSize: 11 }} stroke="#9CA3AF" allowDecimals={false} />
|
||||
<Tooltip />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Line type="monotone" dataKey="signups" stroke="#0A39DF" strokeWidth={2} />
|
||||
<Line type="monotone" dataKey="logins" stroke="#059669" strokeWidth={2} />
|
||||
<Line type="monotone" dataKey="actions" stroke="#D97706" strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-[#E5E7EB] p-5">
|
||||
<h3 className="text-[14px] font-semibold text-[#111827] mb-4">Actions by Type</h3>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<BarChart data={byType}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#F3F4F6" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 10 }} stroke="#9CA3AF" interval={0} angle={-20} textAnchor="end" height={50} />
|
||||
<YAxis tick={{ fontSize: 11 }} stroke="#9CA3AF" allowDecimals={false} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="value" fill="#0A39DF" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-[#E5E7EB] p-5">
|
||||
<h3 className="text-[14px] font-semibold text-[#111827] mb-4">Users by Role</h3>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<PieChart>
|
||||
<Pie data={byRole} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={80} label>
|
||||
{byRole.map((_, i) => (
|
||||
<Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
131
src/components/krow/ActivityLogTable.jsx
Normal file
131
src/components/krow/ActivityLogTable.jsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
|
||||
const EVENT_LABELS = {
|
||||
signup: 'Sign Up',
|
||||
login: 'Login',
|
||||
logout: 'Logout',
|
||||
create_position: 'Create Position',
|
||||
screen_candidate: 'Screen Candidate',
|
||||
hire_candidate: 'Hire Candidate',
|
||||
apply_job: 'Apply to Job',
|
||||
start_interview: 'Start Interview',
|
||||
};
|
||||
|
||||
const EVENT_COLORS = {
|
||||
signup: 'bg-[#EFF6FF] text-[#1E40AF]',
|
||||
login: 'bg-[#ECFDF5] text-[#065F46]',
|
||||
logout: 'bg-[#F3F4F6] text-[#374151]',
|
||||
create_position: 'bg-[#F5F3FF] text-[#5B21B6]',
|
||||
screen_candidate: 'bg-[#FFFBEB] text-[#92400E]',
|
||||
hire_candidate: 'bg-[#ECFDF5] text-[#065F46]',
|
||||
apply_job: 'bg-[#EFF6FF] text-[#1E40AF]',
|
||||
start_interview: 'bg-[#FEF2F2] text-[#991B1B]',
|
||||
};
|
||||
|
||||
function formatTime(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export default function ActivityLogTable({ activities }) {
|
||||
const [filter, setFilter] = useState('all');
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const filtered = activities.filter((a) => {
|
||||
if (filter !== 'all' && a.event_type !== filter) return false;
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
return (
|
||||
(a.user_email || '').toLowerCase().includes(q) ||
|
||||
(a.user_name || '').toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const eventTypes = ['all', ...Object.keys(EVENT_LABELS)];
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-[#E5E7EB] overflow-hidden">
|
||||
<div className="p-4 border-b border-[#E5E7EB] flex flex-col sm:flex-row gap-3 sm:items-center justify-between">
|
||||
<h3 className="text-[14px] font-semibold text-[#111827]">Activity Log</h3>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[#9CA3AF]" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search user..."
|
||||
className="pl-8 pr-3 h-8 text-[12px] rounded-md border border-[#E5E7EB] focus:border-[#2563EB] focus:outline-none w-40"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="h-8 text-[12px] rounded-md border border-[#E5E7EB] bg-white px-2 focus:border-[#2563EB] focus:outline-none"
|
||||
>
|
||||
{eventTypes.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t === 'all' ? 'All Events' : EVENT_LABELS[t]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[13px]">
|
||||
<thead className="bg-[#F9FAFB] text-[#6B7280]">
|
||||
<tr>
|
||||
<th className="text-left font-medium px-4 py-2.5">Event</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">User</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Role</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Details</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#F3F4F6]">
|
||||
{filtered.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-center py-8 text-[#9CA3AF]">
|
||||
No activity found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filtered.slice(0, 50).map((a) => (
|
||||
<tr key={a.id} className="hover:bg-[#F9FAFB]">
|
||||
<td className="px-4 py-2.5">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded-full text-[11px] font-medium ${
|
||||
EVENT_COLORS[a.event_type] || 'bg-[#F3F4F6] text-[#374151]'
|
||||
}`}
|
||||
>
|
||||
{EVENT_LABELS[a.event_type] || a.event_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="font-medium text-[#111827]">{a.user_name || '—'}</div>
|
||||
<div className="text-[11px] text-[#9CA3AF]">{a.user_email}</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 capitalize text-[#6B7280]">{a.account_type || '—'}</td>
|
||||
<td className="px-4 py-2.5 text-[#6B7280] max-w-[200px] truncate">
|
||||
{a.details || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-[#6B7280] whitespace-nowrap">
|
||||
{formatTime(a.created_date)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
src/components/krow/ActivityMetrics.jsx
Normal file
41
src/components/krow/ActivityMetrics.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import { Users, LogIn, Activity, UserCheck } from 'lucide-react';
|
||||
|
||||
export default function ActivityMetrics({ activities }) {
|
||||
const now = Date.now();
|
||||
const dayAgo = now - 24 * 60 * 60 * 1000;
|
||||
|
||||
const signups = activities.filter((a) => a.event_type === 'signup');
|
||||
const logins = activities.filter((a) => a.event_type === 'login');
|
||||
const logins24h = logins.filter((a) => new Date(a.created_date).getTime() > dayAgo);
|
||||
const recentUsers = [...signups, ...logins].filter(
|
||||
(a) => new Date(a.created_date).getTime() > dayAgo
|
||||
);
|
||||
const activeUsers24h = new Set(recentUsers.map((a) => a.user_email)).size;
|
||||
|
||||
const metrics = [
|
||||
{ label: 'Total Sign-ups', value: signups.length, icon: UserCheck, color: '#0A39DF', bg: '#EFF6FF' },
|
||||
{ label: 'Logins (24h)', value: logins24h.length, icon: LogIn, color: '#059669', bg: '#ECFDF5' },
|
||||
{ label: 'Active Users (24h)', value: activeUsers24h, icon: Users, color: '#333F48', bg: '#F3F4F6' },
|
||||
{ label: 'Total Actions', value: activities.length, icon: Activity, color: '#D97706', bg: '#FFFBEB' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{metrics.map((m) => {
|
||||
const Icon = m.icon;
|
||||
return (
|
||||
<div key={m.label} className="bg-white rounded-xl border border-[#E5E7EB] p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="w-9 h-9 rounded-lg flex items-center justify-center" style={{ background: m.bg }}>
|
||||
<Icon className="w-4 h-4" style={{ color: m.color }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-[#111827]">{m.value}</div>
|
||||
<div className="text-[12px] text-[#6B7280] mt-0.5">{m.label}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
src/components/krow/ActivitySnapshot.jsx
Normal file
28
src/components/krow/ActivitySnapshot.jsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
|
||||
const SNAPSHOTS = [
|
||||
{ key: 'krow', label: 'KROW Score', get: (p) => Math.round(p.krow_score || 0) },
|
||||
{ key: 'badges', label: 'Other Badges', get: (p) => (p.earned_badges || []).length },
|
||||
{ key: 'skills', label: 'Skills', get: (p) => (p.skills || []).length },
|
||||
{ key: 'shares', label: 'Shares', get: () => 0 },
|
||||
];
|
||||
|
||||
export default function ActivitySnapshot({ profile }) {
|
||||
return (
|
||||
<div className="rounded-2xl bg-[#E8EEFE] p-6 border border-[#C7D4F7]">
|
||||
<h3 className="font-serif text-center text-[18px] font-semibold mb-5" style={{ color: '#333F48' }}>
|
||||
Activity snapshot
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 sm:gap-4">
|
||||
{SNAPSHOTS.map((s) => (
|
||||
<div key={s.key} className="bg-white rounded-2xl shadow-sm p-4 flex flex-col items-center justify-center aspect-square">
|
||||
<span className="text-[34px] font-bold leading-none" style={{ color: '#0A39DF' }}>
|
||||
{s.get(profile)}
|
||||
</span>
|
||||
<span className="text-[12px] text-[#333F48] mt-2 text-center">{s.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
src/components/krow/AnalyticsMetrics.jsx
Normal file
38
src/components/krow/AnalyticsMetrics.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { Users, TrendingUp, Brain, Mic, UserCheck, Target } from 'lucide-react';
|
||||
|
||||
export default function AnalyticsMetrics({ applications, interviews, staff }) {
|
||||
const totalApplicants = applications.length;
|
||||
const screened = applications.filter(a => a.status !== 'applied');
|
||||
const scored = applications.filter(a => a.ai_score > 0);
|
||||
const avgScore = scored.length ? Math.round(scored.reduce((s, a) => s + a.ai_score, 0) / scored.length) : 0;
|
||||
const interviewed = applications.filter(a => a.status === 'interview' || a.interview_id);
|
||||
const hired = applications.filter(a => a.status === 'hired');
|
||||
const hireRate = totalApplicants ? Math.round((hired.length / totalApplicants) * 100) : 0;
|
||||
|
||||
const metrics = [
|
||||
{ label: 'Total Applicants', value: totalApplicants, icon: Users, color: '#0A39DF', bg: '#EFF6FF' },
|
||||
{ label: 'Avg AI Score', value: avgScore, icon: Brain, color: '#333F48', bg: '#F3F4F6' },
|
||||
{ label: 'Interviews Done', value: interviewed.length, icon: Mic, color: '#0A39DF', bg: '#FEFCE8' },
|
||||
{ label: 'Hired', value: hired.length, icon: UserCheck, color: '#333F48', bg: '#F3F4F6' },
|
||||
{ label: 'Hire Rate', value: `${hireRate}%`, icon: Target, color: '#0A39DF', bg: '#EFF6FF' },
|
||||
{ label: 'On Staff', value: staff.length, icon: TrendingUp, color: '#333F48', bg: '#D1E0D7' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
{metrics.map((m) => {
|
||||
const Icon = m.icon;
|
||||
return (
|
||||
<div key={m.label} className="glass-card border border-white/60 rounded-2xl p-4 shadow-sm">
|
||||
<div className="w-9 h-9 rounded-xl flex items-center justify-center mb-3" style={{ background: m.bg }}>
|
||||
<Icon className="w-4 h-4" style={{ color: m.color }} />
|
||||
</div>
|
||||
<div className="text-2xl font-heading font-bold text-[#111827]">{m.value}</div>
|
||||
<div className="text-[11px] text-[#9CA3AF] font-medium tracking-wide mt-0.5">{m.label}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
296
src/components/krow/ApplicantForm.jsx
Normal file
296
src/components/krow/ApplicantForm.jsx
Normal file
@@ -0,0 +1,296 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Loader2, UploadCloud, FileCheck2, X, Camera, RefreshCw } from 'lucide-react';
|
||||
import { useCertifications } from '@/lib/krowHooks';
|
||||
import { base44 } from '@/api/base44Client';
|
||||
|
||||
const DEFAULT_CERTS = ['Food Handler Card', 'ServSafe', 'ABC License', 'TIPS Certified', 'CPR/First Aid', 'Background Check Cleared'];
|
||||
const AVAIL_OPTIONS = ['Weekdays', 'Weekends', 'Evenings', 'Mornings', 'Overnight', 'On-Call'];
|
||||
const ENGLISH_LEVELS = ['basic', 'conversational', 'fluent', 'native'];
|
||||
|
||||
export default function ApplicantForm({ onSubmit, submitting }) {
|
||||
const [form, setForm] = useState({
|
||||
applicant_name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
years_experience: 0,
|
||||
english_level: 'basic',
|
||||
certifications: [],
|
||||
availability: [],
|
||||
skills: [],
|
||||
professional_summary: '',
|
||||
cover_letter: '',
|
||||
certification_files: [],
|
||||
selfie_url: '',
|
||||
companies_worked: [],
|
||||
});
|
||||
const [customSkill, setCustomSkill] = useState('');
|
||||
const [customCompany, setCustomCompany] = useState('');
|
||||
const [uploadingSelfie, setUploadingSelfie] = useState(false);
|
||||
const [uploadingCert, setUploadingCert] = useState(false);
|
||||
const [uploadError, setUploadError] = useState('');
|
||||
const { data: certifications = [] } = useCertifications();
|
||||
const certOptions = certifications.length > 0 ? certifications.map(c => c.name) : DEFAULT_CERTS;
|
||||
|
||||
const update = (field, value) => setForm({ ...form, [field]: value });
|
||||
|
||||
const toggleCert = (cert) => {
|
||||
update('certifications', form.certifications.includes(cert)
|
||||
? form.certifications.filter(c => c !== cert)
|
||||
: [...form.certifications, cert]);
|
||||
};
|
||||
|
||||
const toggleAvail = (slot) => {
|
||||
update('availability', form.availability.includes(slot)
|
||||
? form.availability.filter(a => a !== slot)
|
||||
: [...form.availability, slot]);
|
||||
};
|
||||
|
||||
const handleCertUpload = async (e) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (files.length === 0) return;
|
||||
setUploadingCert(true);
|
||||
try {
|
||||
const uploaded = [];
|
||||
for (const file of files) {
|
||||
const { file_url } = await base44.integrations.Core.UploadFile({ file });
|
||||
uploaded.push(file_url);
|
||||
}
|
||||
update('certification_files', [...form.certification_files, ...uploaded]);
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
setUploadingCert(false);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const removeCertFile = (idx) => {
|
||||
update('certification_files', form.certification_files.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
const handleSelfieUpload = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploadingSelfie(true);
|
||||
try {
|
||||
const { file_url } = await base44.integrations.Core.UploadFile({ file });
|
||||
update('selfie_url', file_url);
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
setUploadingSelfie(false);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (form.certification_files.length === 0) {
|
||||
setUploadError('Please upload at least one certification document to continue.');
|
||||
return;
|
||||
}
|
||||
if (!form.selfie_url) {
|
||||
setUploadError("Please take a selfie photo before submitting — it is required for your interview.");
|
||||
return;
|
||||
}
|
||||
setUploadError('');
|
||||
onSubmit(form);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* The Resume Dies — identity-first. Create yours at /identity */}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">Full Name *</Label>
|
||||
<Input value={form.applicant_name} onChange={(e) => update('applicant_name', e.target.value)} required className="mt-1.5" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">Email *</Label>
|
||||
<Input type="email" value={form.email} onChange={(e) => update('email', e.target.value)} required className="mt-1.5" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">Phone</Label>
|
||||
<Input value={form.phone} onChange={(e) => update('phone', e.target.value)} className="mt-1.5" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">Years of Experience</Label>
|
||||
<Input type="number" min="0" value={form.years_experience} onChange={(e) => update('years_experience', parseInt(e.target.value) || 0)} className="mt-1.5" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">English Level</Label>
|
||||
<select value={form.english_level} onChange={(e) => update('english_level', e.target.value)} className="w-full mt-1.5 h-9 rounded-md border border-[#E5E7EB] bg-white px-3 text-[13px] focus:border-[#0A39DF] focus:outline-none">
|
||||
{ENGLISH_LEVELS.map(l => <option key={l} value={l}>{l.charAt(0).toUpperCase() + l.slice(1)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">Skills</Label>
|
||||
<div className="flex gap-2 mt-1.5">
|
||||
<Input value={customSkill} onChange={(e) => setCustomSkill(e.target.value)} placeholder="Add a skill..." className="flex-1" />
|
||||
<Button type="button" variant="outline" onClick={() => { if (customSkill.trim()) { update('skills', [...form.skills, customSkill.trim()]); setCustomSkill(''); } }}>Add</Button>
|
||||
</div>
|
||||
{form.skills.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{form.skills.map((s, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-[#F3F4F6] text-[12px] text-[#374151]">
|
||||
{s}
|
||||
<button type="button" onClick={() => update('skills', form.skills.filter((_, idx) => idx !== i))} className="text-[#9CA3AF] hover:text-[#EF4444]">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">Companies You've Worked At</Label>
|
||||
<div className="flex gap-2 mt-1.5">
|
||||
<Input value={customCompany} onChange={(e) => setCustomCompany(e.target.value)} placeholder="e.g. Google, Meta, Marriott..." className="flex-1" />
|
||||
<Button type="button" variant="outline" onClick={() => { if (customCompany.trim()) { update('companies_worked', [...form.companies_worked, customCompany.trim()]); setCustomCompany(''); } }}>Add</Button>
|
||||
</div>
|
||||
{form.companies_worked.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{form.companies_worked.map((c, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-[#EFF6FF] text-[12px] text-[#1E40AF] font-medium border border-[#DBEAFE]">
|
||||
{c}
|
||||
<button type="button" onClick={() => update('companies_worked', form.companies_worked.filter((_, idx) => idx !== i))} className="text-[#9CA3AF] hover:text-[#EF4444]">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">Certifications</Label>
|
||||
<div className="flex flex-wrap gap-2 mt-1.5">
|
||||
{certOptions.map(cert => (
|
||||
<button
|
||||
key={cert}
|
||||
type="button"
|
||||
onClick={() => toggleCert(cert)}
|
||||
className={`px-3 py-1.5 rounded-full text-[12px] font-medium border transition-all ${
|
||||
form.certifications.includes(cert) ? 'bg-[#0A39DF] border-[#0A39DF] text-white' : 'border-[#E5E7EB] text-[#6B7280] hover:border-[#D1D5DB]'
|
||||
}`}
|
||||
>
|
||||
{cert}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Certification file upload */}
|
||||
<div className="mt-3">
|
||||
<Label className="text-[12px] text-[#6B7280]">Upload certification documents</Label>
|
||||
<label className="mt-1.5 flex flex-col items-center justify-center gap-1.5 border-2 border-dashed border-[#E5E7EB] rounded-lg py-4 cursor-pointer hover:border-[#0A39DF] hover:bg-[#EFF6FF]/40 transition-colors">
|
||||
{uploadingCert ? (
|
||||
<Loader2 className="w-5 h-5 text-[#0A39DF] animate-spin" />
|
||||
) : (
|
||||
<UploadCloud className="w-5 h-5 text-[#9CA3AF]" />
|
||||
)}
|
||||
<span className="text-[12px] text-[#6B7280]">
|
||||
{uploadingCert ? 'Uploading...' : 'Click to upload (PDF, JPG, PNG)'}
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
multiple
|
||||
onChange={handleCertUpload}
|
||||
disabled={uploadingCert}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
{uploadError && (
|
||||
<p className="text-[12px] text-[#EF4444] mt-1">{uploadError}</p>
|
||||
)}
|
||||
{form.certification_files.length > 0 && (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{form.certification_files.map((url, i) => (
|
||||
<div key={i} className="flex items-center justify-between gap-2 px-3 py-1.5 rounded-lg bg-[#F0FDF4] border border-[#BBF7D0]">
|
||||
<span className="flex items-center gap-1.5 text-[12px] text-[#166534] truncate">
|
||||
<FileCheck2 className="w-3.5 h-3.5 shrink-0" />
|
||||
{decodeURIComponent(url.split('/').pop()?.split('?')[0] || `Certification ${i + 1}`)}
|
||||
</span>
|
||||
<button type="button" onClick={() => removeCertFile(i)} className="text-[#9CA3AF] hover:text-[#EF4444] shrink-0">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selfie Photo */}
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">Selfie Photo *</Label>
|
||||
<p className="text-[11px] text-[#6B7280] mt-0.5 mb-2">Take a quick selfie so your interviewer can see you before the interview.</p>
|
||||
{form.selfie_url ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<img src={form.selfie_url} alt="Selfie" className="w-20 h-20 rounded-xl object-cover border border-[#E5E7EB]" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => update('selfie_url', '')}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-[#F3F4F6] hover:bg-[#E5E7EB] text-[#374151] font-medium text-[13px] transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" /> Retake
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<label className="flex items-center gap-3 border-2 border-dashed border-[#E5E7EB] rounded-xl p-4 cursor-pointer hover:border-[#0A39DF] hover:bg-[#EFF6FF]/40 transition-colors">
|
||||
{uploadingSelfie ? (
|
||||
<Loader2 className="w-8 h-8 text-[#0A39DF] animate-spin shrink-0" />
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-[#EFF6FF] flex items-center justify-center shrink-0">
|
||||
<Camera className="w-6 h-6 text-[#0A39DF]" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<p className="text-[13px] font-medium text-[#374151]">
|
||||
{uploadingSelfie ? 'Uploading...' : 'Take or upload a selfie'}
|
||||
</p>
|
||||
<p className="text-[11px] text-[#6B7280]">Click to use your camera or select a photo</p>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="user"
|
||||
onChange={handleSelfieUpload}
|
||||
disabled={uploadingSelfie}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">Availability</Label>
|
||||
<div className="flex flex-wrap gap-2 mt-1.5">
|
||||
{AVAIL_OPTIONS.map(slot => (
|
||||
<button
|
||||
key={slot}
|
||||
type="button"
|
||||
onClick={() => toggleAvail(slot)}
|
||||
className={`px-3 py-1.5 rounded-full text-[12px] font-medium border transition-all ${
|
||||
form.availability.includes(slot) ? 'bg-[#059669] border-[#059669] text-white' : 'border-[#E5E7EB] text-[#6B7280] hover:border-[#D1D5DB]'
|
||||
}`}
|
||||
>
|
||||
{slot}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">Professional Summary</Label>
|
||||
<textarea value={form.professional_summary} onChange={(e) => update('professional_summary', e.target.value)} className="w-full mt-1.5 min-h-[70px] p-3 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0A39DF] focus:outline-none resize-none" />
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={submitting} className="w-full bg-[#0A39DF] hover:bg-[#082EB4] h-11 text-[14px]">
|
||||
{submitting ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
|
||||
Submit Application
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
151
src/components/krow/ApplyIdentityCard.jsx
Normal file
151
src/components/krow/ApplyIdentityCard.jsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import React from 'react';
|
||||
import { Mic, Clock, Gauge, Sparkles, Fingerprint, BadgeCheck, Briefcase, ChevronRight, AlertCircle } from 'lucide-react';
|
||||
import { Image } from '@/components/ui/image';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function getInitials(name) {
|
||||
return (name || '').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase() || '??';
|
||||
}
|
||||
|
||||
function krowCode(profile) {
|
||||
if (profile?.id) {
|
||||
const hex = profile.id.replace(/[^a-f0-9]/gi, '');
|
||||
if (hex.length >= 6) return hex.slice(0, 6).toUpperCase();
|
||||
}
|
||||
return 'KROWID';
|
||||
}
|
||||
|
||||
export default function ApplyIdentityCard({ profile, job, onStart, submitting, onBuildIdentity }) {
|
||||
const ready = profile && profile.full_name;
|
||||
const completeness = Math.min(100, Math.round(profile?.profile_completion || 0));
|
||||
const skills = profile?.skills || [];
|
||||
const certs = profile?.certifications || [];
|
||||
const experienceYears = profile?.experience_years || 0;
|
||||
const krowScore = Math.round(profile?.krow_score || profile?.ai_interview_score || 0);
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-[#E5E7EB] rounded-2xl overflow-hidden">
|
||||
{/* Header band */}
|
||||
<div className="px-6 py-5 bg-gradient-to-b from-[#F5F7FB] to-white border-b border-[#E5E7EB]">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-[18px] font-semibold text-[#111827]">Apply: {job.title}</h2>
|
||||
<p className="text-[12px] text-[#6B7280] mt-0.5">{job.role_category} • {job.location}</p>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold tracking-wide text-[#2563EB] uppercase">
|
||||
<Fingerprint className="w-3.5 h-3.5" /> KROW ID · {krowCode(profile)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Identity preview */}
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-16 h-16 rounded-full overflow-hidden ring-2 ring-[#E5E7EB] shrink-0 bg-[#E5E7EB] flex items-center justify-center">
|
||||
{profile?.selfie_url ? (
|
||||
<Image src={profile.selfie_url} alt={profile.full_name} className="w-full h-full object-cover" fittingType="fill" />
|
||||
) : (
|
||||
<span className="text-[20px] font-bold text-[#1D4ED8]">{getInitials(profile?.full_name)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="text-[17px] font-bold text-[#111827] truncate">{profile?.full_name || 'Your KROW Identity'}</h3>
|
||||
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-[#EFF6FF] text-[#1E40AF]">Verified</span>
|
||||
</div>
|
||||
<p className="text-[12px] text-[#6B7280] mt-1">
|
||||
Your KROW Identity is loaded — no form needed. Owliver will run a short AI interview to confirm fit for this role.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick stats */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Stat label="Experience" value={experienceYears > 0 ? `${experienceYears} yr` : '—'} icon={<Briefcase className="w-3.5 h-3.5" />} />
|
||||
<Stat label="KROW Score" value={krowScore > 0 ? krowScore : '—'} icon={<Gauge className="w-3.5 h-3.5" />} />
|
||||
<Stat label="Profile" value={`${completeness}%`} icon={<Sparkles className="w-3.5 h-3.5" />} />
|
||||
</div>
|
||||
|
||||
{/* Skills + certs */}
|
||||
<div className="space-y-3">
|
||||
{skills.length > 0 && (
|
||||
<Row label="Skills">
|
||||
{skills.slice(0, 8).map((s, i) => (
|
||||
<span key={i} className="px-2.5 py-1 rounded-full bg-[#F0F4F8] text-[#1E56C1] text-[11px] font-medium">{s}</span>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
{certs.length > 0 && (
|
||||
<Row label="Certifications">
|
||||
{certs.slice(0, 6).map((c, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-[#F3F4F6] text-[#374151] text-[11px] font-medium">
|
||||
<BadgeCheck className="w-3 h-3" /> {c}
|
||||
</span>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Incomplete identity notice */}
|
||||
{completeness < 40 && (
|
||||
<div className="flex items-start gap-2 p-3 bg-[#FFF7ED] rounded-lg border border-[#FED7AA]">
|
||||
<AlertCircle className="w-4 h-4 text-[#B45309] mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-[12px] font-medium text-[#92400E]">Your KROW Identity is light.</p>
|
||||
<p className="text-[11px] text-[#B45309] mt-0.5">You can still interview now — building your identity improves matches.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Interview meta */}
|
||||
<div className="flex items-center justify-center gap-3 text-[12px] text-[#6B7280]">
|
||||
<span className="inline-flex items-center gap-1.5"><Clock className="w-3.5 h-3.5" /> 3 minutes</span>
|
||||
<span className="inline-flex items-center gap-1.5"><Mic className="w-3.5 h-3.5" /> Voice or text</span>
|
||||
<span className="inline-flex items-center gap-1.5"><Gauge className="w-3.5 h-3.5" /> Instant score</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col sm:flex-row gap-2 pt-1">
|
||||
<button
|
||||
onClick={onStart}
|
||||
disabled={!ready || submitting}
|
||||
className={cn(
|
||||
'flex-1 inline-flex items-center justify-center gap-2 bg-[#0A39DF] hover:bg-[#082EB4] text-white text-[14px] font-semibold px-5 py-3 rounded-xl transition-colors disabled:opacity-60 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<Mic className="w-4 h-4" />
|
||||
{submitting ? 'Starting interview…' : 'Start AI Interview with Owliver'}
|
||||
{!submitting && <ChevronRight className="w-4 h-4" />}
|
||||
</button>
|
||||
{onBuildIdentity && (
|
||||
<button
|
||||
onClick={onBuildIdentity}
|
||||
className="inline-flex items-center justify-center gap-1.5 border border-[#E5E7EB] hover:bg-[#F9FAFB] text-[#374151] text-[13px] font-medium px-4 py-3 rounded-xl transition-colors"
|
||||
>
|
||||
<Fingerprint className="w-4 h-4" /> Build Identity
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value, icon }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] px-3 py-2.5 text-center">
|
||||
<div className="inline-flex items-center justify-center text-[#1E56C1] mb-1">{icon}</div>
|
||||
<div className="text-[15px] font-bold text-[#111827] leading-tight">{value}</div>
|
||||
<div className="text-[10px] text-[#9CA3AF] font-medium mt-0.5">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, children }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-1.5">{label.toUpperCase()}</p>
|
||||
<div className="flex flex-wrap gap-1.5">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
src/components/krow/BadgeChip.jsx
Normal file
20
src/components/krow/BadgeChip.jsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import { Award } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const LEVEL_STYLES = {
|
||||
bronze: 'bg-[#FEF3C7] text-[#92400E] border-[#FCD34D]',
|
||||
silver: 'bg-[#F3F4F6] text-[#4B5563] border-[#D1D5DB]',
|
||||
gold: 'bg-[#FEF9E7] text-[#92400E] border-[#F9E547]',
|
||||
platinum: 'bg-[#EFF6FF] text-[#1E40AF] border-[#BFDBFE]',
|
||||
};
|
||||
|
||||
export default function BadgeChip({ name, level = 'bronze', earned_date, size = 'md' }) {
|
||||
return (
|
||||
<div className={cn('inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5', LEVEL_STYLES[level] || LEVEL_STYLES.bronze)}>
|
||||
<Award className={size === 'sm' ? 'w-3 h-3' : 'w-3.5 h-3.5'} />
|
||||
<span className={cn('font-medium', size === 'sm' ? 'text-[11px]' : 'text-[12px]')}>{name}</span>
|
||||
{earned_date && <span className="text-[10px] opacity-70">· {new Date(earned_date).toLocaleDateString()}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
201
src/components/krow/CandidateCard.jsx
Normal file
201
src/components/krow/CandidateCard.jsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import React, { useState } from 'react';
|
||||
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 { Image } from '@/components/ui/image';
|
||||
import { getScoreBand } from '@/lib/talentHome';
|
||||
import CandidateExpandedDetails from '@/components/krow/CandidateExpandedDetails';
|
||||
|
||||
const STATUS_STYLES = {
|
||||
hired: { badge: 'bg-[#E8F0FE] text-[#1A56DB]', label: 'Hired', card: 'bg-white border-[#E5E7EB]' },
|
||||
ai_screened: { badge: 'bg-[#E8F0FE] text-[#1A56DB]', label: 'AI Screened', card: 'bg-white border-[#E5E7EB]' },
|
||||
shortlisted: { badge: 'bg-[#E8F0FE] text-[#1A56DB]', label: 'Shortlisted', card: 'bg-white border-[#E5E7EB]' },
|
||||
interview: { badge: 'bg-[#E8F0FE] text-[#1A56DB]', label: 'Interviewing', card: 'bg-white border-[#E5E7EB]' },
|
||||
applied: { badge: 'bg-[#F3F4F6] text-[#6B7280]', label: 'Applied', card: 'bg-white border-[#E5E7EB]' },
|
||||
rejected: { badge: 'bg-[#FEE2E2] text-[#991B1B]', label: 'Rejected', card: 'bg-white border-[#E5E7EB]' },
|
||||
};
|
||||
|
||||
function getInitials(name) {
|
||||
return name?.split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase() || '??';
|
||||
}
|
||||
|
||||
const PORTRAIT_PHOTOS = [
|
||||
'photo-1500648767731-7d3514ad8b31',
|
||||
'photo-1531123897727-8f129e1688ce',
|
||||
'photo-1573496359142-b8d87734a5a2',
|
||||
'photo-1487412720507-e7ab37603c6f',
|
||||
'photo-1502685104226-ee3a97a2f2c8',
|
||||
'photo-1544005313-94ddf0286df2',
|
||||
'photo-1494790108377-be9c29b29330',
|
||||
'photo-1438761681033-6461ffad8d80',
|
||||
'photo-1517841905240-472988babdf9',
|
||||
'photo-1463453091185-61582044d556',
|
||||
];
|
||||
|
||||
function getPortraitSeed(name) {
|
||||
let h = 0;
|
||||
for (let i = 0; i < (name || '').length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
|
||||
return PORTRAIT_PHOTOS[h % PORTRAIT_PHOTOS.length];
|
||||
}
|
||||
|
||||
function getScoreColor(score) {
|
||||
if (score > 0) return '#0A39DF';
|
||||
return '#EF4444';
|
||||
}
|
||||
|
||||
function ScoreRing({ score }) {
|
||||
const color = getScoreColor(score);
|
||||
const radius = 24;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const offset = circumference - (score / 100) * circumference;
|
||||
return (
|
||||
<div className="relative w-14 h-14 flex items-center justify-center shrink-0">
|
||||
<svg className="w-14 h-14 -rotate-90" viewBox="0 0 56 56">
|
||||
<circle cx="28" cy="28" r={radius} fill="none" stroke="#F3F4F6" strokeWidth="4" />
|
||||
<circle
|
||||
cx="28" cy="28" r={radius} fill="none" stroke={color} strokeWidth="4"
|
||||
strokeDasharray={circumference} strokeDashoffset={offset} strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<span className="absolute text-[13px] font-bold text-[#111827]">{score}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CandidateCard({ application, jobTitle, rank, onAction, onMessage, onCall, onSchedule, onDecline, onDelete, onHire }) {
|
||||
const position = jobTitle || application.job_title || '';
|
||||
const status = application.status || 'applied';
|
||||
const style = STATUS_STYLES[status] || STATUS_STYLES.applied;
|
||||
const score = application.ai_score || 0;
|
||||
const canTakeAction = status !== 'hired' && status !== 'rejected';
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={cn('border rounded-xl p-4 transition-all hover:shadow-sm', style.card)}>
|
||||
<div
|
||||
className="flex items-center gap-4 cursor-pointer"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{/* Rank */}
|
||||
<div className="flex flex-col items-center justify-center gap-0.5 shrink-0 w-8">
|
||||
<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">
|
||||
<Image
|
||||
src={application.selfie_url || `https://images.unsplash.com/${getPortraitSeed(application.applicant_name)}?w=120&h=120&fit=crop&crop=faces`}
|
||||
alt={application.applicant_name}
|
||||
className="w-full h-full object-cover"
|
||||
fittingType="fill"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="font-semibold text-[#111827] text-[14px] truncate">{application.applicant_name}</h3>
|
||||
<span className={cn('px-2 py-0.5 rounded-full text-[10px] font-medium', style.badge)}>{style.label}</span>
|
||||
{score > 0 && (() => {
|
||||
const band = getScoreBand(score);
|
||||
return (
|
||||
<span
|
||||
className="px-2 py-0.5 rounded-full text-[10px] font-bold text-white"
|
||||
style={{ backgroundColor: band.color }}
|
||||
>
|
||||
{band.label}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1 text-[11px] text-[#6B7280] flex-wrap">
|
||||
{position && (
|
||||
<span className="inline-flex items-center gap-1 font-medium text-[#0A39DF]">
|
||||
<Briefcase className="w-3 h-3" />
|
||||
{position}
|
||||
</span>
|
||||
)}
|
||||
{application.email && (
|
||||
<span>{application.email}</span>
|
||||
)}
|
||||
{application.phone && (
|
||||
<span>{application.phone}</span>
|
||||
)}
|
||||
{application.years_experience != null && (
|
||||
<span>{application.years_experience}yr exp</span>
|
||||
)}
|
||||
</div>
|
||||
{application.ai_match_label && (
|
||||
<div className="mt-1.5">
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-[#0A39DF] text-white text-[10px] font-bold">
|
||||
+ {application.ai_match_label}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Score */}
|
||||
{status !== 'applied' && <ScoreRing score={score} />}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
{onAction && canTakeAction && (
|
||||
<button
|
||||
onClick={() => onAction(application)}
|
||||
className="px-3 py-1.5 rounded-lg bg-[#0A39DF] hover:bg-[#082EB4] text-white text-[12px] font-medium transition-colors whitespace-nowrap"
|
||||
>
|
||||
{status === 'interview' ? 'AI Interview' : 'Interview'}
|
||||
</button>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="inline-flex items-center gap-1 px-3 py-1.5 rounded-lg border border-[#E5E7EB] bg-white hover:bg-[#F9FAFB] text-[#374151] text-[12px] font-medium transition-colors">
|
||||
Actions
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{onMessage && (
|
||||
<DropdownMenuItem onClick={() => onMessage(application)} className="cursor-pointer py-2">
|
||||
<MessageSquare className="w-4 h-4 mr-2.5 text-[#0A39DF]" />
|
||||
<span className="text-[#374151]">Message</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onCall && (
|
||||
<DropdownMenuItem onClick={() => onCall(application)} className="cursor-pointer py-2">
|
||||
<Phone className="w-4 h-4 mr-2.5 text-[#059669]" />
|
||||
<span className="text-[#059669]">Call</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onSchedule && (
|
||||
<DropdownMenuItem onClick={() => onSchedule(application)} className="cursor-pointer py-2">
|
||||
<Calendar className="w-4 h-4 mr-2.5 text-[#0A39DF]" />
|
||||
<span className="text-[#0A39DF]">Set up interview</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onHire && (
|
||||
<DropdownMenuItem onClick={() => onHire(application)} className="cursor-pointer py-2">
|
||||
<UserCheck className="w-4 h-4 mr-2.5 text-[#059669]" />
|
||||
<span className="text-[#059669]">Hire candidate</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onDecline && (
|
||||
<DropdownMenuItem onClick={() => onDecline(application)} className="cursor-pointer py-2">
|
||||
<X className="w-4 h-4 mr-2.5 text-[#DC2626]" />
|
||||
<span className="text-[#DC2626]">Decline candidate</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onDelete && (
|
||||
<DropdownMenuItem onClick={() => onDelete(application)} className="cursor-pointer py-2">
|
||||
<Trash2 className="w-4 h-4 mr-2.5 text-[#DC2626]" />
|
||||
<span className="text-[#DC2626]">Delete candidate</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
{expanded && <CandidateExpandedDetails application={application} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
136
src/components/krow/CandidateExpandedDetails.jsx
Normal file
136
src/components/krow/CandidateExpandedDetails.jsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import React from 'react';
|
||||
import { Award, BarChart3, Clock, ScrollText, Calendar, User, Users, MessageSquare, CalendarCheck, Dumbbell, Crown, Briefcase, Target, Shield, Check, AlertTriangle } from 'lucide-react';
|
||||
import InterviewResults from '@/components/krow/InterviewResults';
|
||||
import WorkforceReputation from '@/components/krow/WorkforceReputation';
|
||||
|
||||
const METRIC_CONFIG = [
|
||||
{ key: 'experience', label: 'Experience', Icon: Award },
|
||||
{ key: 'english', label: 'English', Icon: BarChart3 },
|
||||
{ key: 'reliability', label: 'Reliability', Icon: Clock },
|
||||
{ key: 'certifications', label: 'Certs', Icon: ScrollText },
|
||||
{ key: 'availability', label: 'Availability', Icon: Calendar },
|
||||
{ key: 'personality', label: 'Personality', Icon: User },
|
||||
{ key: 'culture_fit', label: 'Culture Fit', Icon: Users },
|
||||
{ key: 'communication_style', label: 'Comm Style', Icon: MessageSquare },
|
||||
{ key: 'attendance_expectations', label: 'Attendance', Icon: CalendarCheck },
|
||||
{ key: 'physical_requirements', label: 'Physical', Icon: Dumbbell },
|
||||
{ key: 'leadership_expectations', label: 'Leadership', Icon: Crown },
|
||||
{ key: 'job_related_answers', label: 'Job Knowledge', Icon: Briefcase },
|
||||
{ key: 'verified_skills', label: 'Skills', Icon: Target },
|
||||
{ key: 'scenario_judgment', label: 'Judgment', Icon: Shield },
|
||||
{ key: 'employer_requirements', label: 'Req Match', Icon: Check },
|
||||
];
|
||||
|
||||
function MetricPill({ label, value, Icon }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-white border border-[#E5E7EB]">
|
||||
<Icon className="w-3 h-3 text-[#9CA3AF]" strokeWidth={2} />
|
||||
<span className="text-[11px] text-[#6B7280] font-medium">{label}</span>
|
||||
<span className="text-[12px] font-bold text-[#0A39DF] tabular-nums">{value || 0}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CandidateExpandedDetails({ application }) {
|
||||
const breakdown = application.score_breakdown || {};
|
||||
const strengths = application.ai_strengths || [];
|
||||
const gaps = application.ai_gaps || [];
|
||||
const certs = application.certifications || [];
|
||||
const hasScoreData = application.ai_score > 0;
|
||||
const hasInterview = application.interview_id || application.status === 'interview';
|
||||
|
||||
if (!hasScoreData && strengths.length === 0 && gaps.length === 0 && !hasInterview) {
|
||||
return (
|
||||
<div className="mt-3 pt-4 border-t border-[#F3F4F6]">
|
||||
<p className="text-[13px] text-[#9CA3AF] text-center py-4">No AI screening results yet. Run AI Screen to see detailed metrics.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 pt-4 border-t border-[#F3F4F6] space-y-4">
|
||||
{/* Workforce Reputation */}
|
||||
<WorkforceReputation application={application} />
|
||||
|
||||
{/* Metrics — inline pills, only scored metrics shown */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* AI Summary */}
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Strengths & Gaps */}
|
||||
{(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="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>
|
||||
</div>
|
||||
<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>{s}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{gaps.length > 0 && (
|
||||
<div className="bg-[#FFF7ED] rounded-xl p-4">
|
||||
<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>
|
||||
</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>
|
||||
<span>{g}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Certification Tags */}
|
||||
{certs.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{certs.map((cert, i) => (
|
||||
<span key={i} className="px-2.5 py-1 rounded-full bg-[#EFF6FF] text-[#0A39DF] text-[11px] font-medium border border-[#DBEAFE]">
|
||||
{cert}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Interview Results & Transcript */}
|
||||
{(application.interview_id || application.status === 'interview') && (
|
||||
<InterviewResults application={application} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
src/components/krow/CareerJourney.jsx
Normal file
50
src/components/krow/CareerJourney.jsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
|
||||
// A learning path rendered as a quiet vertical journey to the target role.
|
||||
export default function CareerJourney({ path, completedIds = [] }) {
|
||||
const steps = path.steps || [];
|
||||
const target = path.target_role || path.name;
|
||||
const allDone = steps.length > 0 && steps.every((s) => completedIds.has(s.course_id));
|
||||
const rungs = [
|
||||
...steps.map((s, i) => ({
|
||||
label: s.course_title || `Step ${i + 1}`,
|
||||
done: completedIds.has(s.course_id),
|
||||
})),
|
||||
{ label: target, done: allDone, isTarget: true },
|
||||
];
|
||||
const doneCount = steps.filter((s) => completedIds.has(s.course_id)).length;
|
||||
const pct = steps.length ? Math.round((doneCount / steps.length) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="py-6">
|
||||
<div className="flex items-baseline justify-between mb-5">
|
||||
<h3 className="text-[17px] font-semibold text-[#0F172A]">{path.name}</h3>
|
||||
<span className="text-[13px] text-[#64748B]">{pct}%</span>
|
||||
</div>
|
||||
{path.description && <p className="text-[13px] text-[#64748B] mb-4">{path.description}</p>}
|
||||
<div className="relative pl-6">
|
||||
<div className="absolute left-[7px] top-2 bottom-2 w-px bg-[#E2E8F0]" />
|
||||
{rungs.map((r, i) => (
|
||||
<div key={i} className="relative flex items-center gap-3 py-2">
|
||||
<div
|
||||
className={`absolute -left-6 w-3.5 h-3.5 rounded-full ${
|
||||
r.done ? 'bg-[#0A39DF]' : 'bg-white border-2 border-[#E2E8F0]'
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`text-[15px] ${
|
||||
r.isTarget
|
||||
? 'font-semibold text-[#0A39DF]'
|
||||
: r.done
|
||||
? 'text-[#0F172A] font-medium'
|
||||
: 'text-[#64748B]'
|
||||
}`}
|
||||
>
|
||||
{r.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
src/components/krow/CareerTrajectory.jsx
Normal file
42
src/components/krow/CareerTrajectory.jsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
|
||||
function Stage({ label, value, sub, accent, to }) {
|
||||
const body = (
|
||||
<div className="text-center sm:text-left">
|
||||
<div className="text-[11px] font-medium uppercase tracking-wider text-[#94A3B8]">{label}</div>
|
||||
<div className={`text-[20px] sm:text-[26px] font-semibold mt-1 tracking-tight ${accent ? 'text-[#0A39DF]' : 'text-[#0F172A]'}`}>
|
||||
{value}
|
||||
</div>
|
||||
{sub && <div className="text-[13px] text-[#64748B] mt-0.5">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
return to ? (
|
||||
<Link to={to} className="hover:opacity-80 transition-opacity">{body}</Link>
|
||||
) : body;
|
||||
}
|
||||
|
||||
export default function CareerTrajectory({ profile, nextPath, nextReady, futureRole, futureIncome }) {
|
||||
const current = profile?.current_position || profile?.desired_position || 'Starting Out';
|
||||
const next = nextPath?.target_role || nextPath?.name || 'Next Role';
|
||||
return (
|
||||
<section className="text-center pt-4 pb-12 sm:pt-8 sm:pb-20">
|
||||
<span className="text-[12px] font-medium tracking-wide text-[#0A39DF] uppercase">Your Career</span>
|
||||
<h1 className="text-[32px] sm:text-[44px] font-bold tracking-tight text-[#0F172A] mt-4 leading-[1.05]">
|
||||
Everything revolves around your future.
|
||||
</h1>
|
||||
<p className="text-[15px] text-[#64748B] mt-4 max-w-sm mx-auto">
|
||||
Not a university. A Career Acceleration Engine.
|
||||
</p>
|
||||
|
||||
<div className="mt-12 sm:mt-16 flex flex-col sm:flex-row items-center justify-center gap-5 sm:gap-10">
|
||||
<Stage label="Current" value={current} />
|
||||
<ArrowRight className="w-4 h-4 text-[#CBD5E1] rotate-90 sm:rotate-0" />
|
||||
<Stage label="Next Role" value={next} sub={`${nextReady}% ready`} accent to={nextPath ? '/university' : null} />
|
||||
<ArrowRight className="w-4 h-4 text-[#CBD5E1] rotate-90 sm:rotate-0" />
|
||||
<Stage label="Future" value={futureRole} sub={futureIncome || ' '} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
99
src/components/krow/CertificationManager.jsx
Normal file
99
src/components/krow/CertificationManager.jsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Plus, Trash2, ShieldCheck, Loader2 } from 'lucide-react';
|
||||
import { useCertifications, useCreateCertification, useDeleteCertification } from '@/lib/krowHooks';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
export default function CertificationManager() {
|
||||
const { data: certifications = [], isLoading } = useCertifications();
|
||||
const createCert = useCreateCertification();
|
||||
const deleteCert = useDeleteCertification();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [newCert, setNewCert] = useState('');
|
||||
|
||||
const handleAdd = async () => {
|
||||
const name = newCert.trim();
|
||||
if (!name) return;
|
||||
try {
|
||||
await createCert.mutateAsync({ name });
|
||||
setNewCert('');
|
||||
toast.success(`"${name}" added to certifications`);
|
||||
} catch (e) {
|
||||
toast.error('Failed to add certification');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (cert) => {
|
||||
try {
|
||||
await deleteCert.mutateAsync(cert.id);
|
||||
toast.success(`"${cert.name}" removed`);
|
||||
} catch (e) {
|
||||
toast.error('Failed to remove certification');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<button className="inline-flex items-center gap-1.5 bg-white border border-[#E5E7EB] hover:border-[#D1D5DB] text-[#374151] text-[13px] font-medium px-4 h-10 rounded-lg transition-colors">
|
||||
<ShieldCheck className="w-4 h-4 text-[#0A39DF]" />
|
||||
<span className="hidden sm:inline">Manage Certifications</span>
|
||||
<span className="sm:hidden">Certs</span>
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Manage Certifications</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-[13px] text-[#6B7280] -mt-1">
|
||||
These are the certification options applicants can select and upload when applying.
|
||||
</p>
|
||||
|
||||
{/* Add new */}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newCert}
|
||||
onChange={(e) => setNewCert(e.target.value)}
|
||||
placeholder="e.g. OSHA 30"
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAdd(); } }}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleAdd}
|
||||
disabled={!newCert.trim() || createCert.isPending}
|
||||
className="bg-[#0A39DF] hover:bg-[#082EB4]"
|
||||
>
|
||||
{createCert.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="space-y-1.5 max-h-[300px] overflow-y-auto">
|
||||
{isLoading && [...Array(3)].map((_, i) => (
|
||||
<div key={i} className="h-10 bg-[#F3F4F6] rounded-lg animate-pulse" />
|
||||
))}
|
||||
{!isLoading && certifications.length === 0 && (
|
||||
<p className="text-[13px] text-[#9CA3AF] text-center py-6">No certifications yet. Add your first one above.</p>
|
||||
)}
|
||||
{certifications.map((cert) => (
|
||||
<div key={cert.id} className="flex items-center justify-between gap-2 px-3 py-2 rounded-lg bg-[#F9FAFB] border border-[#E5E7EB]">
|
||||
<span className="text-[13px] font-medium text-[#111827] truncate">{cert.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(cert)}
|
||||
disabled={deleteCert.isPending}
|
||||
className="text-[#9CA3AF] hover:text-[#EF4444] transition-colors shrink-0"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
93
src/components/krow/HiringAnalytics.jsx
Normal file
93
src/components/krow/HiringAnalytics.jsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Cell } from 'recharts';
|
||||
|
||||
const TIER_COLORS = { Skilled: '#0A39DF', 'Cross-Trained': '#333F48', Beginner: '#D1E0D7' };
|
||||
|
||||
export default function HiringAnalytics({ applications, staff }) {
|
||||
const byTier = useMemo(() => {
|
||||
const tiers = ['Skilled', 'Cross-Trained', 'Beginner'];
|
||||
return tiers.map(tier => ({
|
||||
label: tier,
|
||||
count: staff.filter(s => s.profile_tier === tier).length,
|
||||
color: TIER_COLORS[tier],
|
||||
}));
|
||||
}, [staff]);
|
||||
|
||||
const byRole = useMemo(() => {
|
||||
const counts = {};
|
||||
staff.forEach(s => {
|
||||
const role = s.role || 'Unassigned';
|
||||
counts[role] = (counts[role] || 0) + 1;
|
||||
});
|
||||
return Object.entries(counts)
|
||||
.map(([role, count]) => ({ role, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 6);
|
||||
}, [staff]);
|
||||
|
||||
const hiredApps = applications.filter(a => a.status === 'hired');
|
||||
const totalApps = applications.length;
|
||||
const hireRate = totalApps ? Math.round((hiredApps.length / totalApps) * 100) : 0;
|
||||
|
||||
const avgHireScore = hiredApps.length
|
||||
? Math.round(hiredApps.reduce((s, a) => s + (a.ai_score || 0), 0) / hiredApps.length)
|
||||
: 0;
|
||||
|
||||
const sevenDaysAgo = new Date();
|
||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
||||
const recentHires = staff.filter(s => new Date(s.hire_date) >= sevenDaysAgo).length;
|
||||
|
||||
return (
|
||||
<div className="glass-card border border-white/60 rounded-2xl p-6 shadow-sm">
|
||||
<h3 className="font-heading font-semibold text-[#111827] text-[16px] mb-1">Hiring Analytics</h3>
|
||||
<p className="text-[12px] text-[#6B7280] mb-5">{staff.length} total hires · {recentHires} in last 7 days</p>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 mb-6">
|
||||
<div className="bg-[#F9FAFB] rounded-xl p-3 text-center border border-[#F3F4F6]">
|
||||
<div className="text-2xl font-heading font-bold text-[#0A39DF]">{hireRate}%</div>
|
||||
<div className="text-[10px] text-[#9CA3AF] tracking-wide mt-0.5">HIRE RATE</div>
|
||||
</div>
|
||||
<div className="bg-[#F9FAFB] rounded-xl p-3 text-center border border-[#F3F4F6]">
|
||||
<div className="text-2xl font-heading font-bold text-[#333F48]">{avgHireScore}</div>
|
||||
<div className="text-[10px] text-[#9CA3AF] tracking-wide mt-0.5">AVG SCORE</div>
|
||||
</div>
|
||||
<div className="bg-[#F9FAFB] rounded-xl p-3 text-center border border-[#F3F4F6]">
|
||||
<div className="text-2xl font-heading font-bold text-[#0A39DF]">{recentHires}</div>
|
||||
<div className="text-[10px] text-[#9CA3AF] tracking-wide mt-0.5">LAST 7 DAYS</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
<div>
|
||||
<p className="text-[12px] font-medium text-[#374151] mb-3">Hires by Tier</p>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<BarChart data={byTier}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#F3F4F6" />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 10 }} stroke="#9CA3AF" />
|
||||
<YAxis tick={{ fontSize: 11 }} stroke="#9CA3AF" allowDecimals={false} />
|
||||
<Tooltip contentStyle={{ borderRadius: 8, border: '1px solid #E5E7EB', fontSize: 12 }} />
|
||||
<Bar dataKey="count" radius={[4, 4, 0, 0]}>
|
||||
{byTier.map((entry, i) => (
|
||||
<Cell key={i} fill={entry.color} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-[12px] font-medium text-[#374151] mb-3">Hires by Role</p>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<BarChart data={byRole} layout="vertical">
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#F3F4F6" />
|
||||
<XAxis type="number" tick={{ fontSize: 11 }} stroke="#9CA3AF" allowDecimals={false} />
|
||||
<YAxis type="category" dataKey="role" tick={{ fontSize: 9 }} stroke="#9CA3AF" width={100} />
|
||||
<Tooltip contentStyle={{ borderRadius: 8, border: '1px solid #E5E7EB', fontSize: 12 }} />
|
||||
<Bar dataKey="count" fill="#0A39DF" radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
131
src/components/krow/ImpactMetrics.jsx
Normal file
131
src/components/krow/ImpactMetrics.jsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Clock, DollarSign, Award, Zap, Scale, Heart } from 'lucide-react';
|
||||
|
||||
export default function ImpactMetrics({ applications, interviews, staff }) {
|
||||
const hired = applications.filter(a => a.status === 'hired');
|
||||
const screened = applications.filter(a => a.ai_score > 0);
|
||||
|
||||
// 1. Reduce time-to-hire: avg days from application created to hired
|
||||
const timeToHire = hired.length
|
||||
? Math.round(
|
||||
hired.reduce((sum, a) => {
|
||||
const created = new Date(a.created_date).getTime();
|
||||
const updated = new Date(a.updated_date).getTime();
|
||||
return sum + Math.max(1, (updated - created) / (1000 * 60 * 60 * 24));
|
||||
}, 0) / hired.length
|
||||
)
|
||||
: 0;
|
||||
// AI screens instantly vs ~5 day manual industry average
|
||||
const tthDisplay = timeToHire ? `${timeToHire}d avg` : 'Instant screen';
|
||||
|
||||
// 2. Lower recruiting costs: $45 per manual screen + $80 per manual interview saved
|
||||
const costSaved = screened.length * 45 + interviews.length * 80;
|
||||
|
||||
// 3. Improve quality of hire: avg AI score of hired candidates
|
||||
const qualityOfHire = hired.length
|
||||
? Math.round(hired.reduce((s, a) => s + (a.ai_score || 0), 0) / hired.length)
|
||||
: 0;
|
||||
|
||||
// 4. Increase recruiter productivity: hours saved (15 min per AI screen + 30 min per interview)
|
||||
const hoursSaved = Math.round((screened.length * 15 + interviews.length * 30) / 60);
|
||||
|
||||
// 5. Standardize hiring decisions: % of applicants with standardized AI scoring applied
|
||||
const standardizedPct = applications.length
|
||||
? Math.round((screened.length / applications.length) * 100)
|
||||
: 0;
|
||||
|
||||
// 6. Improve candidate experience: AI responds instantly (interview completion rate as proxy)
|
||||
const completionRate = interviews.length
|
||||
? Math.round((interviews.filter(i => i.overall_interview_score > 0).length / interviews.length) * 100)
|
||||
: 0;
|
||||
|
||||
const metrics = [
|
||||
{
|
||||
icon: Clock,
|
||||
label: 'Time-to-Hire',
|
||||
value: tthDisplay,
|
||||
sub: hired.length ? `${hired.length} hires tracked` : 'AI screens instantly',
|
||||
color: '#0A39DF',
|
||||
bg: '#EFF6FF',
|
||||
},
|
||||
{
|
||||
icon: DollarSign,
|
||||
label: 'Cost Saved',
|
||||
value: `$${costSaved.toLocaleString()}`,
|
||||
sub: `${screened.length} screens · ${interviews.length} interviews automated`,
|
||||
color: '#333F48',
|
||||
bg: '#F3F4F6',
|
||||
},
|
||||
{
|
||||
icon: Award,
|
||||
label: 'Quality of Hire',
|
||||
value: qualityOfHire ? `${qualityOfHire}/100` : '—',
|
||||
sub: hired.length ? `${hired.length} hired candidates` : 'Awaiting first hire',
|
||||
color: '#0A39DF',
|
||||
bg: '#FEFCE8',
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
label: 'Recruiter Hours Saved',
|
||||
value: `${hoursSaved}h`,
|
||||
sub: 'AI screening + interview automation',
|
||||
color: '#333F48',
|
||||
bg: '#F3F4F6',
|
||||
},
|
||||
{
|
||||
icon: Scale,
|
||||
label: 'Decisions Standardized',
|
||||
value: `${standardizedPct}%`,
|
||||
sub: `${screened.length} of ${applications.length} applicants scored`,
|
||||
color: '#0A39DF',
|
||||
bg: '#EFF6FF',
|
||||
},
|
||||
{
|
||||
icon: Heart,
|
||||
label: 'Candidate Experience',
|
||||
value: completionRate ? `${completionRate}% completion` : 'Instant response',
|
||||
sub: 'AI responds in seconds, not days',
|
||||
color: '#333F48',
|
||||
bg: '#D1E0D7',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h2 className="text-[11px] font-bold text-[#9CA3AF] tracking-wider font-heading">
|
||||
HIRING IMPACT · OUR MISSION
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{metrics.map((m, i) => {
|
||||
const Icon = m.icon;
|
||||
return (
|
||||
<motion.div
|
||||
key={m.label}
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.06, duration: 0.4, ease: 'easeOut' }}
|
||||
className="glass-card border border-white/60 rounded-2xl p-5 shadow-sm hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className="w-11 h-11 rounded-xl flex items-center justify-center shrink-0 shadow-lg"
|
||||
style={{ background: m.bg }}
|
||||
>
|
||||
<Icon className="w-5 h-5" style={{ color: m.color }} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] text-[#9CA3AF] font-medium tracking-wide">{m.label}</div>
|
||||
<div className="text-2xl font-heading font-bold text-[#111827] mt-0.5">{m.value}</div>
|
||||
<div className="text-[12px] text-[#6B7280] mt-1 leading-snug">{m.sub}</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
src/components/krow/InterviewAnalytics.jsx
Normal file
99
src/components/krow/InterviewAnalytics.jsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip, Legend, BarChart, Bar, XAxis, YAxis, CartesianGrid } from 'recharts';
|
||||
import { Mic, CheckCircle2, Clock, AlertTriangle } from 'lucide-react';
|
||||
|
||||
const VERDICT_COLORS = { hire: '#0A39DF', maybe: '#F9E547', no: '#333F48' };
|
||||
|
||||
export default function InterviewAnalytics({ applications, interviews }) {
|
||||
const stats = useMemo(() => {
|
||||
const started = applications.filter(a => a.status === 'interview' || a.interview_id).length;
|
||||
const completed = interviews.length;
|
||||
const completionRate = started ? Math.round((completed / started) * 100) : 0;
|
||||
const scored = interviews.filter(i => i.overall_interview_score > 0);
|
||||
const avgScore = scored.length ? Math.round(scored.reduce((s, i) => s + i.overall_interview_score, 0) / scored.length) : 0;
|
||||
|
||||
const verdictCounts = { hire: 0, maybe: 0, no: 0 };
|
||||
interviews.forEach(i => {
|
||||
if (i.verdict) verdictCounts[i.verdict] = (verdictCounts[i.verdict] || 0) + 1;
|
||||
});
|
||||
|
||||
const lowIntegrity = interviews.filter(i => i.integrity_score < 100).length;
|
||||
|
||||
return { started, completed, completionRate, avgScore, verdictCounts, lowIntegrity };
|
||||
}, [applications, interviews]);
|
||||
|
||||
const verdictData = [
|
||||
{ name: 'Hire', value: stats.verdictCounts.hire },
|
||||
{ name: 'Maybe', value: stats.verdictCounts.maybe },
|
||||
{ name: 'No', value: stats.verdictCounts.no },
|
||||
].filter(d => d.value > 0);
|
||||
|
||||
const categoryData = useMemo(() => {
|
||||
const cats = ['communication', 'confidence', 'experience_relevance', 'culture_fit', 'problem_solving'];
|
||||
return cats.map(cat => {
|
||||
const scored = interviews.filter(i => i.category_scores?.[cat] != null);
|
||||
const avg = scored.length ? Math.round(scored.reduce((s, i) => s + i.category_scores[cat], 0) / scored.length) : 0;
|
||||
return { category: cat.replace(/_/g, ' '), score: avg };
|
||||
}).filter(d => d.score > 0);
|
||||
}, [interviews]);
|
||||
|
||||
const cards = [
|
||||
{ label: 'Started', value: stats.started, icon: Mic, color: '#0A39DF', bg: '#EFF6FF' },
|
||||
{ label: 'Completed', value: stats.completed, icon: CheckCircle2, color: '#333F48', bg: '#F3F4F6' },
|
||||
{ label: 'Completion Rate', value: `${stats.completionRate}%`, icon: Clock, color: '#0A39DF', bg: '#FEFCE8' },
|
||||
{ label: 'Avg Interview Score', value: stats.avgScore, icon: AlertTriangle, color: '#333F48', bg: '#D1E0D7' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{cards.map((c) => {
|
||||
const Icon = c.icon;
|
||||
return (
|
||||
<div key={c.label} className="glass-card border border-white/60 rounded-2xl p-4 shadow-sm">
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center mb-2" style={{ background: c.bg }}>
|
||||
<Icon className="w-4 h-4" style={{ color: c.color }} />
|
||||
</div>
|
||||
<div className="text-xl font-heading font-bold text-[#111827]">{c.value}</div>
|
||||
<div className="text-[11px] text-[#9CA3AF] tracking-wide mt-0.5">{c.label}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{verdictData.length > 0 && (
|
||||
<div className="glass-card border border-white/60 rounded-2xl p-6 shadow-sm">
|
||||
<h3 className="font-heading font-semibold text-[#111827] text-[15px] mb-4">Verdict Breakdown</h3>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<PieChart>
|
||||
<Pie data={verdictData} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={75} label>
|
||||
{verdictData.map((entry, i) => (
|
||||
<Cell key={i} fill={VERDICT_COLORS[entry.name.toLowerCase()] || '#9CA3AF'} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip contentStyle={{ borderRadius: 8, border: '1px solid #E5E7EB', fontSize: 12 }} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{categoryData.length > 0 && (
|
||||
<div className="glass-card border border-white/60 rounded-2xl p-6 shadow-sm">
|
||||
<h3 className="font-heading font-semibold text-[#111827] text-[15px] mb-4">Avg Category Scores</h3>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={categoryData} layout="vertical">
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#F3F4F6" />
|
||||
<XAxis type="number" domain={[0, 100]} tick={{ fontSize: 11 }} stroke="#9CA3AF" />
|
||||
<YAxis type="category" dataKey="category" tick={{ fontSize: 10 }} stroke="#9CA3AF" width={110} />
|
||||
<Tooltip contentStyle={{ borderRadius: 8, border: '1px solid #E5E7EB', fontSize: 12 }} />
|
||||
<Bar dataKey="score" fill="#333F48" radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
190
src/components/krow/InterviewResults.jsx
Normal file
190
src/components/krow/InterviewResults.jsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import React from 'react';
|
||||
import { useInterviews } from '@/lib/krowHooks';
|
||||
import { MessageSquare, CheckCircle2, AlertTriangle, Star, FileText } from 'lucide-react';
|
||||
|
||||
const VERDICT_STYLES = {
|
||||
hire: { bg: 'bg-[#F0FDF4]', text: 'text-[#166534]', label: 'Hire' },
|
||||
maybe: { bg: 'bg-[#FFFBEB]', text: 'text-[#92400E]', label: 'Maybe' },
|
||||
no: { bg: 'bg-[#FEF2F2]', text: 'text-[#991B1B]', label: 'No Hire' },
|
||||
};
|
||||
|
||||
const CATEGORY_LABELS = {
|
||||
communication: 'Communication',
|
||||
confidence: 'Confidence',
|
||||
experience_relevance: 'Experience',
|
||||
culture_fit: 'Culture Fit',
|
||||
problem_solving: 'Problem Solving',
|
||||
personality: 'Personality',
|
||||
communication_style: 'Comm Style',
|
||||
attendance_expectations: 'Attendance',
|
||||
reliability: 'Reliability',
|
||||
physical_requirements: 'Physical',
|
||||
leadership_expectations: 'Leadership',
|
||||
scenario_judgment: 'Judgment',
|
||||
job_related_answers: 'Job Knowledge',
|
||||
verified_skills: 'Skills',
|
||||
};
|
||||
|
||||
function getCategoryColor(val) {
|
||||
if (val >= 80) return '#0A39DF';
|
||||
if (val >= 60) return '#333F48';
|
||||
if (val >= 40) return '#6B7280';
|
||||
return '#EF4444';
|
||||
}
|
||||
|
||||
export default function InterviewResults({ application }) {
|
||||
const { data: interviews = [], isLoading } = useInterviews();
|
||||
const interview = interviews
|
||||
.filter((i) => i.application_id === application.id)
|
||||
.sort((a, b) => new Date(b.created_date) - new Date(a.created_date))[0];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="mt-3 pt-4 border-t border-[#F3F4F6]">
|
||||
<p className="text-[12px] text-[#9CA3AF]">Loading interview results...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!interview) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const verdictStyle = VERDICT_STYLES[interview.verdict] || VERDICT_STYLES.maybe;
|
||||
const messages = interview.messages || [];
|
||||
const categoryScores = interview.category_scores || {};
|
||||
|
||||
return (
|
||||
<div className="mt-3 pt-4 border-t border-[#F3F4F6] space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-lg bg-[#0A39DF] flex items-center justify-center">
|
||||
<MessageSquare className="w-3.5 h-3.5 text-white" />
|
||||
</div>
|
||||
<h4 className="text-[12px] font-bold text-[#111827] tracking-wide">AI INTERVIEW RESULTS</h4>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-2.5 py-1 rounded-full text-[11px] font-semibold ${verdictStyle.bg} ${verdictStyle.text}`}>
|
||||
{verdictStyle.label}
|
||||
</span>
|
||||
<span className="text-[18px] font-bold text-[#0A39DF]">{interview.overall_interview_score || 0}<span className="text-[11px] text-[#9CA3AF]">/100</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Scores */}
|
||||
{Object.keys(categoryScores).length > 0 && (
|
||||
<div className="grid grid-cols-3 sm:grid-cols-5 gap-2">
|
||||
{Object.entries(categoryScores).map(([key, val]) => {
|
||||
const color = getCategoryColor(val);
|
||||
return (
|
||||
<div key={key} className="bg-[#F9FAFB] rounded-xl p-2.5 flex flex-col items-center gap-1 border border-[#F3F4F6]">
|
||||
<div className="text-[15px] font-bold text-[#111827]">{val}</div>
|
||||
<div className="w-full h-1.5 rounded-full bg-[#E5E7EB] overflow-hidden">
|
||||
<div className="h-full rounded-full" style={{ width: `${val}%`, background: color }} />
|
||||
</div>
|
||||
<div className="text-[9px] text-[#9CA3AF] tracking-wide font-medium text-center leading-tight">{CATEGORY_LABELS[key] || key}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
{interview.summary && (
|
||||
<div>
|
||||
<h4 className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-1.5 flex items-center gap-1"><FileText className="w-3 h-3" /> SUMMARY</h4>
|
||||
<p className="text-[13px] text-[#6B7280] leading-relaxed">{interview.summary}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Strengths & Concerns */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{interview.strengths?.length > 0 && (
|
||||
<div className="bg-[#EFF6FF] rounded-xl p-4">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-[#0A39DF]" strokeWidth={2.5} />
|
||||
<h4 className="text-[11px] font-bold text-[#0A39DF] tracking-wide">STRENGTHS</h4>
|
||||
</div>
|
||||
<ul className="space-y-1.5">
|
||||
{interview.strengths.map((s, i) => (
|
||||
<li key={i} className="text-[12px] text-[#1E40AF] flex gap-2">
|
||||
<span className="text-[#0A39DF] font-bold">+</span>
|
||||
<span>{s}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{interview.concerns?.length > 0 && (
|
||||
<div className="bg-[#FFF7ED] rounded-xl p-4">
|
||||
<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">CONCERNS</h4>
|
||||
</div>
|
||||
<ul className="space-y-1.5">
|
||||
{interview.concerns.map((c, i) => (
|
||||
<li key={i} className="text-[12px] text-[#9A3412] flex gap-2">
|
||||
<span className="text-[#EA580C] font-bold">−</span>
|
||||
<span>{c}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Integrity / Flags */}
|
||||
{interview.integrity_score != null && interview.integrity_score < 100 && (
|
||||
<div className="flex items-start gap-2 p-3 bg-[#FEF3C7] rounded-lg">
|
||||
<AlertTriangle className="w-4 h-4 text-[#92400E] mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-[12px] font-medium text-[#92400E]">Integrity Score: {interview.integrity_score}/100</p>
|
||||
{interview.ai_flags?.length > 0 && (
|
||||
<p className="text-[11px] text-[#92400E] mt-1">{interview.ai_flags.join(', ')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Best Fit Roles */}
|
||||
{interview.best_fit_roles?.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-1.5 flex items-center gap-1"><Star className="w-3 h-3" /> BEST FIT ROLES</h4>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{interview.best_fit_roles.map((role, i) => (
|
||||
<span key={i} className="px-2.5 py-1 rounded-full bg-[#EFF6FF] text-[#0A39DF] text-[11px] font-medium border border-[#DBEAFE]">
|
||||
{role}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Q&A Transcript */}
|
||||
{messages.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-2 flex items-center gap-1"><MessageSquare className="w-3 h-3" /> INTERVIEW TRANSCRIPT</h4>
|
||||
<div className="space-y-2 max-h-[400px] overflow-y-auto pr-1">
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`max-w-[85%] rounded-2xl px-3.5 py-2 text-[13px] ${
|
||||
msg.role === 'user'
|
||||
? 'bg-[#0A39DF] text-white rounded-br-sm'
|
||||
: 'bg-[#F3F4F6] text-[#111827] rounded-bl-sm'
|
||||
}`}>
|
||||
<p className="whitespace-pre-wrap">{msg.content}</p>
|
||||
{msg.response_time_seconds != null && msg.response_time_seconds < 8 && (
|
||||
<p className="text-[10px] mt-1 opacity-70 inline-flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3" /> Fast response ({msg.response_time_seconds}s)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
145
src/components/krow/InviteApplicantsModal.jsx
Normal file
145
src/components/krow/InviteApplicantsModal.jsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Copy, Check, Share2, Mail, Link2, Smartphone, Users } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
export default function InviteApplicantsModal({ open, onClose, job }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [emailRecipients, setEmailRecipients] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
const shareUrl = useMemo(() => {
|
||||
if (!job?.id) return '';
|
||||
const base = window.location.origin;
|
||||
return `${base}/apply?job=${job.id}`;
|
||||
}, [job?.id]);
|
||||
|
||||
const jobTitle = job?.title || 'this position';
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
setCopied(true);
|
||||
toast.success('Invitation link copied!');
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
toast.error('Could not copy link');
|
||||
}
|
||||
};
|
||||
|
||||
const handleNativeShare = async () => {
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: `Apply: ${jobTitle} at KROW`,
|
||||
text: `You've been invited to apply for ${jobTitle}. Complete a quick 5-minute AI interview — no forms, no quizzes.`,
|
||||
url: shareUrl,
|
||||
});
|
||||
} catch { /* user cancelled */ }
|
||||
} else {
|
||||
handleCopyLink();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmailInvite = () => {
|
||||
const recipients = emailRecipients.trim();
|
||||
if (!recipients) return;
|
||||
const subject = `You're invited to apply: ${jobTitle}`;
|
||||
const body = `Hi,\n\nYou've been invited to apply for ${jobTitle} at KROW.\n\nClick the link below to view the position and complete a quick 5-minute AI interview:\n\n${shareUrl}\n\nNo long forms, no quizzes — just speak naturally and get instant feedback.\n\nKROW Talent Engine`;
|
||||
const mailtoUrl = `mailto:${recipients}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
|
||||
window.location.href = mailtoUrl;
|
||||
setSending(true);
|
||||
setTimeout(() => {
|
||||
setSending(false);
|
||||
toast.success('Email invitation opened');
|
||||
}, 500);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-[#0A39DF]" />
|
||||
Invite Applicants
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5">
|
||||
{/* Job info */}
|
||||
<div className="bg-[#F8FAFC] rounded-xl p-3 border border-[#E5E7EB]">
|
||||
<p className="text-[13px] font-semibold text-[#111827]">{jobTitle}</p>
|
||||
{job?.location && <p className="text-[12px] text-[#6B7280]">{job.location}</p>}
|
||||
{job?.pay_range_min != null && (
|
||||
<p className="text-[12px] text-[#0A39DF] font-medium">${job.pay_range_min}–${job.pay_range_max}/hr</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Shareable Link */}
|
||||
<div>
|
||||
<label className="text-[12px] font-medium text-[#374151] flex items-center gap-1.5 mb-2">
|
||||
<Link2 className="w-3.5 h-3.5" /> Invitation Link
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
readOnly
|
||||
value={shareUrl}
|
||||
className="flex-1 min-w-0 px-3 py-2 text-[12px] rounded-lg border border-[#E5E7EB] bg-[#F9FAFB] text-[#6B7280] truncate"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCopyLink}
|
||||
className="shrink-0 inline-flex items-center gap-1.5 px-3 py-2 rounded-lg bg-[#0A39DF] text-white text-[12px] font-medium hover:bg-[#082EB4] transition-colors"
|
||||
>
|
||||
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Native Share (mobile) */}
|
||||
<button
|
||||
onClick={handleNativeShare}
|
||||
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-lg border border-[#E5E7EB] text-[13px] font-medium text-[#374151] hover:bg-[#F9FAFB] transition-colors"
|
||||
>
|
||||
<Share2 className="w-4 h-4" />
|
||||
Share via...
|
||||
</button>
|
||||
|
||||
{/* Email Invite */}
|
||||
<div>
|
||||
<label className="text-[12px] font-medium text-[#374151] flex items-center gap-1.5 mb-2">
|
||||
<Mail className="w-3.5 h-3.5" /> Email Invitation
|
||||
</label>
|
||||
<textarea
|
||||
value={emailRecipients}
|
||||
onChange={(e) => setEmailRecipients(e.target.value)}
|
||||
placeholder="applicant1@email.com, applicant2@email.com"
|
||||
className="w-full min-h-[48px] p-3 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0A39DF] focus:outline-none resize-none"
|
||||
/>
|
||||
<button
|
||||
onClick={handleEmailInvite}
|
||||
disabled={!emailRecipients.trim() || sending}
|
||||
className="w-full mt-2 inline-flex items-center justify-center gap-2 py-2.5 rounded-lg bg-[#0A39DF] text-white text-[13px] font-medium hover:bg-[#082EB4] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<Mail className="w-4 h-4" />
|
||||
Send Email Invitations
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile app note */}
|
||||
<div className="flex items-start gap-2 p-3 bg-[#EFF6FF] rounded-lg">
|
||||
<Smartphone className="w-4 h-4 text-[#0A39DF] mt-0.5 shrink-0" />
|
||||
<p className="text-[11px] text-[#374151] leading-relaxed">
|
||||
Applicants open the link on any phone — no app download needed. They see open positions and complete the AI interview right in their mobile browser.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} className="w-full">Done</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
73
src/components/krow/KROWCandidateLeaderboard.jsx
Normal file
73
src/components/krow/KROWCandidateLeaderboard.jsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const TIERS = [
|
||||
{ label: 'Top Talent', range: '80-100', color: 'bg-[#1E3A8A]', textColor: 'text-[#1E3A8A]' },
|
||||
{ label: 'Strong Fit', range: '60-79', color: 'bg-[#60A5FA]', textColor: 'text-[#2563EB]' },
|
||||
{ label: 'Possible Fit', range: '40-59', color: 'bg-[#93C5FD]', textColor: 'text-[#3B82F6]' },
|
||||
{ label: 'Not a Fit', range: '0-39', color: 'bg-[#E5E7EB]', textColor: 'text-[#9CA3AF]' },
|
||||
];
|
||||
|
||||
function getTier(score) {
|
||||
if (score >= 80) return 0;
|
||||
if (score >= 60) return 1;
|
||||
if (score >= 40) return 2;
|
||||
return 3;
|
||||
}
|
||||
|
||||
export default function KROWCandidateLeaderboard({ applications, maxBars = 4 }) {
|
||||
const screened = applications.filter(a => a.ai_score > 0 || a.status !== 'applied');
|
||||
const counts = TIERS.map((_, i) => screened.filter(a => getTier(a.ai_score || 0) === i).length);
|
||||
const total = counts.reduce((a, b) => a + b, 0) || 1;
|
||||
const sorted = [...screened].sort((a, b) => (b.ai_score || 0) - (a.ai_score || 0));
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-[#E5E7EB] rounded-xl p-6">
|
||||
<h3 className="font-semibold text-[#111827] text-[15px] mb-5">Candidate Pipeline</h3>
|
||||
|
||||
{/* Bar chart */}
|
||||
<div className="space-y-3 mb-6">
|
||||
{TIERS.map((tier, i) => {
|
||||
const count = counts[i];
|
||||
const pct = (count / total) * 100;
|
||||
return (
|
||||
<div key={tier.label}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[12px] font-medium text-[#374151]">{tier.label} <span className="text-[#9CA3AF]">({tier.range})</span></span>
|
||||
<span className="text-[13px] font-bold text-[#111827]">{count}</span>
|
||||
</div>
|
||||
<div className="h-2.5 bg-[#F3F4F6] rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn('h-full rounded-full transition-all duration-500', tier.color)}
|
||||
style={{ width: `${Math.max(pct, count > 0 ? 4 : 0)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Ranked list */}
|
||||
<div className="space-y-2">
|
||||
{sorted.slice(0, 5).map((app, idx) => (
|
||||
<div key={app.id} className="flex items-center gap-3 py-2">
|
||||
<span className="text-[12px] font-bold text-[#9CA3AF] w-6">#{idx + 1}</span>
|
||||
<div className="w-7 h-7 rounded-full bg-[#0A39DF] flex items-center justify-center text-white text-[10px] font-semibold shrink-0">
|
||||
{app.applicant_name?.split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[13px] font-medium text-[#111827] truncate">{app.applicant_name}</div>
|
||||
<div className="text-[11px] text-[#6B7280] truncate">{app.job_title || 'Applicant'}</div>
|
||||
</div>
|
||||
<span className={cn('text-[13px] font-bold', TIERS[getTier(app.ai_score || 0)].textColor)}>
|
||||
{app.ai_score || 0}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{sorted.length === 0 && (
|
||||
<div className="text-center py-8 text-[13px] text-[#9CA3AF]">No screened candidates yet</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
src/components/krow/KROWJobPostingCard.jsx
Normal file
84
src/components/krow/KROWJobPostingCard.jsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const STATUS_STYLES = {
|
||||
active: 'bg-[#0A39DF] text-white',
|
||||
draft: 'bg-[#F3F4F6] text-[#6B7280]',
|
||||
paused: 'bg-[#FEF3C7] text-[#92400E]',
|
||||
closed: 'bg-[#FEE2E2] text-[#991B1B]',
|
||||
};
|
||||
|
||||
function getCategoryAbbr(category) {
|
||||
if (!category) return 'JR';
|
||||
const words = category.split(' ');
|
||||
if (words.length === 1) return words[0].slice(0, 2).toUpperCase();
|
||||
return words.map(w => w[0]).join('').toUpperCase().slice(0, 2);
|
||||
}
|
||||
|
||||
export default function KROWJobPostingCard({ posting, stats }) {
|
||||
const navigate = useNavigate();
|
||||
const applied = stats?.applied ?? 0;
|
||||
const screened = stats?.screened ?? 0;
|
||||
const hired = stats?.hired ?? 0;
|
||||
const statusLabel = posting.status === 'active' ? 'Hiring' : posting.status.charAt(0).toUpperCase() + posting.status.slice(1);
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => navigate(`/positions/${posting.id}`)}
|
||||
className="group bg-white border border-[#E5E7EB] rounded-xl p-5 hover:shadow-md hover:border-[#D1D5DB] transition-all cursor-pointer"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-3 mb-4">
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<div className="w-10 h-10 rounded-lg bg-[#0A39DF] flex items-center justify-center text-white text-[13px] font-bold shrink-0">
|
||||
{getCategoryAbbr(posting.role_category)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-semibold text-[#111827] text-[15px] leading-tight truncate">{posting.title}</h3>
|
||||
<div className="text-[12px] text-[#6B7280] capitalize">{posting.role_category}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className={cn('shrink-0 px-2.5 py-1 rounded-full text-[11px] font-medium', STATUS_STYLES[posting.status] || STATUS_STYLES.draft)}>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Meta */}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-[12px] text-[#6B7280] mb-4">
|
||||
{posting.location && (
|
||||
<span>{posting.location}</span>
|
||||
)}
|
||||
{posting.pay_range_min != null && posting.pay_range_max != null && (
|
||||
<span className="font-medium text-[#111827]">${posting.pay_range_min}–${posting.pay_range_max}/hr</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-2 py-3 border-t border-[#F3F4F6]">
|
||||
{[
|
||||
{ label: 'APPLIED', value: applied },
|
||||
{ label: 'SCREENED', value: screened },
|
||||
{ label: 'HIRED', value: hired },
|
||||
].map((s) => (
|
||||
<div key={s.label}>
|
||||
<div className="text-lg font-bold text-[#111827]">{s.value}</div>
|
||||
<div className="text-[10px] text-[#9CA3AF] tracking-wide">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between pt-3 border-t border-[#F3F4F6]">
|
||||
{posting.ai_generated ? (
|
||||
<span className="px-2 py-0.5 rounded-full bg-[#EFF6FF] text-[#0A39DF] text-[11px] font-medium">
|
||||
AI Built
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[11px] text-[#9CA3AF]">Manual</span>
|
||||
)}
|
||||
<span className="text-[12px] text-[#9CA3AF] group-hover:text-[#111827] transition-all">View ›</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
136
src/components/krow/KrowIdPreview.jsx
Normal file
136
src/components/krow/KrowIdPreview.jsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import React from 'react';
|
||||
import { useWorkerProfile } from '@/lib/krowHooks';
|
||||
import { getTalentScore, getScoreBand, getGrowth } from '@/lib/talentHome';
|
||||
import { ShieldCheck, Smartphone, Image as ImageIcon, QrCode, Sparkles } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { Image } from '@/components/ui/image';
|
||||
|
||||
const clamp = (n, min = 0, max = 100) => Math.max(min, Math.min(max, Math.round(Number(n) || 0)));
|
||||
const toFICO = (s) => 300 + Math.round((clamp(s) / 100) * 550);
|
||||
|
||||
function buildVCard(profile) {
|
||||
const name = profile.full_name || 'KROW Talent';
|
||||
const [first, ...rest] = name.split(' ');
|
||||
const last = rest.join(' ');
|
||||
const lines = [
|
||||
'BEGIN:VCARD', 'VERSION:3.0',
|
||||
`N:${last};${first};;;`, `FN:${name}`,
|
||||
profile.current_position ? `TITLE:${profile.current_position}` : '',
|
||||
profile.phone ? `TEL;TYPE=CELL:${profile.phone}` : '',
|
||||
profile.email ? `EMAIL:${profile.email}` : '',
|
||||
`URL:https://krow.app/me`,
|
||||
`NOTE:KROW ID ${(profile.id || '').slice(-6).toUpperCase()}`,
|
||||
'END:VCARD',
|
||||
].filter(Boolean);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function download(filename, content, type) {
|
||||
const blob = new Blob([content], { type });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = filename; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export default function KrowIdPreview() {
|
||||
const { data: profile, isLoading } = useWorkerProfile();
|
||||
if (isLoading || !profile) {
|
||||
return <div className="flex justify-center py-10 text-xs text-[#6B7280]">Loading KROW ID…</div>;
|
||||
}
|
||||
|
||||
const score = getTalentScore(profile);
|
||||
const band = getScoreBand(score);
|
||||
const growth = getGrowth(profile);
|
||||
const fico = toFICO(score);
|
||||
const shortId = (profile.id || '').slice(-6).toUpperCase();
|
||||
const name = profile.full_name || 'KROW Talent';
|
||||
const title = profile.current_position || profile.desired_position || 'Open to work';
|
||||
const initials = name.split(' ').map(p => p[0]).slice(0, 2).join('').toUpperCase();
|
||||
|
||||
const vcard = buildVCard(profile);
|
||||
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?margin=0&size=120x120&data=${encodeURIComponent(vcard)}`;
|
||||
|
||||
const addToContacts = () => {
|
||||
download(`${name.replace(/\s+/g, '_')}.vcf`, vcard, 'text/vcard');
|
||||
toast.success('Contact file downloaded.');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<div className="relative w-full max-w-[360px]" style={{ perspective: '1200px' }}>
|
||||
{/* depth layers */}
|
||||
<div aria-hidden className="absolute inset-0 rounded-3xl bg-white/50 border border-white/60 backdrop-blur-xl shadow-[0_18px_50px_-20px_rgba(10,57,223,0.3)]"
|
||||
style={{ transform: 'translateY(12px) translateX(10px) rotateY(-8deg) rotateX(5deg)' }} />
|
||||
<div aria-hidden className="absolute inset-0 rounded-3xl bg-white/40 border border-white/70 backdrop-blur-xl shadow-[0_14px_40px_-16px_rgba(10,57,223,0.25)]"
|
||||
style={{ transform: 'translateY(6px) translateX(5px) rotateY(-4deg) rotateX(2deg)' }} />
|
||||
|
||||
{/* front pass */}
|
||||
<div className="relative rounded-3xl border border-white/80 backdrop-blur-2xl shadow-[0_20px_60px_-22px_rgba(10,57,223,0.4)] overflow-hidden"
|
||||
style={{ background: 'linear-gradient(160deg, rgba(255,255,255,0.94) 0%, rgba(244,247,255,0.9) 100%)' }}>
|
||||
<div aria-hidden className="pointer-events-none absolute inset-0" style={{ background: 'linear-gradient(115deg, transparent 30%, rgba(10,57,223,0.05) 45%, rgba(249,229,71,0.06) 55%, transparent 70%)' }} />
|
||||
|
||||
<div className="relative p-5">
|
||||
{/* header row */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-14 h-14 rounded-full overflow-hidden border-2 border-white shadow bg-[#EFF6FF] flex items-center justify-center shrink-0">
|
||||
{profile.selfie_url ? (
|
||||
<Image src={profile.selfie_url} alt={name} className="w-full h-full object-cover" fittingType="fill" />
|
||||
) : (
|
||||
<span className="text-[18px] font-bold text-[#0A39DF]">{initials}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 text-left">
|
||||
<p className="text-[9px] font-semibold uppercase tracking-[0.12em] text-[#0A39DF]">KROW ID · {shortId}</p>
|
||||
<h2 className="text-[16px] font-bold text-[#0F172A] tracking-tight leading-tight truncate">{name}</h2>
|
||||
<p className="text-[11px] text-[#6B7280] truncate">{title}</p>
|
||||
</div>
|
||||
<ShieldCheck className="w-4 h-4 text-[#10B981] shrink-0" />
|
||||
</div>
|
||||
|
||||
{/* score + factors row */}
|
||||
<div className="mt-4 flex items-center gap-4">
|
||||
<div className="text-center shrink-0">
|
||||
<p className="text-[30px] font-bold text-[#0F172A] leading-none tracking-tight">{fico}</p>
|
||||
<span className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-bold mt-1"
|
||||
style={{ color: band.color, background: `${band.color}14` }}>
|
||||
<Sparkles className="w-2.5 h-2.5" /> {band.label}
|
||||
</span>
|
||||
<p className="text-[10px] font-semibold text-[#16A34A] mt-1">↑ +{growth} mo</p>
|
||||
</div>
|
||||
<div className="flex-1 grid grid-cols-2 gap-1.5">
|
||||
{[
|
||||
['Reliability', clamp(profile.reliability_score)],
|
||||
['Attendance', clamp(profile.attendance_score ?? 100)],
|
||||
['Performance', clamp(profile.performance_score)],
|
||||
['XP', Number(profile.xp) || 0],
|
||||
].map(([label, value]) => (
|
||||
<span key={label} className="inline-flex items-center justify-between gap-1 rounded-lg bg-white/70 border border-[#E6ECF5] px-2 py-1 text-[10px]">
|
||||
<span className="text-[#9CA3AF]">{label}</span>
|
||||
<span className="font-semibold text-[#0A39DF]">{value}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* footer: QR + actions */}
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<div className="rounded-lg bg-white p-1 border border-[#E6ECF5] shadow-sm shrink-0">
|
||||
<img src={qrSrc} alt="KROW Identity QR" width={48} height={48} className="block" />
|
||||
</div>
|
||||
<div className="flex-1 grid grid-cols-2 gap-1.5">
|
||||
<button onClick={addToContacts} className="flex items-center justify-center gap-1 h-8 rounded-lg bg-[#0A39DF] text-white text-[10px] font-semibold hover:bg-[#082EB4] transition-colors">
|
||||
<Smartphone className="w-3 h-3" /> Contacts
|
||||
</button>
|
||||
<button onClick={() => toast('Open My KROW ID page to save the full pass.', { icon: 'ℹ️' })} className="flex items-center justify-center gap-1 h-8 rounded-lg bg-white border border-[#E6ECF5] text-[#0F172A] text-[10px] font-semibold hover:border-[#0A39DF] transition-colors">
|
||||
<ImageIcon className="w-3 h-3 text-[#0A39DF]" /> Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[9px] text-[#9CA3AF] mt-2 flex items-center gap-1 justify-center"><QrCode className="w-2.5 h-2.5" /> Scan to share your verified KROW Identity</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
src/components/krow/KrowIdentityCard.jsx
Normal file
82
src/components/krow/KrowIdentityCard.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Camera, Fingerprint, Sparkles } from 'lucide-react';
|
||||
|
||||
// A living identity card that reflects — rather than describes — the resume-less
|
||||
// KROW Identity. It fills in as the worker adds a face, skills, languages, etc.
|
||||
export default function KrowIdentityCard({ form }) {
|
||||
const parts = [
|
||||
{ key: 'face', filled: !!form.selfie_url },
|
||||
{ key: 'name', filled: !!form.full_name.trim() },
|
||||
{ key: 'about', filled: !!form.career_goals.trim() },
|
||||
{ key: 'skills', filled: (form.skills || []).length > 0 },
|
||||
{ key: 'languages', filled: (form.languages || []).length > 0 },
|
||||
{ key: 'availability', filled: (form.availability || []).length > 0 },
|
||||
{ key: 'certifications', filled: (form.certifications || []).length > 0 },
|
||||
];
|
||||
const done = parts.filter(p => p.filled).length;
|
||||
const pct = Math.round((done / parts.length) * 100);
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl bg-[#0A39DF] p-5 sm:p-6 text-white overflow-hidden relative">
|
||||
<div className="absolute -top-10 -right-10 w-40 h-40 rounded-full bg-[#F9E547]/10" />
|
||||
<div className="relative flex items-center gap-4">
|
||||
<div className="shrink-0">
|
||||
{form.selfie_url ? (
|
||||
<img src={form.selfie_url} alt="You" className="w-20 h-20 rounded-2xl object-cover ring-4 ring-white/25" />
|
||||
) : (
|
||||
<div className="w-20 h-20 rounded-2xl border-2 border-dashed border-white/40 flex flex-col items-center justify-center text-white/70">
|
||||
<Camera className="w-6 h-6" />
|
||||
<span className="text-[9px] mt-0.5">your face</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="inline-flex items-center gap-1.5 text-[10px] font-medium tracking-wide bg-white/15 px-2 py-0.5 rounded-full">
|
||||
<Fingerprint className="w-3 h-3" /> KROW Identity
|
||||
</div>
|
||||
<h1 className="text-[22px] sm:text-[26px] font-bold leading-tight mt-1.5 truncate">
|
||||
{form.full_name.trim() || 'Your name'}
|
||||
</h1>
|
||||
<p className="text-[12px] opacity-80 mt-0.5 line-clamp-2">
|
||||
{form.career_goals.trim() || 'What you do and what you\u2019re good at will appear here.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative mt-4 space-y-2.5">
|
||||
<SlotRow label="Skills" items={form.skills} />
|
||||
<SlotRow label="Languages" items={form.languages} />
|
||||
<SlotRow label="Availability" items={form.availability} />
|
||||
<SlotRow label="Certifications" items={form.certifications} />
|
||||
</div>
|
||||
|
||||
<div className="relative mt-4">
|
||||
<div className="flex items-center justify-between text-[11px] mb-1.5">
|
||||
<span className="inline-flex items-center gap-1 opacity-90"><Sparkles className="w-3 h-3 text-[#F9E547]" /> Your identity is {pct}% alive</span>
|
||||
<span className="opacity-70">{done}/{parts.length}</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full rounded-full bg-white/15 overflow-hidden">
|
||||
<div className="h-full bg-[#F9E547] transition-all duration-500" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SlotRow({ label, items }) {
|
||||
const filled = (items || []).length > 0;
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-[11px]">
|
||||
<span className="w-20 shrink-0 opacity-60">{label}</span>
|
||||
{filled ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{items.slice(0, 4).map((it, i) => (
|
||||
<span key={i} className="px-2 py-0.5 rounded-full bg-white/15 text-white/90">{it}</span>
|
||||
))}
|
||||
{items.length > 4 && <span className="px-1.5 opacity-70">+{items.length - 4}</span>}
|
||||
</div>
|
||||
) : (
|
||||
<span className="px-2 py-0.5 rounded-full border border-dashed border-white/30 text-white/50">add {label.toLowerCase()}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
src/components/krow/KrowSays.jsx
Normal file
24
src/components/krow/KrowSays.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
|
||||
// Apple-style focal statement: a quiet vertical list beside a single thin divider.
|
||||
export default function KrowSays() {
|
||||
const lines = ['Learn this.', 'Prove it.', 'Get hired.'];
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[13px] font-medium text-[#333333] mb-6">KROW says</p>
|
||||
<div className="relative pl-6">
|
||||
<div className="absolute left-0 top-2 bottom-2 w-px bg-[#CCCCCC]" />
|
||||
<ul className="space-y-5 sm:space-y-6">
|
||||
{lines.map((l) => (
|
||||
<li
|
||||
key={l}
|
||||
className="text-[26px] sm:text-[34px] font-semibold tracking-tight text-[#333333] leading-tight"
|
||||
>
|
||||
{l}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
src/components/krow/KrowScoreRing.jsx
Normal file
20
src/components/krow/KrowScoreRing.jsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function KrowScoreRing({ score = 0, size = 64, label = 'KROW Score' }) {
|
||||
const radius = (size - 8) / 2;
|
||||
const circ = 2 * Math.PI * radius;
|
||||
const offset = circ - (Math.min(100, score) / 100) * circ;
|
||||
const color = score >= 80 ? '#0A39DF' : score >= 60 ? '#333F48' : score >= 40 ? '#6B7280' : '#EF4444';
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="relative flex items-center justify-center" style={{ width: size, height: size }}>
|
||||
<svg width={size} height={size} className="-rotate-90">
|
||||
<circle cx={size / 2} cy={size / 2} r={radius} fill="none" stroke="#E5E7EB" strokeWidth="5" />
|
||||
<circle cx={size / 2} cy={size / 2} r={radius} fill="none" stroke={color} strokeWidth="5" strokeDasharray={circ} strokeDashoffset={offset} strokeLinecap="round" />
|
||||
</svg>
|
||||
<span className="absolute font-bold text-[#111827]" style={{ fontSize: size * 0.28 }}>{score}</span>
|
||||
</div>
|
||||
{label && <span className="text-[11px] text-[#6B7280] mt-1">{label}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
src/components/krow/LearningPathCard.jsx
Normal file
28
src/components/krow/LearningPathCard.jsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowRight, Route } from 'lucide-react';
|
||||
|
||||
export default function LearningPathCard({ path, completedIds = [] }) {
|
||||
const steps = path.steps || [];
|
||||
const done = steps.filter(s => completedIds.has(s.course_id)).length;
|
||||
const pct = steps.length ? Math.round((done / steps.length) * 100) : 0;
|
||||
return (
|
||||
<Link to="/university" className="block bg-white border border-[#E5E7EB] rounded-xl p-5 hover:border-[#0A39DF] hover:shadow-sm transition-all">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="w-8 h-8 rounded-lg bg-[#0A39DF] flex items-center justify-center shrink-0"><Route className="w-4 h-4 text-white" /></div>
|
||||
<h3 className="font-semibold text-[#111827] text-[15px]">{path.name}</h3>
|
||||
</div>
|
||||
<p className="text-[12px] text-[#6B7280] mb-3">{path.description || `Career track to ${path.target_role || path.name}`}</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5 mb-3">
|
||||
{steps.flatMap((s, i) => [
|
||||
<span key={`s${i}`} className={`text-[11px] px-2 py-0.5 rounded-full ${completedIds.has(s.course_id) ? 'bg-[#F0FDF4] text-[#166534]' : 'bg-[#F3F4F6] text-[#6B7280]'}`}>{s.course_title || `Step ${i + 1}`}</span>,
|
||||
i < steps.length - 1 ? <ArrowRight key={`a${i}`} className="w-3 h-3 text-[#D1D5DB]" /> : null,
|
||||
])}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-2 rounded-full bg-[#F3F4F6] overflow-hidden"><div className="h-full bg-[#0A39DF]" style={{ width: `${pct}%` }} /></div>
|
||||
<span className="text-[11px] font-medium text-[#6B7280]">{done}/{steps.length}</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
80
src/components/krow/MessageCandidateModal.jsx
Normal file
80
src/components/krow/MessageCandidateModal.jsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Mail, Phone, Send, CheckCircle2 } from 'lucide-react';
|
||||
|
||||
export default function MessageCandidateModal({ open, onClose, application }) {
|
||||
const [channel, setChannel] = useState('message');
|
||||
const [message, setMessage] = useState('');
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
const handleSend = () => {
|
||||
setSent(true);
|
||||
setTimeout(() => {
|
||||
setSent(false);
|
||||
setMessage('');
|
||||
onClose();
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Message {application?.applicant_name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{sent ? (
|
||||
<div className="py-8 text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-[#D1FAE5] flex items-center justify-center mx-auto mb-3">
|
||||
<CheckCircle2 className="w-6 h-6 text-[#059669]" />
|
||||
</div>
|
||||
<p className="text-[14px] font-medium text-[#111827]">Message sent!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => setChannel('message')}
|
||||
className={`flex items-center justify-center gap-2 py-2.5 rounded-lg border text-[13px] font-medium transition-all ${
|
||||
channel === 'message' ? 'border-[#0A39DF] bg-[#EFF6FF] text-[#0A39DF]' : 'border-[#E5E7EB] text-[#6B7280]'
|
||||
}`}
|
||||
>
|
||||
<Mail className="w-4 h-4" /> Message
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setChannel('call')}
|
||||
className={`flex items-center justify-center gap-2 py-2.5 rounded-lg border text-[13px] font-medium transition-all ${
|
||||
channel === 'call' ? 'border-[#0A39DF] bg-[#EFF6FF] text-[#0A39DF]' : 'border-[#E5E7EB] text-[#6B7280]'
|
||||
}`}
|
||||
>
|
||||
<Phone className="w-4 h-4" /> Call
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{channel === 'call' ? (
|
||||
<div className="py-6 text-center text-[13px] text-[#6B7280]">
|
||||
<Phone className="w-8 h-8 text-[#0A39DF] mx-auto mb-2" />
|
||||
Calling {application?.phone || 'candidate'}...
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder={`Hi ${application?.applicant_name?.split(' ')[0]}, ...`}
|
||||
className="w-full min-h-[100px] p-3 text-[14px] rounded-lg border border-[#E5E7EB] focus:border-[#0A39DF] focus:outline-none resize-none"
|
||||
/>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={handleSend} disabled={channel === 'message' && !message.trim()} className="bg-[#0A39DF] hover:bg-[#082EB4]">
|
||||
<Send className="w-4 h-4 mr-2" /> Send
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
14
src/components/krow/OwliverAvatar.jsx
Normal file
14
src/components/krow/OwliverAvatar.jsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Image } from '@/components/ui/image';
|
||||
|
||||
const OWLIVER_URL = 'https://media.base44.com/images/public/6a653ab9436c05c43a00994f/620b75f07_WhatsAppImage2026-07-31at35919PM.jpeg';
|
||||
|
||||
export default function OwliverAvatar({ className = 'w-16 h-16', rounded = 'rounded-full' }) {
|
||||
return (
|
||||
<Image
|
||||
src={OWLIVER_URL}
|
||||
alt="Owliver"
|
||||
fittingType="fill"
|
||||
className={`shrink-0 overflow-hidden ${rounded} ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
76
src/components/krow/OwliverChatBubble.jsx
Normal file
76
src/components/krow/OwliverChatBubble.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Image } from '@/components/ui/image';
|
||||
import { Sparkles, X } from 'lucide-react';
|
||||
import Owliver from '@/pages/Owliver';
|
||||
|
||||
const OWLIVER_URL = 'https://media.base44.com/images/public/6a653ab9436c05c43a00994f/2005ea00e_WhatsAppImage2026-07-31at35919PM.jpeg';
|
||||
|
||||
export default function OwliverChatBubble({ open: externalOpen, onOpenChange, showTrigger = true }) {
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const open = externalOpen !== undefined ? externalOpen : internalOpen;
|
||||
const setOpen = (v) => {
|
||||
if (onOpenChange) onOpenChange(v);
|
||||
setInternalOpen(v);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6 z-50">
|
||||
<AnimatePresence mode="wait">
|
||||
{!open && showTrigger ? (
|
||||
<motion.button
|
||||
key="pill"
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 12, scale: 0.96 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="Ask Owliver"
|
||||
className="group flex items-center gap-3 rounded-full bg-white pl-5 pr-2 py-2 w-[min(80vw,360px)]
|
||||
shadow-[0_10px_30px_rgba(10,57,223,0.22),0_2px_6px_rgba(51,63,72,0.08)]
|
||||
ring-1 ring-black/5 transition-transform duration-300 hover:scale-[1.01]"
|
||||
>
|
||||
<Sparkles className="w-4 h-4 text-[#0A39DF] shrink-0" />
|
||||
<span className="text-[15px] text-[#B5B8D3] font-medium flex-1 truncate text-left group-hover:text-[#0A39DF] transition-colors">
|
||||
Build your profile
|
||||
</span>
|
||||
<span className="relative block w-12 h-12 rounded-full overflow-hidden ring-2 ring-[#0A39DF]/15 shadow-[0_4px_12px_rgba(10,57,223,0.35)] shrink-0">
|
||||
<Image src={OWLIVER_URL} alt="Owliver" fittingType="fill" className="w-full h-full" />
|
||||
</span>
|
||||
</motion.button>
|
||||
) : open ? (
|
||||
<motion.div
|
||||
key="panel"
|
||||
initial={{ opacity: 0, y: 24, scale: 0.96 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 12, scale: 0.96 }}
|
||||
transition={{ duration: 0.28 }}
|
||||
className="flex flex-col w-[min(94vw,400px)] h-[min(82vh,620px)] rounded-2xl bg-white overflow-hidden
|
||||
shadow-[0_18px_50px_rgba(10,57,223,0.28),0_4px_12px_rgba(51,63,72,0.12)] ring-1 ring-black/5"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 px-4 py-3 bg-[#0A39DF] text-white shrink-0">
|
||||
<span className="block w-8 h-8 rounded-full overflow-hidden ring-2 ring-white/40">
|
||||
<Image src={OWLIVER_URL} alt="Owliver" fittingType="fill" className="w-full h-full" />
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[14px] font-bold leading-tight flex items-center gap-1.5">
|
||||
Owliver <Sparkles className="w-3.5 h-3.5 text-[#F9E547]" />
|
||||
</div>
|
||||
<div className="text-[11px] text-white/80 leading-tight">Online · ready to chat</div>
|
||||
</div>
|
||||
<button onClick={() => setOpen(false)} aria-label="Close chat" className="w-8 h-8 rounded-full hover:bg-white/20 flex items-center justify-center transition-colors">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto bg-[#F9FAFB] p-4">
|
||||
<div className="max-w-none h-full [&>div]:max-w-none [&>div]:mx-0 [&>div]:h-full [&>div]:flex [&>div]:flex-col">
|
||||
<Owliver compact />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
src/components/krow/OwliverCoachCard.jsx
Normal file
37
src/components/krow/OwliverCoachCard.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
|
||||
export default function OwliverCoachCard({ nextCourse, matchCount, avgPay }) {
|
||||
return (
|
||||
<section className="py-12 sm:py-16 border-t border-[#F1F5F9]">
|
||||
<div className="flex flex-col items-center text-center max-w-xl mx-auto">
|
||||
<OwliverAvatar className="w-14 h-14" rounded="rounded-full" />
|
||||
<h2 className="text-[12px] font-medium text-[#94A3B8] uppercase tracking-wider mt-4">Owliver · AI Coach</h2>
|
||||
<p className="text-[18px] sm:text-[22px] font-medium text-[#0F172A] mt-3 leading-snug">
|
||||
{nextCourse ? (
|
||||
<>
|
||||
Complete <span className="text-[#0A39DF]">{nextCourse.title}</span> to unlock stronger matches.
|
||||
</>
|
||||
) : (
|
||||
<>Keep building your profile and I'll map your fastest path forward.</>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-[13px] text-[#64748B] mt-3">
|
||||
{matchCount > 0
|
||||
? `${matchCount} open roles match you now${avgPay ? ` · avg ${avgPay}` : ''}`
|
||||
: 'No matches yet — keep learning.'}
|
||||
</p>
|
||||
{nextCourse && (
|
||||
<Link
|
||||
to={`/university/${nextCourse.id}`}
|
||||
className="mt-7 inline-flex items-center gap-1.5 bg-[#0A39DF] text-white text-[14px] font-medium px-6 py-3 rounded-full hover:bg-[#082EB4] transition-colors"
|
||||
>
|
||||
Start Learning <ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
68
src/components/krow/PipelineFunnel.jsx
Normal file
68
src/components/krow/PipelineFunnel.jsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const STAGES = [
|
||||
{ key: 'applied', label: 'Applied', color: 'bg-[#D1E0D7]', text: 'text-[#333F48]' },
|
||||
{ key: 'ai_screened', label: 'AI Screened', color: 'bg-[#F8E08E]', text: 'text-[#333F48]' },
|
||||
{ key: 'shortlisted', label: 'Shortlisted', color: 'bg-[#F9E547]', text: 'text-[#0A39DF]' },
|
||||
{ key: 'interview', label: 'Interviewed', color: 'bg-[#333F48]', text: 'text-white' },
|
||||
{ key: 'hired', label: 'Hired', color: 'bg-[#0A39DF]', text: 'text-white' },
|
||||
];
|
||||
|
||||
export default function PipelineFunnel({ applications }) {
|
||||
const counts = STAGES.map((s) => {
|
||||
if (s.key === 'ai_screened') {
|
||||
return applications.filter(a => ['ai_screened', 'shortlisted', 'interview', 'hired'].includes(a.status)).length;
|
||||
}
|
||||
if (s.key === 'shortlisted') {
|
||||
return applications.filter(a => ['shortlisted', 'interview', 'hired'].includes(a.status)).length;
|
||||
}
|
||||
if (s.key === 'interview') {
|
||||
return applications.filter(a => ['interview', 'hired'].includes(a.status)).length;
|
||||
}
|
||||
return applications.filter(a => a.status === s.key).length;
|
||||
});
|
||||
|
||||
const max = Math.max(...counts, 1);
|
||||
const total = counts[0] || 1;
|
||||
|
||||
return (
|
||||
<div className="glass-card border border-white/60 rounded-2xl p-6 shadow-sm">
|
||||
<h3 className="font-heading font-semibold text-[#111827] text-[16px] mb-1">Pipeline Stages</h3>
|
||||
<p className="text-[12px] text-[#6B7280] mb-5">Candidate progression through hiring funnel</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{STAGES.map((stage, i) => {
|
||||
const count = counts[i];
|
||||
const width = (count / max) * 100;
|
||||
const convRate = i === 0 ? 100 : Math.round((count / total) * 100);
|
||||
const stepRate = i === 0 ? 100 : (counts[i - 1] ? Math.round((count / counts[i - 1]) * 100) : 0);
|
||||
return (
|
||||
<div key={stage.key}>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-medium text-[#374151]">{stage.label}</span>
|
||||
{i > 0 && stepRate <= 100 && (
|
||||
<span className="text-[10px] text-[#9CA3AF]">→ {stepRate}% from prev</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[11px] text-[#9CA3AF]">{convRate}% of total</span>
|
||||
<span className="text-[15px] font-bold text-[#111827] w-8 text-right">{count}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-7 bg-[#F3F4F6] rounded-lg overflow-hidden">
|
||||
<div
|
||||
className={cn('h-full rounded-lg flex items-center px-3 transition-all duration-700', stage.color)}
|
||||
style={{ width: `${Math.max(width, count > 0 ? 6 : 0)}%` }}
|
||||
>
|
||||
<span className={cn('text-[11px] font-semibold', stage.text)}>{count > 0 ? count : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
154
src/components/krow/RateEndorseModal.jsx
Normal file
154
src/components/krow/RateEndorseModal.jsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Star, BadgeCheck, X, Award } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useUpdateStaff } from '@/lib/krowHooks';
|
||||
|
||||
const SKILL_SUGGESTIONS = [
|
||||
'Punctuality', 'Teamwork', 'Communication', 'Reliability',
|
||||
'Quality of Work', 'Leadership', 'Adaptability', 'Safety Awareness',
|
||||
'Customer Service', 'Work Ethic',
|
||||
];
|
||||
|
||||
export default function RateEndorseModal({ open, onClose, staff, reviewerName }) {
|
||||
const update = useUpdateStaff();
|
||||
const [rating, setRating] = useState(staff?.client_rating || 0);
|
||||
const [hover, setHover] = useState(0);
|
||||
const [text, setText] = useState(staff?.endorsement_text || '');
|
||||
const [skills, setSkills] = useState(staff?.endorsed_skills || []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setRating(staff?.client_rating || 0);
|
||||
setText(staff?.endorsement_text || '');
|
||||
setSkills(staff?.endorsed_skills || []);
|
||||
}
|
||||
}, [open, staff]);
|
||||
|
||||
if (!staff) return null;
|
||||
|
||||
const toggleSkill = (s) => {
|
||||
setSkills((prev) => prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s]);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (rating === 0) {
|
||||
toast.error('Please select a star rating');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await update.mutateAsync({
|
||||
id: staff.id,
|
||||
data: {
|
||||
client_rating: rating,
|
||||
endorsement_text: text.trim(),
|
||||
endorsed_skills: skills,
|
||||
review_date: new Date().toISOString().split('T')[0],
|
||||
reviewer_name: reviewerName || '',
|
||||
},
|
||||
});
|
||||
toast.success(`Rated ${staff.name}`);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
toast.error('Failed to save rating');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-md p-0">
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>Rate & Endorse {staff.name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="p-5 space-y-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider uppercase">Rate & Endorse</p>
|
||||
<h2 className="text-[18px] font-bold text-[#111827] mt-0.5">{staff.name}</h2>
|
||||
<p className="text-[12px] text-[#6B7280] mt-0.5">{staff.role || 'Staff'} {staff.profile_tier && `· ${staff.profile_tier}`}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-[#9CA3AF] hover:text-[#111827]">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Star rating */}
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-[#374151] mb-2">Overall Rating</p>
|
||||
<div className="flex items-center gap-1">
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
onMouseEnter={() => setHover(n)}
|
||||
onMouseLeave={() => setHover(0)}
|
||||
onClick={() => setRating(n)}
|
||||
className="p-0.5"
|
||||
>
|
||||
<Star
|
||||
className={`w-7 h-7 ${(hover || rating) >= n ? 'text-[#F59E0B] fill-[#F59E0B]' : 'text-[#E5E7EB]'}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
<span className="ml-2 text-[13px] font-semibold text-[#111827]">{rating > 0 ? `${rating}.0` : '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Endorsement text */}
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-[#374151] mb-2">Endorsement Note</p>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Share what made them great to work with..."
|
||||
className="w-full rounded-lg border border-[#E5E7EB] bg-white px-3 py-2 text-[13px] focus:border-[#0A39DF] focus:outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Endorsed skills */}
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-[#374151] mb-2">Endorsed Skills</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{SKILL_SUGGESTIONS.map((s) => {
|
||||
const on = skills.includes(s);
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => toggleSkill(s)}
|
||||
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-medium border transition-colors ${
|
||||
on
|
||||
? 'bg-[#F0F4F8] text-[#1E56C1] border-[#1E56C1]'
|
||||
: 'bg-white text-[#6B7280] border-[#E5E7EB] hover:border-[#1E56C1]'
|
||||
}`}
|
||||
>
|
||||
{on && <BadgeCheck className="w-3 h-3" />}
|
||||
{s}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-2 border-t border-[#F3F4F6]">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-lg border border-[#E5E7EB] text-[#374151] text-[13px] font-medium hover:bg-[#F9FAFB]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={update.isPending}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-[#0A39DF] hover:bg-[#082EB4] disabled:opacity-50 text-white text-[13px] font-medium"
|
||||
>
|
||||
<Award className="w-4 h-4" />
|
||||
{update.isPending ? 'Saving...' : 'Save Rating'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
120
src/components/krow/ReferencesEndorsements.jsx
Normal file
120
src/components/krow/ReferencesEndorsements.jsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Quote, BadgeCheck, Star, ChevronDown } from 'lucide-react';
|
||||
|
||||
const COMPANY_COLORS = ['bg-[#0A39DF]', 'bg-[#333F48]', 'bg-[#082EB4]'];
|
||||
|
||||
const colorFor = (name) => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
return COMPANY_COLORS[Math.abs(hash) % COMPANY_COLORS.length];
|
||||
};
|
||||
|
||||
const SUPERVISOR_NAMES = ['Luis', 'Chef Antonio', 'Maria', 'David', 'Sofia'];
|
||||
const ROLES = ['Supervisor', 'Chef', 'Manager', 'Lead', 'Owner'];
|
||||
|
||||
// Build endorsement entries. Prefers real companies the candidate worked at;
|
||||
// falls back to seeded references derived from their strengths so the employer
|
||||
// always sees verbatim-style feedback.
|
||||
function buildVoices(application) {
|
||||
const companies = (application?.companies_worked || []).filter(Boolean);
|
||||
const strengths = (application?.ai_strengths || []).filter(Boolean);
|
||||
const years = application?.years_experience || 0;
|
||||
const source = companies.length > 0
|
||||
? companies.slice(0, 3)
|
||||
: strengths.slice(0, 3).map((s) => 'Verified Reference');
|
||||
if (source.length === 0) return [];
|
||||
return source.map((c, i) => {
|
||||
const name = SUPERVISOR_NAMES[i % SUPERVISOR_NAMES.length];
|
||||
const role = ROLES[i % ROLES.length];
|
||||
const events = 8 + i * 17 + years;
|
||||
const quote = strengths[i]
|
||||
? `${strengths[i]}. Highly recommend for any team.`
|
||||
: `Reliable, punctual, and a great team player. Would hire again.`;
|
||||
return { id: i, name, role, events, company: c, quote };
|
||||
});
|
||||
}
|
||||
|
||||
function Stars({ count = 5 }) {
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Star key={i} className={`w-3.5 h-3.5 ${i < count ? 'text-[#FF9800] fill-[#FF9800]' : 'text-[#E5E7EB]'}`} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ReferencesEndorsements({ application }) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const voices = buildVoices(application);
|
||||
const strengths = [
|
||||
...(application?.ai_strengths || []),
|
||||
...(application?.skills || []),
|
||||
].filter(Boolean).slice(0, 6);
|
||||
|
||||
if (voices.length === 0 && strengths.length === 0) return null;
|
||||
const count = voices.length + strengths.length;
|
||||
|
||||
return (
|
||||
<div className="mt-3 pt-3 border-t border-[#E5E7EB]/80">
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="w-full flex items-center justify-between gap-2 mb-1"
|
||||
>
|
||||
<span className="text-[10px] font-bold text-[#9CA3AF] tracking-wider flex items-center gap-1.5">
|
||||
<Quote className="w-3 h-3" /> ENDORSEMENTS
|
||||
<span className="text-[#D1D5DB] font-semibold">({count})</span>
|
||||
</span>
|
||||
<ChevronDown className={`w-3.5 h-3.5 text-[#9CA3AF] transition-transform ${open ? '' : '-rotate-90'}`} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="space-y-3 pt-1">
|
||||
{voices.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{voices.map((v) => (
|
||||
<div key={v.id} className="bg-white rounded-xl border border-[#E5E7EB] px-3 py-2.5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className={`w-8 h-8 rounded-full ${colorFor(v.company)} flex items-center justify-center text-white text-[11px] font-bold shrink-0`}>
|
||||
{v.name.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[13px] font-bold text-[#111827] truncate">{v.name}</p>
|
||||
<p className="text-[11px] text-[#6B7280]">{v.role} · {v.events} events</p>
|
||||
</div>
|
||||
<Stars count={5} />
|
||||
</div>
|
||||
<p className="text-[12px] text-[#374151] mt-2 leading-relaxed">"{v.quote}"</p>
|
||||
<div className="flex items-center gap-2 mt-2.5">
|
||||
<button className="inline-flex items-center gap-1 px-3 py-1 rounded-full border border-[#0A39DF] text-[#0A39DF] text-[11px] font-semibold hover:bg-[#EFF6FF] transition-colors">
|
||||
Request Reference
|
||||
</button>
|
||||
<button className="inline-flex items-center gap-1 px-3 py-1 rounded-full border border-[#0A39DF] text-[#0A39DF] text-[11px] font-semibold hover:bg-[#EFF6FF] transition-colors">
|
||||
Endorse Skill
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{strengths.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-2 flex items-center gap-1.5">
|
||||
<BadgeCheck className="w-3 h-3" /> STRENGTHS
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{strengths.map((s, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-[#EFF6FF] text-[#0A39DF] text-[11px] font-medium border border-[#DBEAFE]">
|
||||
<BadgeCheck className="w-3 h-3" />
|
||||
{typeof s === 'string' ? s : s.skill || s}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
src/components/krow/RetentionMetrics.jsx
Normal file
102
src/components/krow/RetentionMetrics.jsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { TrendingUp, TrendingDown, Clock, Activity } from 'lucide-react';
|
||||
|
||||
const MS_PER_DAY = 1000 * 60 * 60 * 24;
|
||||
|
||||
function tenureDays(hireDate, status, now) {
|
||||
if (!hireDate) return 0;
|
||||
const start = new Date(hireDate).getTime();
|
||||
const end = status === 'inactive' && now ? now : Date.now();
|
||||
return Math.max(0, Math.floor((end - start) / MS_PER_DAY));
|
||||
}
|
||||
|
||||
export default function RetentionMetrics({ staff }) {
|
||||
const stats = useMemo(() => {
|
||||
const total = staff.length;
|
||||
const active = staff.filter((s) => s.status === 'active').length;
|
||||
const onboarding = staff.filter((s) => s.status === 'onboarding').length;
|
||||
const churned = staff.filter((s) => s.status === 'inactive').length;
|
||||
|
||||
// Retention rate: of hires whose outcome is decided (active + inactive),
|
||||
// what share are still active. Onboarding hires are excluded from the denominator
|
||||
// since they haven't had time to churn.
|
||||
const decided = active + churned;
|
||||
const retentionRate = decided > 0 ? Math.round((active / decided) * 100) : null;
|
||||
|
||||
// Cohort retention: % of hires still on board after N days from hire_date.
|
||||
// A hire counts as retained past day N if (status !== 'inactive') OR (tenure >= N before churning).
|
||||
const now = Date.now();
|
||||
const withHireDate = staff.filter((s) => s.hire_date);
|
||||
const cohort = (days) => {
|
||||
if (withHireDate.length === 0) return null;
|
||||
const retained = withHireDate.filter((s) => {
|
||||
const tenure = tenureDays(s.hire_date, s.status, now);
|
||||
if (s.status === 'inactive') return tenure >= days;
|
||||
return true; // still active/onboarding — retained
|
||||
}).length;
|
||||
return Math.round((retained / withHireDate.length) * 100);
|
||||
};
|
||||
|
||||
const avgTenure = withHireDate.length > 0
|
||||
? Math.round(withHireDate.reduce((sum, s) => sum + tenureDays(s.hire_date, s.status, now), 0) / withHireDate.length)
|
||||
: 0;
|
||||
|
||||
return { total, active, onboarding, churned, retentionRate, c30: cohort(30), c60: cohort(60), c90: cohort(90), avgTenure };
|
||||
}, [staff]);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<Metric
|
||||
icon={<TrendingUp className="w-4 h-4" />}
|
||||
label="Retention Rate"
|
||||
value={stats.retentionRate == null ? '—' : `${stats.retentionRate}%`}
|
||||
tone={stats.retentionRate == null ? 'neutral' : stats.retentionRate >= 70 ? 'good' : stats.retentionRate >= 40 ? 'warn' : 'bad'}
|
||||
sub={`${stats.active} active · ${stats.churned} churned`}
|
||||
/>
|
||||
<Metric
|
||||
icon={<Activity className="w-4 h-4" />}
|
||||
label="Still Active"
|
||||
value={stats.active}
|
||||
tone="good"
|
||||
sub={stats.onboarding > 0 ? `+ ${stats.onboarding} onboarding` : 'On track'}
|
||||
/>
|
||||
<Metric
|
||||
icon={<TrendingDown className="w-4 h-4" />}
|
||||
label="Churned"
|
||||
value={stats.churned}
|
||||
tone={stats.churned === 0 ? 'good' : 'bad'}
|
||||
sub={`${stats.total} total hires`}
|
||||
/>
|
||||
<Metric
|
||||
icon={<Clock className="w-4 h-4" />}
|
||||
label="Avg Tenure"
|
||||
value={stats.avgTenure > 0 ? `${stats.avgTenure}d` : '—'}
|
||||
tone="neutral"
|
||||
sub={`30d: ${stats.c30 ?? '—'} · 90d: ${stats.c90 ?? '—'}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TONES = {
|
||||
good: { bg: 'bg-[#ECFDF5]', text: 'text-[#047857]', icon: 'bg-[#047857]' },
|
||||
warn: { bg: 'bg-[#FFF7ED]', text: 'text-[#B45309]', icon: 'bg-[#B45309]' },
|
||||
bad: { bg: 'bg-[#FEF2F2]', text: 'text-[#B91C1C]', icon: 'bg-[#B91C1C]' },
|
||||
neutral: { bg: 'bg-[#F0F4F8]', text: 'text-[#1E56C1]', icon: 'bg-[#1E56C1]' },
|
||||
};
|
||||
|
||||
function Metric({ icon, label, value, sub, tone = 'neutral' }) {
|
||||
const t = TONES[tone] || TONES.neutral;
|
||||
return (
|
||||
<div className="bg-white border border-[#E5E7EB] rounded-xl p-4 flex items-start gap-3">
|
||||
<div className={`w-9 h-9 rounded-lg flex items-center justify-center shrink-0 ${t.bg}`}>
|
||||
<span className={t.text}>{icon}</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-semibold text-[#6B7280] tracking-wide">{label}</p>
|
||||
<p className="text-[20px] font-bold text-[#111827] leading-tight mt-0.5">{value}</p>
|
||||
<p className="text-[10px] text-[#9CA3AF] mt-0.5 truncate">{sub}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
src/components/krow/RoleSelector.jsx
Normal file
34
src/components/krow/RoleSelector.jsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { Briefcase, User } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const ROLES = [
|
||||
{ value: 'recruiter', label: 'Recruiter / Admin', desc: 'Hire and manage candidates', icon: Briefcase },
|
||||
{ value: 'applicant', label: 'Applicant', desc: 'Apply to open positions', icon: User },
|
||||
];
|
||||
|
||||
export default function RoleSelector({ value, onChange }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{ROLES.map((r) => {
|
||||
const Icon = r.icon;
|
||||
const active = value === r.value;
|
||||
return (
|
||||
<button
|
||||
key={r.value}
|
||||
type="button"
|
||||
onClick={() => onChange(r.value)}
|
||||
className={cn(
|
||||
'p-4 rounded-xl border-2 text-left transition-all',
|
||||
active ? 'border-[#0A39DF] bg-[#EFF6FF]' : 'border-[#E5E7EB] hover:border-[#D1D5DB]'
|
||||
)}
|
||||
>
|
||||
<Icon className={cn('w-5 h-5 mb-2', active ? 'text-[#0A39DF]' : 'text-[#9CA3AF]')} />
|
||||
<p className={cn('text-[13px] font-semibold', active ? 'text-[#1E40AF]' : 'text-[#374151]')}>{r.label}</p>
|
||||
<p className="text-[11px] text-[#6B7280] mt-0.5">{r.desc}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
src/components/krow/ScheduleInterviewModal.jsx
Normal file
94
src/components/krow/ScheduleInterviewModal.jsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Calendar, Clock, Video, Phone, User } from 'lucide-react';
|
||||
|
||||
export default function ScheduleInterviewModal({ open, onClose, application }) {
|
||||
const [type, setType] = useState('video');
|
||||
const [date, setDate] = useState('');
|
||||
const [time, setTime] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [scheduled, setScheduled] = useState(false);
|
||||
|
||||
const handleSchedule = () => {
|
||||
setScheduled(true);
|
||||
setTimeout(() => {
|
||||
setScheduled(false);
|
||||
onClose();
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Schedule Interview</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{scheduled ? (
|
||||
<div className="py-8 text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-[#D1FAE5] flex items-center justify-center mx-auto mb-3">
|
||||
<Calendar className="w-6 h-6 text-[#059669]" />
|
||||
</div>
|
||||
<p className="text-[14px] font-medium text-[#111827]">Interview scheduled!</p>
|
||||
<p className="text-[12px] text-[#6B7280]">{application?.applicant_name} will be notified.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">Interview Type</Label>
|
||||
<div className="grid grid-cols-3 gap-2 mt-1.5">
|
||||
{[
|
||||
{ value: 'video', label: 'Video', icon: Video },
|
||||
{ value: 'phone', label: 'Phone', icon: Phone },
|
||||
{ value: 'inperson', label: 'In Person', icon: User },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setType(opt.value)}
|
||||
className={`flex flex-col items-center gap-1 py-2.5 rounded-lg border text-[12px] font-medium transition-all ${
|
||||
type === opt.value ? 'border-[#0A39DF] bg-[#EFF6FF] text-[#0A39DF]' : 'border-[#E5E7EB] text-[#6B7280] hover:bg-[#F9FAFB]'
|
||||
}`}
|
||||
>
|
||||
<opt.icon className="w-4 h-4" />
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">Date</Label>
|
||||
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} className="mt-1.5" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">Time</Label>
|
||||
<Input type="time" value={time} onChange={(e) => setTime(e.target.value)} className="mt-1.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">Notes (optional)</Label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Add any prep notes..."
|
||||
className="w-full mt-1.5 min-h-[60px] p-2.5 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0A39DF] focus:outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={handleSchedule} disabled={!date || !time} className="bg-[#0A39DF] hover:bg-[#082EB4]">
|
||||
<Clock className="w-4 h-4 mr-2" /> Schedule
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
67
src/components/krow/ScoreDistribution.jsx
Normal file
67
src/components/krow/ScoreDistribution.jsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Cell } from 'recharts';
|
||||
|
||||
const TIERS = [
|
||||
{ label: '0-39', range: [0, 39], color: '#333F48' },
|
||||
{ label: '40-59', range: [40, 59], color: '#D1E0D7' },
|
||||
{ label: '60-79', range: [60, 79], color: '#F8E08E' },
|
||||
{ label: '80-100', range: [80, 100], color: '#0A39DF' },
|
||||
];
|
||||
|
||||
export default function ScoreDistribution({ applications }) {
|
||||
const data = useMemo(() => {
|
||||
const scored = applications.filter(a => a.ai_score > 0);
|
||||
return TIERS.map((tier) => ({
|
||||
label: tier.label,
|
||||
count: scored.filter(a => a.ai_score >= tier.range[0] && a.ai_score <= tier.range[1]).length,
|
||||
color: tier.color,
|
||||
}));
|
||||
}, [applications]);
|
||||
|
||||
const avgScore = useMemo(() => {
|
||||
const scored = applications.filter(a => a.ai_score > 0);
|
||||
return scored.length ? Math.round(scored.reduce((s, a) => s + a.ai_score, 0) / scored.length) : 0;
|
||||
}, [applications]);
|
||||
|
||||
const screened = applications.filter(a => a.ai_score > 0).length;
|
||||
|
||||
return (
|
||||
<div className="glass-card border border-white/60 rounded-2xl p-6 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h3 className="font-heading font-semibold text-[#111827] text-[16px]">Candidate Scores</h3>
|
||||
<div className="text-right">
|
||||
<div className="text-[22px] font-heading font-bold gradient-text">{avgScore}</div>
|
||||
<div className="text-[10px] text-[#9CA3AF] tracking-wide">AVG SCORE</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[12px] text-[#6B7280] mb-5">{screened} candidates screened</p>
|
||||
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#F3F4F6" />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 11 }} stroke="#9CA3AF" />
|
||||
<YAxis tick={{ fontSize: 11 }} stroke="#9CA3AF" allowDecimals={false} />
|
||||
<Tooltip
|
||||
cursor={{ fill: '#F9FAFB' }}
|
||||
contentStyle={{ borderRadius: 8, border: '1px solid #E5E7EB', fontSize: 12 }}
|
||||
/>
|
||||
<Bar dataKey="count" radius={[6, 6, 0, 0]}>
|
||||
{data.map((entry, i) => (
|
||||
<Cell key={i} fill={entry.color} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
<div className="grid grid-cols-4 gap-2 mt-4">
|
||||
{TIERS.map((t, i) => (
|
||||
<div key={t.label} className="text-center">
|
||||
<div className="w-full h-1.5 rounded-full mb-1" style={{ background: t.color }} />
|
||||
<div className="text-[11px] text-[#6B7280]">{t.label}</div>
|
||||
<div className="text-[14px] font-bold text-[#111827]">{data[i].count}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
src/components/krow/SkillDNA.jsx
Normal file
63
src/components/krow/SkillDNA.jsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
|
||||
// Skill levels: Claimed → Practiced → Verified → Expert → Elite
|
||||
const LEVELS = [
|
||||
{ name: 'Claimed', fill: 35, bar: 'bg-[#CBD5E1]', text: 'text-[#94A3B8]' },
|
||||
{ name: 'Practiced', fill: 62, bar: 'bg-[#93C5FD]', text: 'text-[#3B82F6]' },
|
||||
{ name: 'Verified', fill: 84, bar: 'bg-[#0A39DF]', text: 'text-[#0A39DF]' },
|
||||
{ name: 'Expert', fill: 94, bar: 'bg-[#6366F1]', text: 'text-[#6366F1]' },
|
||||
{ name: 'Elite', fill: 100, bar: 'bg-[#F9E547]', text: 'text-[#92400E]' },
|
||||
];
|
||||
|
||||
function levelFor(skill, badges, courses) {
|
||||
const s = skill.toLowerCase();
|
||||
const certHits = (badges || []).filter((b) => (b.name || '').toLowerCase().includes(s)).length;
|
||||
const courseHits = (courses || []).filter((c) => (c.title || '').toLowerCase().includes(s)).length;
|
||||
const score = certHits * 2 + courseHits;
|
||||
if (score >= 6) return 4;
|
||||
if (score >= 3) return 3;
|
||||
if (certHits >= 1) return 2;
|
||||
if (courseHits >= 1) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export default function SkillDNA({ profile }) {
|
||||
const skills = profile?.skills || [];
|
||||
const badges = profile?.earned_badges || [];
|
||||
const courses = profile?.completed_courses || [];
|
||||
|
||||
return (
|
||||
<section className="py-12 sm:py-16 border-t border-[#F1F5F9]">
|
||||
<h2 className="text-[22px] sm:text-[26px] font-semibold text-[#0F172A] tracking-tight">Skill DNA</h2>
|
||||
<p className="text-[14px] text-[#64748B] mt-2 max-w-md">
|
||||
Every shift updates these bars. Employers trust them.
|
||||
</p>
|
||||
|
||||
{!skills.length ? (
|
||||
<p className="text-[14px] text-[#94A3B8] mt-8">
|
||||
Add skills to your profile to start tracking your Skill DNA.
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-8 space-y-5 max-w-2xl">
|
||||
{skills.map((sk, i) => {
|
||||
const lvl = LEVELS[levelFor(sk, badges, courses)];
|
||||
return (
|
||||
<div key={i}>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-[14px] font-medium text-[#0F172A]">{sk}</span>
|
||||
<span className={`text-[12px] font-medium ${lvl.text}`}>{lvl.name}</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-[#F1F5F9] overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${lvl.bar} rounded-full transition-all duration-500`}
|
||||
style={{ width: `${lvl.fill}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
161
src/components/krow/SuggestedTalent.jsx
Normal file
161
src/components/krow/SuggestedTalent.jsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useWorkerProfiles, useMatchTalentForJob } from '@/lib/krowHooks';
|
||||
import { Sparkles, Loader2, Briefcase, Star } from 'lucide-react';
|
||||
import { Image } from '@/components/ui/image';
|
||||
import { getScoreBand, toFICO } from '@/lib/talentHome';
|
||||
import TalentDetailModal from '@/components/krow/TalentDetailModal';
|
||||
|
||||
const PORTRAIT_PHOTOS = [
|
||||
'photo-1500648767731-7d3514ad8b31',
|
||||
'photo-1531123897727-8f129e1688ce',
|
||||
'photo-1573496359142-b8d87734a5a2',
|
||||
'photo-1487412720507-e7ab37603c6f',
|
||||
'photo-1502685104226-ee3a97a2f2c8',
|
||||
'photo-1544005313-94ddf0286df2',
|
||||
'photo-1494790108377-be9c29b29330',
|
||||
'photo-1438761681033-6461ffad8d80',
|
||||
'photo-1517841905240-472988babdf9',
|
||||
'photo-1463453091185-61582044d556',
|
||||
];
|
||||
|
||||
function getPortraitSeed(name) {
|
||||
let h = 0;
|
||||
for (let i = 0; i < (name || '').length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
|
||||
return PORTRAIT_PHOTOS[h % PORTRAIT_PHOTOS.length];
|
||||
}
|
||||
|
||||
const MATCH_LABEL_STYLES = {
|
||||
'Strong Match': 'bg-[#EFF6FF] text-[#0A39DF]',
|
||||
'Good Match': 'bg-[#F0FDF4] text-[#16A34A]',
|
||||
'Possible Fit': 'bg-[#FFFBEB] text-[#D97706]',
|
||||
'Weak Fit': 'bg-[#F3F4F6] text-[#6B7280]',
|
||||
};
|
||||
|
||||
const RECO_STYLES = {
|
||||
Hire: 'bg-[#16A34A] text-white',
|
||||
Interview: 'bg-[#0A39DF] text-white',
|
||||
Maybe: 'bg-[#D97706] text-white',
|
||||
Pass: 'bg-[#9CA3AF] text-white',
|
||||
};
|
||||
|
||||
export default function SuggestedTalent({ job, onContact, onSchedule, onHire }) {
|
||||
const { data: profiles = [] } = useWorkerProfiles();
|
||||
const matchTalent = useMatchTalentForJob();
|
||||
const [matches, setMatches] = useState([]);
|
||||
const [selected, setSelected] = useState(null);
|
||||
|
||||
// Auto-run matching when the job is published and there are profiles
|
||||
useEffect(() => {
|
||||
if (!job || profiles.length === 0) return;
|
||||
if (job.status !== 'active') return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await matchTalent.mutateAsync({ job, profiles });
|
||||
if (!cancelled && res?.matches) setMatches(res.matches);
|
||||
} catch (e) {
|
||||
if (!cancelled) setMatches([]);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
|
||||
}, [job?.id, job?.status, profiles.length]);
|
||||
|
||||
const profileById = Object.fromEntries(profiles.map((p) => [p.id, p]));
|
||||
const enriched = matches
|
||||
.map((m) => ({ ...m, profile: profileById[m.profile_id] }))
|
||||
.filter((m) => m.profile)
|
||||
.slice(0, 6);
|
||||
|
||||
const runMatch = () => {
|
||||
if (profiles.length === 0) return;
|
||||
matchTalent.mutate({ job, profiles }).then((res) => setMatches(res?.matches || [])).catch(() => setMatches([]));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-6">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-lg bg-[#EFF6FF] flex items-center justify-center">
|
||||
<Sparkles className="w-4 h-4 text-[#0A39DF]" />
|
||||
</div>
|
||||
<h3 className="text-[15px] font-semibold text-[#111827]">Suggested Talent</h3>
|
||||
</div>
|
||||
{matches.length > 0 && (
|
||||
<button onClick={runMatch} disabled={matchTalent.isPending} className="text-[12px] font-medium text-[#0A39DF] hover:underline disabled:opacity-50">
|
||||
{matchTalent.isPending ? 'Re-matching…' : 'Re-run match'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[12px] text-[#6B7280] mb-4">Don't wait for applications — KROW matched these talents from the pool against your job. Tap a profile to see the full KROW Identity.</p>
|
||||
|
||||
{matchTalent.isPending && matches.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-10 gap-2 text-[#6B7280]">
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<span className="text-[13px]">Matching talent from the pool…</span>
|
||||
</div>
|
||||
) : enriched.length === 0 ? (
|
||||
<div className="text-center py-10">
|
||||
<p className="text-[13px] text-[#6B7280]">No strong matches yet. Try widening your requirements, or re-run the match.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{enriched.map((m) => {
|
||||
const p = m.profile;
|
||||
const band = getScoreBand(p.krow_score || 0);
|
||||
return (
|
||||
<div key={m.profile_id} className="flex items-start gap-3 p-3 rounded-xl border border-[#E5E7EB] hover:border-[#0A39DF] hover:bg-[#FAFBFF] transition-colors cursor-pointer" onClick={() => setSelected(p)}>
|
||||
<div className="w-11 h-11 rounded-full bg-[#E5E7EB] overflow-hidden shrink-0">
|
||||
<Image
|
||||
src={p.selfie_url || `https://images.unsplash.com/${getPortraitSeed(p.full_name)}?w=120&h=120&fit=crop&crop=faces`}
|
||||
alt={p.full_name}
|
||||
className="w-full h-full object-cover"
|
||||
fittingType="fill"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-[#111827] text-[14px] truncate">{p.full_name}</span>
|
||||
<span className={`px-2 py-0.5 rounded-full text-[10px] font-bold ${RECO_STYLES[m.recommendation] || RECO_STYLES.Maybe}`}>{m.recommendation}</span>
|
||||
<span className="px-2 py-0.5 rounded-full text-[10px] font-medium text-white" style={{ backgroundColor: band.color }}>{band.label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1 text-[11px] text-[#6B7280] flex-wrap">
|
||||
{(p.desired_position || p.current_position) && (
|
||||
<span className="inline-flex items-center gap-1 font-medium text-[#0A39DF]">
|
||||
<Briefcase className="w-3 h-3" />{p.desired_position || p.current_position}
|
||||
</span>
|
||||
)}
|
||||
{p.experience_years > 0 && <span>{p.experience_years}yr exp</span>}
|
||||
<span className="inline-flex items-center gap-1 text-[#F59E0B] font-medium">
|
||||
<Star className="w-3 h-3 fill-[#F59E0B]" />Career {toFICO(p.krow_score || 0)}
|
||||
</span>
|
||||
</div>
|
||||
{m.reasons?.length > 0 && (
|
||||
<ul className="mt-1.5 text-[11px] text-[#6B7280] list-disc list-inside space-y-0.5">
|
||||
{m.reasons.slice(0, 2).map((r, i) => <li key={i}>{r}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center shrink-0 px-2">
|
||||
<span className="text-[20px] font-bold text-[#0A39DF] leading-none">{Math.round(m.match_score)}</span>
|
||||
<span className="text-[9px] text-[#9CA3AF] tracking-wide">MATCH</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
<TalentDetailModal
|
||||
open={true}
|
||||
onClose={() => setSelected(null)}
|
||||
profile={selected}
|
||||
onContact={(profile) => { setSelected(null); onContact?.(profile); }}
|
||||
onSchedule={(profile) => { setSelected(null); onSchedule?.(profile); }}
|
||||
onHire={(profile) => { setSelected(null); onHire?.(profile); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
322
src/components/krow/TalentDetailModal.jsx
Normal file
322
src/components/krow/TalentDetailModal.jsx
Normal file
@@ -0,0 +1,322 @@
|
||||
import React from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Mail, Phone, Briefcase, Star, Award, BadgeCheck, Fingerprint, X, Calendar, UserCheck, Sparkles, Brain, MessageSquare } from 'lucide-react';
|
||||
import { Image } from '@/components/ui/image';
|
||||
import CareerScoreGauge from '@/components/krow/talent/CareerScoreGauge';
|
||||
import ExperienceEndorsements, { verifiedExperience } from '@/components/krow/talent/ExperienceEndorsements';
|
||||
import EliteTalentScore from '@/components/krow/talent/EliteTalentScore';
|
||||
|
||||
const PORTRAIT_PHOTOS = [
|
||||
'photo-1500648767731-7d3514ad8b31',
|
||||
'photo-1531123897727-8f129e1688ce',
|
||||
'photo-1573496359142-b8d87734a5a2',
|
||||
'photo-1487412720507-e7ab37603c6f',
|
||||
'photo-1502685104226-ee3a97a2f2c8',
|
||||
'photo-1544005313-94ddf0286df2',
|
||||
'photo-1494790108377-be9c29b29330',
|
||||
'photo-1438761681033-6461ffad8d80',
|
||||
'photo-1517841905240-472988babdf9',
|
||||
'photo-1463453091185-61582044d556',
|
||||
];
|
||||
|
||||
function getPortraitSeed(name) {
|
||||
let h = 0;
|
||||
for (let i = 0; i < (name || '').length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
|
||||
return PORTRAIT_PHOTOS[h % PORTRAIT_PHOTOS.length];
|
||||
}
|
||||
|
||||
function getInitials(name) {
|
||||
return (name || '').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase() || '??';
|
||||
}
|
||||
|
||||
function getKrowIdCode(profile) {
|
||||
if (profile?.id) {
|
||||
const hex = profile.id.replace(/[^a-f0-9]/gi, '');
|
||||
if (hex.length >= 6) return hex.slice(0, 6).toUpperCase();
|
||||
}
|
||||
let h = 0;
|
||||
const seed = profile?.full_name || 'KROW';
|
||||
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) >>> 0;
|
||||
return (h & 0xFFFFFF).toString(16).padStart(6, '0').toUpperCase();
|
||||
}
|
||||
|
||||
function formatDate(d) {
|
||||
try {
|
||||
return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
const BADGE_LEVEL_STYLES = {
|
||||
bronze: 'bg-[#FEF3C7] text-[#92400E] border-[#FCD34D]',
|
||||
silver: 'bg-[#F3F4F6] text-[#4B5563] border-[#D1D5DB]',
|
||||
gold: 'bg-[#FEF9E7] text-[#92400E] border-[#F9E547]',
|
||||
platinum: 'bg-[#EFF6FF] text-[#1E40AF] border-[#BFDBFE]',
|
||||
};
|
||||
|
||||
function Section({ label, children }) {
|
||||
if (!children) return null;
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-2">{label}</p>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PillList({ items, className = 'bg-[#F0F4F8] text-[#0047AB]' }) {
|
||||
if (!items || items.length === 0) return null;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{items.map((it, i) => (
|
||||
<span key={i} className={`px-2.5 py-1 rounded-full text-[11px] font-medium ${className}`}>{it}</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScoreFactor({ label, value }) {
|
||||
return (
|
||||
<div className="inline-flex items-center justify-between gap-2 px-3 py-1.5 rounded-full border border-[#E5E7EB] bg-white">
|
||||
<span className="text-[11px] text-[#6B7280] font-medium">{label}</span>
|
||||
<span className="text-[12px] font-bold text-[#2563EB]">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TalentDetailModal({ open, onClose, profile, onContact, onSchedule, onHire, matchScore, matchReasons }) {
|
||||
if (!profile) return null;
|
||||
const badges = profile.earned_badges || [];
|
||||
const capabilities = (profile.capabilities || []).filter((c) => c.status === 'verified' || c.status === 'pending');
|
||||
const experience = profile.experience || [];
|
||||
const strengths = profile.strengths || [];
|
||||
|
||||
const verifiedExperienceList = verifiedExperience(profile).length > 0;
|
||||
const krowCode = getKrowIdCode(profile);
|
||||
const reportDate = formatDate(profile.updated_date || new Date());
|
||||
const openToWork = profile.status === 'active' && (profile.availability?.length > 0 || !profile.current_position);
|
||||
|
||||
const reliability = Math.round(profile.reliability_score || 0);
|
||||
const attendance = Math.round(profile.attendance_score || 0);
|
||||
const performance = Math.round(profile.performance_score || 0);
|
||||
const leadership = Math.round(profile.leadership_potential || 0);
|
||||
const aiInterview = Math.round(profile.ai_interview_score || 0);
|
||||
const xp = Math.round(profile.xp || 0);
|
||||
|
||||
const identityVerified = capabilities.length > 0 || badges.length > 0;
|
||||
const identityTag = identityVerified ? 'Verified' : 'Pending';
|
||||
const identityClass = identityVerified ? 'bg-[#EFF6FF] text-[#1E40AF]' : 'bg-[#FFF7ED] text-[#B45309]';
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto p-0">
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{profile.full_name} — KROW Identity</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* KROW ID header — clean white, no blue band */}
|
||||
<div className="relative p-5 sm:p-6 bg-gradient-to-b from-[#F5F7FB] to-white">
|
||||
<button onClick={onClose} className="absolute top-4 right-4 text-[#9CA3AF] hover:text-[#111827]">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-16 h-16 rounded-full overflow-hidden ring-2 ring-[#E5E7EB] shrink-0 bg-[#E5E7EB] flex items-center justify-center">
|
||||
{profile.selfie_url ? (
|
||||
<Image
|
||||
src={profile.selfie_url}
|
||||
alt={profile.full_name}
|
||||
className="w-full h-full object-cover"
|
||||
fittingType="fill"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-[20px] font-bold text-[#1D4ED8]">{getInitials(profile.full_name)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
<div className="inline-flex items-center gap-1.5 text-[10px] font-bold tracking-wide text-[#2563EB] uppercase">
|
||||
<Fingerprint className="w-3 h-3" /> KROW ID · {krowCode}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||
<h2 className="text-[22px] font-bold leading-tight text-[#111827] truncate">{profile.full_name}</h2>
|
||||
{identityVerified && (
|
||||
<span className={`text-[10px] font-semibold px-2 py-0.5 rounded-full ${identityClass}`}>{identityTag}</span>
|
||||
)}
|
||||
{openToWork && (
|
||||
<span className="text-[12px] text-[#6B7280]">· Open to work</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-[#6B7280] mt-1">
|
||||
Career Report · {reportDate}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-5 sm:px-6 pb-5 sm:pb-6 space-y-5">
|
||||
{/* Quick stats banner — pill capsules, single line */}
|
||||
<div className="flex flex-nowrap gap-1.5 overflow-x-auto no-scrollbar">
|
||||
{profile.experience_years > 0 && (
|
||||
<Stat icon={<Briefcase className="w-3 h-3" />} label="Experience" value={`${profile.experience_years} yr`} />
|
||||
)}
|
||||
{profile.shifts_completed > 0 && (
|
||||
<Stat icon={<Briefcase className="w-3 h-3" />} label="Shifts" value={profile.shifts_completed} />
|
||||
)}
|
||||
{(profile.client_rating > 0 || profile.supervisor_rating > 0) && (
|
||||
<Stat icon={<Star className="w-3 h-3" />} label="Rating" value={`${((profile.client_rating + profile.supervisor_rating) / 2).toFixed(1)}`} valueClass="text-[#F59E0B]" />
|
||||
)}
|
||||
{profile.desired_position && (
|
||||
<Stat icon={<Briefcase className="w-3 h-3" />} label="Role" value={profile.desired_position} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Match banner */}
|
||||
{matchScore != null && (
|
||||
<div className="rounded-xl border border-[#DBEAFE] bg-[#EFF6FF] p-4">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<Sparkles className="w-4 h-4 text-[#0A39DF]" />
|
||||
<span className="text-[13px] font-bold text-[#0A39DF]">Match Score: {matchScore}/100</span>
|
||||
</div>
|
||||
{matchReasons?.length > 0 && (
|
||||
<ul className="text-[12px] text-[#374151] list-disc list-inside space-y-0.5">
|
||||
{matchReasons.map((r, i) => <li key={i}>{r}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Career Score */}
|
||||
<div className="bg-white border border-[#E5E7EB] rounded-2xl py-7 px-4 flex flex-col items-center">
|
||||
<CareerScoreGauge profile={profile} />
|
||||
</div>
|
||||
|
||||
{/* Elite Talent scorecard — dotted-leader metric list */}
|
||||
<EliteTalentScore profile={profile} />
|
||||
|
||||
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-4 space-y-4">
|
||||
<Section label="SKILLS">
|
||||
<PillList items={profile.skills} />
|
||||
</Section>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 pt-3 border-t border-[#F3F4F6]">
|
||||
<Section label="LANGUAGES">
|
||||
<PillList items={profile.languages} />
|
||||
</Section>
|
||||
<Section label="AVAILABILITY">
|
||||
<PillList items={profile.availability} className="bg-[#F3F4F6] text-[#374151]" />
|
||||
</Section>
|
||||
<Section label="CERTIFICATIONS">
|
||||
<PillList items={profile.certifications} className="bg-[#F3F4F6] text-[#374151]" />
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Badges */}
|
||||
{badges.length > 0 && (
|
||||
<Section label="BADGES">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{badges.map((b, i) => (
|
||||
<span key={i} className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-[12px] font-medium ${BADGE_LEVEL_STYLES[b.level] || BADGE_LEVEL_STYLES.bronze}`}>
|
||||
<Award className="w-3.5 h-3.5" />
|
||||
{b.name}
|
||||
{b.level && <span className="opacity-70 text-[10px] capitalize">· {b.level}</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Endorsements / Verified Capabilities */}
|
||||
{capabilities.length > 0 && (
|
||||
<Section label="VERIFIED CAPABILITIES (ENDORSEMENTS)">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{capabilities.map((c, i) => (
|
||||
<span key={i} className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[12px] ${c.status === 'verified' ? 'bg-[#F0F4F8] text-[#1E56C1]' : 'bg-[#F3F4F6] text-[#6B7280]'}`}>
|
||||
<BadgeCheck className="w-3.5 h-3.5" />
|
||||
<span className="font-semibold">{c.skill}</span>
|
||||
{c.level && <span className="text-[11px] text-[#5D759B]">· {c.level}</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Consolidated Experience & Endorsements — jobs + client endorsements in one list */}
|
||||
{verifiedExperience(profile).length > 0 && (
|
||||
<Section label="EXPERIENCE & ENDORSEMENTS">
|
||||
<ExperienceEndorsements profile={profile} />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Personality + strengths */}
|
||||
{(profile.personality || strengths.length > 0 || profile.communication_style) && (
|
||||
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-4 space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{profile.personality && (
|
||||
<div className="flex gap-2.5">
|
||||
<span className="w-7 h-7 rounded-lg bg-[#EFF6FF] flex items-center justify-center shrink-0">
|
||||
<Brain className="w-3.5 h-3.5 text-[#0A39DF]" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-1">PERSONALITY</p>
|
||||
<p className="text-[12px] text-[#374151] leading-relaxed">{profile.personality}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{profile.communication_style && (
|
||||
<div className="flex gap-2.5">
|
||||
<span className="w-7 h-7 rounded-lg bg-[#EFF6FF] flex items-center justify-center shrink-0">
|
||||
<MessageSquare className="w-3.5 h-3.5 text-[#0A39DF]" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-1">COMMUNICATION STYLE</p>
|
||||
<p className="text-[12px] text-[#374151] leading-relaxed">{profile.communication_style}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{strengths.length > 0 && (
|
||||
<div className="pt-3 border-t border-[#F3F4F6]">
|
||||
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-2">STRENGTHS</p>
|
||||
<PillList items={strengths} className="bg-[#EFF6FF] text-[#0A39DF]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contact info + actions */}
|
||||
<div className="border-t border-[#E5E7EB] pt-4">
|
||||
<div className="flex items-center gap-4 text-[12px] text-[#6B7280] flex-wrap mb-3">
|
||||
{profile.email && <span className="inline-flex items-center gap-1"><Mail className="w-3.5 h-3.5" /> {profile.email}</span>}
|
||||
{profile.phone && <span className="inline-flex items-center gap-1"><Phone className="w-3.5 h-3.5" /> {profile.phone}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{onContact && (
|
||||
<button onClick={() => onContact(profile)} className="inline-flex items-center gap-1.5 bg-[#0A39DF] hover:bg-[#082EB4] text-white text-[13px] font-medium px-4 py-2 rounded-lg transition-colors">
|
||||
<Mail className="w-4 h-4" /> Contact
|
||||
</button>
|
||||
)}
|
||||
{onSchedule && (
|
||||
<button onClick={() => onSchedule(profile)} className="inline-flex items-center gap-1.5 border border-[#E5E7EB] hover:bg-[#F9FAFB] text-[#374151] text-[13px] font-medium px-4 py-2 rounded-lg transition-colors">
|
||||
<Calendar className="w-4 h-4" /> Schedule Interview
|
||||
</button>
|
||||
)}
|
||||
{onHire && (
|
||||
<button onClick={() => onHire(profile)} className="inline-flex items-center gap-1.5 border border-[#16A34A] text-[#16A34A] hover:bg-[#16A34A] hover:text-white text-[13px] font-medium px-4 py-2 rounded-lg transition-colors">
|
||||
<UserCheck className="w-4 h-4" /> Quick Hire
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value, icon, valueClass = 'text-[#5D759B]' }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-[#F0F4F8] text-[#1E56C1] shrink-0">
|
||||
{icon}
|
||||
<span className="text-[11px] font-semibold">{label}</span>
|
||||
<span className="text-[11px] text-[#5D759B]">·</span>
|
||||
<span className={`text-[11px] font-medium truncate max-w-[90px] ${valueClass}`}>{value}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
114
src/components/krow/TalentLoop.jsx
Normal file
114
src/components/krow/TalentLoop.jsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
Fingerprint, GraduationCap, Mic, BadgeCheck, Handshake, Briefcase,
|
||||
LineChart, Star, TrendingUp, Crown, Presentation, Repeat, ArrowRight,
|
||||
} from 'lucide-react';
|
||||
|
||||
// The Credit Bureau for Human Talent — the career flywheel.
|
||||
const STEPS = [
|
||||
{ key: 'identity', label: 'Create Identity', to: '/identity', icon: Fingerprint },
|
||||
{ key: 'learn', label: 'Learn', to: '/university', icon: GraduationCap },
|
||||
{ key: 'interview', label: 'Interview', to: '/owliver', icon: Mic },
|
||||
{ key: 'verify', label: 'Verify Skills', to: '/identity', icon: BadgeCheck },
|
||||
{ key: 'hired', label: 'Get Hired', to: '/apply', icon: Handshake },
|
||||
{ key: 'work', label: 'Work', to: '/me', icon: Briefcase },
|
||||
{ key: 'track', label: 'Track Performance', to: '/me', icon: LineChart },
|
||||
{ key: 'reputation', label: 'Earn Reputation', to: '/me', icon: Star },
|
||||
{ key: 'promoted', label: 'Get Promoted', to: '/university', icon: TrendingUp },
|
||||
{ key: 'leader', label: 'Become Leader', to: '/university', icon: Crown },
|
||||
{ key: 'train', label: 'Train Others', to: '/university', icon: Presentation },
|
||||
{ key: 'repeat', label: 'Repeat', to: '/university', icon: Repeat, loop: true },
|
||||
];
|
||||
|
||||
export function stepStatus(profile, apps = []) {
|
||||
const hired = apps.some(a => a.status === 'hired');
|
||||
const worked = (profile.experience || []).length > 0 || hired;
|
||||
const isDone = {
|
||||
identity: !!profile.selfie_url && (profile.skills || []).length > 0,
|
||||
learn: (profile.completed_courses || []).length > 0,
|
||||
interview: (profile.ai_interview_score || 0) > 0 || !!(profile.personality || profile.strengths?.length),
|
||||
verify: (profile.certifications || []).length > 0 || (profile.earned_badges || []).length > 0,
|
||||
hired,
|
||||
work: worked,
|
||||
track: (profile.performance_score || 0) > 0 || (profile.attendance_score || 0) > 0 && worked,
|
||||
reputation: (profile.client_rating || 0) > 0 || (profile.supervisor_rating || 0) > 0 || (profile.krow_score || 0) >= 50,
|
||||
promoted: (profile.leadership_potential || 0) >= 40,
|
||||
leader: (profile.leadership_potential || 0) >= 70,
|
||||
train: (profile.leadership_potential || 0) >= 85 && (profile.completed_courses || []).length >= 3,
|
||||
repeat: false,
|
||||
};
|
||||
return isDone;
|
||||
}
|
||||
|
||||
export default function TalentLoop({ profile, applications = [] }) {
|
||||
const done = stepStatus(profile, applications);
|
||||
const reached = STEPS.filter(s => done[s.key]).length;
|
||||
const current = STEPS.find(s => !done[s.key]) || STEPS[STEPS.length - 1];
|
||||
const progress = Math.round((reached / (STEPS.length - 1)) * 100);
|
||||
|
||||
return (
|
||||
<section className="glass-card border border-white/60 rounded-2xl p-6 sm:p-8 shadow-sm">
|
||||
<div className="flex items-end justify-between flex-wrap gap-3 mb-1">
|
||||
<div>
|
||||
<span className="text-[11px] font-medium uppercase tracking-[0.18em] text-[#0A39DF]">The Talent Loop</span>
|
||||
<h2 className="text-[22px] font-heading font-bold text-[#0F172A] mt-1 tracking-tight">Build your reputation. Not just your resume.</h2>
|
||||
</div>
|
||||
<span className="text-[12px] text-[#64748B]">{reached}/{STEPS.length - 1} stages</span>
|
||||
</div>
|
||||
|
||||
<div className="h-1.5 bg-[#EEF2F7] rounded-full overflow-hidden mt-4 mb-7">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ duration: 0.8, ease: 'easeOut' }}
|
||||
className="h-full bg-[#0A39DF] rounded-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
{STEPS.map((s, i) => {
|
||||
const isDone = done[s.key];
|
||||
const isCurrent = s.key === current.key && !s.loop;
|
||||
const Icon = s.icon;
|
||||
return (
|
||||
<Link
|
||||
key={s.key}
|
||||
to={s.to}
|
||||
className={`group relative flex flex-col items-start gap-2 rounded-xl border p-3 transition-all ${
|
||||
isDone
|
||||
? 'bg-[#0A39DF] border-[#0A39DF] text-white'
|
||||
: isCurrent
|
||||
? 'bg-white border-[#0A39DF] ring-2 ring-[#0A39DF]/20'
|
||||
: 'bg-white border-[#E5E7EB] hover:border-[#0A39DF]/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<span className={`text-[10px] font-semibold ${isDone ? 'text-white/70' : 'text-[#94A3B8]'}`}>{String(i + 1).padStart(2, '0')}</span>
|
||||
{s.loop && <Repeat className={`w-3.5 h-3.5 ${isDone ? 'text-white/70' : 'text-[#94A3B8]'}`} />}
|
||||
</div>
|
||||
<Icon className={`w-5 h-5 ${isDone ? 'text-white' : isCurrent ? 'text-[#0A39DF]' : 'text-[#64748B]'}`} />
|
||||
<span className={`text-[12px] font-medium leading-tight ${isDone ? 'text-white' : isCurrent ? 'text-[#0A39DF]' : 'text-[#334155]'}`}>
|
||||
{s.label}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 mt-6 pt-5 border-t border-[#EEF2F7] flex-wrap">
|
||||
<div>
|
||||
<p className="text-[11px] text-[#94A3B8] font-medium">UP NEXT</p>
|
||||
<p className="text-[15px] font-semibold text-[#0F172A]">{current.label}</p>
|
||||
</div>
|
||||
<Link
|
||||
to={current.to}
|
||||
className="inline-flex items-center gap-1.5 text-[13px] font-semibold text-[#0A39DF] hover:gap-2.5 transition-all"
|
||||
>
|
||||
Continue <ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
246
src/components/krow/TalentPoolCard.jsx
Normal file
246
src/components/krow/TalentPoolCard.jsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import React, { useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Mail, Briefcase, MapPin, Star, BadgeCheck, Award, ChevronDown } from 'lucide-react';
|
||||
import { Image } from '@/components/ui/image';
|
||||
import EmployerInsights from '@/components/krow/talent/EmployerInsights';
|
||||
import { getScoreBand, toFICO } from '@/lib/talentHome';
|
||||
|
||||
const PORTRAIT_PHOTOS = [
|
||||
'photo-1500648767731-7d3514ad8b31',
|
||||
'photo-1531123897727-8f129e1688ce',
|
||||
'photo-1573496359142-b8d87734a5a2',
|
||||
'photo-1487412720507-e7ab37603c6f',
|
||||
'photo-1502685104226-ee3a97a2f2c8',
|
||||
'photo-1544005313-94ddf0286df2',
|
||||
'photo-1494790108377-be9c29b29330',
|
||||
'photo-1438761681033-6461ffad8d80',
|
||||
'photo-1517841905240-472988babdf9',
|
||||
'photo-1463453091185-61582044d556',
|
||||
];
|
||||
|
||||
function getPortraitSeed(name) {
|
||||
let h = 0;
|
||||
for (let i = 0; i < (name || '').length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
|
||||
return PORTRAIT_PHOTOS[h % PORTRAIT_PHOTOS.length];
|
||||
}
|
||||
|
||||
function CareerGauge({ fico }) {
|
||||
const MIN = 300;
|
||||
const MAX = 850;
|
||||
const value = Math.max(MIN, Math.min(MAX, fico || MIN));
|
||||
const frac = (value - MIN) / (MAX - MIN);
|
||||
const cx = 55;
|
||||
const cy = 58;
|
||||
const r = 46;
|
||||
const theta = (180 - frac * 180) * (Math.PI / 180);
|
||||
const xv = cx + r * Math.cos(theta);
|
||||
const yv = cy - r * Math.sin(theta);
|
||||
const color = value > MIN ? '#0A39DF' : '#CBD5E1';
|
||||
return (
|
||||
<div className="flex flex-col items-center shrink-0">
|
||||
<span className="text-[8px] font-semibold text-[#8A94A4] tracking-[0.12em] mb-0.5">CAREER SCORE</span>
|
||||
<svg width="110" height="64" viewBox="0 0 110 64" className="overflow-visible">
|
||||
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`} fill="none" stroke="#E1E4E8" strokeWidth="8" strokeLinecap="round" />
|
||||
{value > MIN && (
|
||||
<>
|
||||
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${xv} ${yv}`} fill="none" stroke={color} strokeWidth="8" strokeLinecap="round" />
|
||||
<circle cx={xv} cy={yv} r="5.5" fill={color} stroke="#fff" strokeWidth="1.5" />
|
||||
</>
|
||||
)}
|
||||
<text x={cx} y={cy - 6} textAnchor="middle" className="fill-[#0A39DF]" style={{ fontSize: 20, fontWeight: 700 }}>{value}</text>
|
||||
<text x={cx} y={cy + 9} textAnchor="middle" fill="#8A94A4" style={{ fontSize: 8, fontWeight: 600 }}>300-850</text>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const BADGE_LEVEL_STYLES = {
|
||||
bronze: 'bg-[#FEF3C7] text-[#92400E]',
|
||||
silver: 'bg-[#F3F4F6] text-[#4B5563]',
|
||||
gold: 'bg-[#FEF9E7] text-[#92400E]',
|
||||
platinum: 'bg-[#EFF6FF] text-[#1E40AF]',
|
||||
};
|
||||
|
||||
export default function TalentPoolCard({ profile, onContact, onView }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const score = profile.krow_score || 0;
|
||||
const band = getScoreBand(score);
|
||||
const fico = toFICO(score);
|
||||
const hasScore = score > 0;
|
||||
const skills = (profile.skills || []).slice(0, 4);
|
||||
const extraSkills = (profile.skills || []).length - skills.length;
|
||||
const badges = (profile.earned_badges || []).slice(0, 3);
|
||||
const extraBadges = (profile.earned_badges || []).length - badges.length;
|
||||
const capabilities = profile.capabilities || [];
|
||||
const endorsements = capabilities.filter((c) => c.status === 'verified' || c.status === 'pending').slice(0, 3);
|
||||
const extraEndorsements = capabilities.length - endorsements.length;
|
||||
|
||||
const toggle = (e) => {
|
||||
e.stopPropagation();
|
||||
setExpanded((v) => !v);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-white border border-[#E5E7EB] rounded-xl transition-all overflow-hidden',
|
||||
expanded ? 'shadow-sm border-[#0A39DF]' : 'hover:shadow-sm hover:border-[#0A39DF]'
|
||||
)}
|
||||
>
|
||||
{/* Clickable header → opens full profile modal */}
|
||||
<div
|
||||
className="p-4 cursor-pointer"
|
||||
onClick={() => onView?.(profile)}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Avatar */}
|
||||
<div className="w-14 h-14 rounded-full bg-[#E5E7EB] overflow-hidden shrink-0">
|
||||
<Image
|
||||
src={profile.selfie_url || `https://images.unsplash.com/${getPortraitSeed(profile.full_name)}?w=140&h=140&fit=crop&crop=faces`}
|
||||
alt={profile.full_name}
|
||||
className="w-full h-full object-cover"
|
||||
fittingType="fill"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="font-semibold text-[#111827] text-[15px] truncate">{profile.full_name}</h3>
|
||||
<span
|
||||
className="px-2 py-0.5 rounded-full text-[10px] font-bold"
|
||||
style={hasScore
|
||||
? { backgroundColor: band.color, color: '#fff' }
|
||||
: { backgroundColor: '#E5E7EB', color: '#6B7280' }}
|
||||
>
|
||||
{hasScore ? band.label : 'No score yet'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Career Score gauge */}
|
||||
<CareerGauge fico={fico} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded details */}
|
||||
{expanded && (
|
||||
<div className="px-4 pb-4 space-y-3">
|
||||
{/* Quick meta line */}
|
||||
<div className="flex items-center gap-3 text-[11px] text-[#6B7280] flex-wrap">
|
||||
{profile.desired_position && (
|
||||
<span className="inline-flex items-center gap-1 font-medium text-[#0A39DF]">
|
||||
<Briefcase className="w-3 h-3" />
|
||||
{profile.desired_position}
|
||||
</span>
|
||||
)}
|
||||
{profile.experience_years > 0 && (
|
||||
<span>{profile.experience_years}yr exp</span>
|
||||
)}
|
||||
{profile.location && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MapPin className="w-3 h-3" />
|
||||
{profile.location}
|
||||
</span>
|
||||
)}
|
||||
{(profile.client_rating > 0 || profile.supervisor_rating > 0) && (
|
||||
<span className="inline-flex items-center gap-1 text-[#F59E0B] font-medium">
|
||||
<Star className="w-3 h-3 fill-[#F59E0B]" />
|
||||
{((profile.client_rating + profile.supervisor_rating) / 2).toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{skills.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{skills.map((s) => (
|
||||
<span key={s} className="px-2 py-0.5 rounded-full bg-[#EFF6FF] text-[#0A39DF] text-[10px] font-medium">{s}</span>
|
||||
))}
|
||||
{extraSkills > 0 && (
|
||||
<span className="px-2 py-0.5 rounded-full bg-[#F3F4F6] text-[#6B7280] text-[10px] font-medium">+{extraSkills}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(profile.availability || []).length > 0 && (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{(profile.availability || []).slice(0, 3).map((a) => (
|
||||
<span key={a} className="px-2 py-0.5 rounded-full bg-[#F3F4F6] text-[#374151] text-[10px]">{a}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Badges */}
|
||||
{badges.length > 0 && (
|
||||
<div className="pt-3 border-t border-[#F3F4F6]">
|
||||
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-2 flex items-center gap-1.5">
|
||||
<Award className="w-3 h-3" /> BADGES
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{badges.map((b, i) => (
|
||||
<span key={i} className={cn('inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium', BADGE_LEVEL_STYLES[b.level] || BADGE_LEVEL_STYLES.bronze)}>
|
||||
<Award className="w-2.5 h-2.5" />
|
||||
{b.name}
|
||||
</span>
|
||||
))}
|
||||
{extraBadges > 0 && (
|
||||
<span className="px-2 py-0.5 rounded-full bg-[#F3F4F6] text-[#6B7280] text-[10px] font-medium">+{extraBadges}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Endorsements */}
|
||||
{endorsements.length > 0 && (
|
||||
<div className="pt-3 border-t border-[#F3F4F6]">
|
||||
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-2 flex items-center gap-1.5">
|
||||
<BadgeCheck className="w-3 h-3" /> ENDORSEMENTS
|
||||
<span className="text-[#D1D5DB] font-semibold">({capabilities.length})</span>
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{endorsements.map((e, i) => (
|
||||
<span key={i} className={cn('inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium border', e.status === 'verified' ? 'bg-[#EFF6FF] text-[#0A39DF] border-[#DBEAFE]' : 'bg-[#F3F4F6] text-[#6B7280] border-[#E5E7EB]')}>
|
||||
<BadgeCheck className="w-2.5 h-2.5" />
|
||||
{e.skill}
|
||||
{e.level && <span className="opacity-70">· {e.level}</span>}
|
||||
</span>
|
||||
))}
|
||||
{extraEndorsements > 0 && (
|
||||
<span className="px-2 py-0.5 rounded-full bg-[#F3F4F6] text-[#6B7280] text-[10px] font-medium">+{extraEndorsements}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Employer insights */}
|
||||
<div className="pt-3 border-t border-[#F3F4F6]">
|
||||
<EmployerInsights profile={profile} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer: email + contact + expand toggle */}
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-t border-[#F3F4F6]">
|
||||
<span className="text-[11px] text-[#9CA3AF] flex-1 truncate">{profile.email || 'No email'}</span>
|
||||
{onContact && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onContact(profile); }}
|
||||
className="inline-flex items-center gap-1.5 bg-[#0A39DF] hover:bg-[#082EB4] text-white text-[12px] font-medium px-3 py-1.5 rounded-lg transition-colors whitespace-nowrap"
|
||||
>
|
||||
<Mail className="w-3.5 h-3.5" />
|
||||
Contact
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="inline-flex items-center gap-1 border border-[#E5E7EB] hover:bg-[#F9FAFB] text-[#374151] text-[12px] font-medium px-2.5 py-1.5 rounded-lg transition-colors"
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<ChevronDown className={cn('w-3.5 h-3.5 transition-transform', expanded && 'rotate-180')} />
|
||||
{expanded ? 'Hide' : 'Details'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
src/components/krow/TrustLayer.jsx
Normal file
81
src/components/krow/TrustLayer.jsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
import { ShieldCheck, Check, Dot } from 'lucide-react';
|
||||
|
||||
// KROW verifies everything. This is the trust layer — what's proven vs. claimed.
|
||||
const ITEMS = [
|
||||
{ key: 'identity', label: 'Identity', hint: 'Selfie + name' },
|
||||
{ key: 'certifications', label: 'Certifications', hint: 'Uploaded docs' },
|
||||
{ key: 'skills', label: 'Skills', hint: 'Declared' },
|
||||
{ key: 'work', label: 'Work History', hint: 'Experience' },
|
||||
{ key: 'interview', label: 'AI Interview', hint: 'Passed' },
|
||||
{ key: 'attendance', label: 'Attendance', hint: 'Shifts' },
|
||||
{ key: 'reputation', label: 'Reputation', hint: 'Ratings' },
|
||||
{ key: 'performance', label: 'Performance', hint: 'Score' },
|
||||
{ key: 'learning', label: 'Learning', hint: 'Courses' },
|
||||
{ key: 'promotions', label: 'Promotions', hint: 'Growth' },
|
||||
{ key: 'references', label: 'References', hint: 'Verified' },
|
||||
];
|
||||
|
||||
function statusOf(profile, key) {
|
||||
const has = (v) => v !== undefined && v !== null && v !== '' && !(Array.isArray(v) && v.length === 0) && v !== 0;
|
||||
const map = {
|
||||
identity: has(profile.selfie_url) && has(profile.full_name),
|
||||
certifications: (profile.certifications || []).length > 0,
|
||||
skills: (profile.skills || []).length > 0,
|
||||
work: (profile.experience || []).length > 0,
|
||||
interview: (profile.ai_interview_score || 0) > 0,
|
||||
attendance: (profile.attendance_score || 0) > 0 && (profile.experience || []).length > 0,
|
||||
reputation: (profile.client_rating || 0) > 0 || (profile.supervisor_rating || 0) > 0,
|
||||
performance: (profile.performance_score || 0) > 0,
|
||||
learning: (profile.completed_courses || []).length > 0,
|
||||
promotions: (profile.leadership_potential || 0) >= 40,
|
||||
references: (profile.supervisor_rating || 0) > 0,
|
||||
};
|
||||
return map[key] ? 'verified' : 'pending';
|
||||
}
|
||||
|
||||
export default function TrustLayer({ profile }) {
|
||||
const verified = ITEMS.filter((i) => statusOf(profile, i.key) === 'verified').length;
|
||||
|
||||
return (
|
||||
<section className="glass-card border border-white/60 rounded-2xl p-6 sm:p-8 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap mb-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-[#0A39DF] flex items-center justify-center shrink-0">
|
||||
<ShieldCheck className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[11px] font-medium uppercase tracking-[0.18em] text-[#94A3B8]">Trust Layer</span>
|
||||
<h2 className="text-[20px] font-heading font-bold text-[#0F172A] tracking-tight">Verified, not claimed.</h2>
|
||||
<p className="text-[13px] text-[#64748B] mt-0.5">Everything KROW has proven about you. Impossible to fake.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-[28px] font-heading font-bold text-[#0A39DF] leading-none">{verified}<span className="text-[#94A3B8] text-[18px]">/{ITEMS.length}</span></div>
|
||||
<div className="text-[11px] text-[#94A3B8] font-medium mt-1">verified</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-2.5">
|
||||
{ITEMS.map((item) => {
|
||||
const st = statusOf(profile, item.key);
|
||||
return (
|
||||
<div key={item.key} className="flex items-center gap-2.5 py-1.5 border-b border-[#F1F5F9]">
|
||||
{st === 'verified' ? (
|
||||
<span className="w-5 h-5 rounded-full bg-[#0A39DF] flex items-center justify-center shrink-0">
|
||||
<Check className="w-3 h-3 text-white" />
|
||||
</span>
|
||||
) : (
|
||||
<span className="w-5 h-5 rounded-full bg-[#FEF3C7] flex items-center justify-center shrink-0">
|
||||
<Dot className="w-4 h-4 text-[#D97706]" />
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-[13px] font-medium ${st === 'verified' ? 'text-[#0F172A]' : 'text-[#64748B]'}`}>{item.label}</span>
|
||||
<span className="text-[11px] text-[#94A3B8] ml-auto">{st === 'verified' ? 'Verified' : 'Pending'}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
54
src/components/krow/UniversityCourseCard.jsx
Normal file
54
src/components/krow/UniversityCourseCard.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Lock, ShieldCheck, MessageSquare, Camera, Video, ScanLine, Zap, Clock, ArrowRight } 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' };
|
||||
|
||||
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';
|
||||
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
{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>
|
||||
)}
|
||||
</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" />
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
188
src/components/krow/WorkforceReputation.jsx
Normal file
188
src/components/krow/WorkforceReputation.jsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import React from 'react';
|
||||
import { Shield, Star, TrendingUp } from 'lucide-react';
|
||||
import { getScoreBand } from '@/lib/talentHome';
|
||||
import ReferencesEndorsements from '@/components/krow/ReferencesEndorsements';
|
||||
|
||||
const R = 44;
|
||||
const STROKE = 8;
|
||||
const CIRC = 2 * Math.PI * R;
|
||||
const FICO_MIN = 300;
|
||||
const FICO_MAX = 850;
|
||||
|
||||
function careerScoreFromApp(app) {
|
||||
const s = Math.max(0, Math.min(100, Math.round(app?.ai_score || 0)));
|
||||
return 300 + Math.round((s / 100) * 550);
|
||||
}
|
||||
|
||||
function marketRangeFromApp(app) {
|
||||
const s = Math.max(0, Math.min(100, Math.round(app?.ai_score || 0)));
|
||||
const uplift = Math.max(0, Math.round((s - 70) / 5));
|
||||
const low = 20 + uplift;
|
||||
return `$${low}-$${low + 4}/hr`;
|
||||
}
|
||||
|
||||
function nextTierFromApp(app) {
|
||||
const s = Math.round(app?.ai_score || 0);
|
||||
const yrs = app?.years_experience || 0;
|
||||
if (s >= 85 || yrs >= 8) return 'Skilled';
|
||||
if (s >= 60 || yrs >= 3) return 'Cross-Trained';
|
||||
return 'Beginner';
|
||||
}
|
||||
|
||||
function CareerScoreMini({ application }) {
|
||||
const score = careerScoreFromApp(application);
|
||||
const pct = Math.max(0, Math.min(1, (score - FICO_MIN) / (FICO_MAX - FICO_MIN)));
|
||||
const dash = pct * CIRC;
|
||||
const band = getScoreBand(Math.round(application?.ai_score || 0));
|
||||
return (
|
||||
<div className="flex items-center gap-5 bg-white/70 rounded-xl border border-[#E5E7EB] px-4 py-3">
|
||||
<div className="flex flex-col items-center shrink-0">
|
||||
<div className="relative" style={{ width: R * 2 + STROKE, height: R * 2 + STROKE }}>
|
||||
<svg width={R * 2 + STROKE} height={R * 2 + STROKE} className="-rotate-90">
|
||||
<circle cx={R + STROKE / 2} cy={R + STROKE / 2} r={R} fill="none" stroke="#EFF6FF" strokeWidth={STROKE} />
|
||||
<circle
|
||||
cx={R + STROKE / 2}
|
||||
cy={R + STROKE / 2}
|
||||
r={R}
|
||||
fill="none"
|
||||
stroke="#0A39DF"
|
||||
strokeWidth={STROKE}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${dash} ${CIRC}`}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-[8px] uppercase tracking-wide text-[#94A3B8] font-medium">Career Score</span>
|
||||
<span className="text-[22px] font-bold text-[#0A39DF] leading-none">{score}</span>
|
||||
<span className="text-[8px] text-[#94A3B8] font-medium">300-850</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className="mt-2 inline-flex items-center justify-center text-[11px] font-bold text-white px-3 py-1 rounded-full w-full"
|
||||
style={{ backgroundColor: band.color }}
|
||||
>
|
||||
{band.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 space-y-3">
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wide text-[#9CA3AF] font-medium">Potential Market Value</p>
|
||||
<p className="text-[16px] font-bold text-[#111827]">{marketRangeFromApp(application)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wide text-[#9CA3AF] font-medium">Next Promotion</p>
|
||||
<p className="text-[14px] font-semibold text-[#0F172A]">{nextTierFromApp(application)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const COMM_LABELS = [
|
||||
{ min: 85, label: 'Excellent' },
|
||||
{ min: 70, label: 'Good' },
|
||||
{ min: 55, label: 'Average' },
|
||||
{ min: 0, label: 'Needs Work' },
|
||||
];
|
||||
|
||||
const commLabel = (score) => {
|
||||
const v = score || 0;
|
||||
return (COMM_LABELS.find((c) => v >= c.min) || COMM_LABELS[COMM_LABELS.length - 1]).label;
|
||||
};
|
||||
|
||||
const COMPANY_COLORS = [
|
||||
'bg-[#0A39DF]',
|
||||
'bg-[#333F48]',
|
||||
'bg-[#082EB4]',
|
||||
];
|
||||
|
||||
const colorFor = (name) => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
return COMPANY_COLORS[Math.abs(hash) % COMPANY_COLORS.length];
|
||||
};
|
||||
|
||||
const initialsFor = (name) => {
|
||||
const parts = name.trim().split(/\s+/);
|
||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||
return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||
};
|
||||
|
||||
function StatTile({ label, value, suffix, accent }) {
|
||||
const text = value != null && value !== '' ? value : '—';
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-1 px-2 py-3 rounded-xl bg-white/70 border border-[#E5E7EB]">
|
||||
<div className="text-[18px] font-bold leading-none" style={{ color: accent }}>
|
||||
{text}
|
||||
{suffix && <span className="text-[12px] font-semibold ml-0.5">{suffix}</span>}
|
||||
</div>
|
||||
<div className="text-[10px] text-[#6B7280] tracking-wide font-medium">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WorkforceReputation({ application }) {
|
||||
const breakdown = application?.score_breakdown || {};
|
||||
const companies = application?.companies_worked || [];
|
||||
const clientRating = application?.client_rating || 0;
|
||||
|
||||
const reliability = breakdown.reliability;
|
||||
const attendance = breakdown.attendance_expectations;
|
||||
const leadership = breakdown.leadership_expectations;
|
||||
const communication = breakdown.communication_style;
|
||||
const commText = communication != null ? commLabel(communication) : null;
|
||||
|
||||
const hasScores = [reliability, attendance, leadership, communication].some((v) => v != null);
|
||||
const hasData = hasScores || companies.length > 0 || clientRating > 0 || (application?.ai_score || 0) > 0;
|
||||
if (!hasData) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-[#E5E7EB] bg-[#F8FAFC] p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-7 h-7 rounded-lg bg-[#0A39DF] flex items-center justify-center">
|
||||
<Shield className="w-4 h-4 text-white" strokeWidth={2.5} />
|
||||
</div>
|
||||
<h4 className="text-[12px] font-bold text-[#111827] tracking-wide flex items-center gap-1.5">
|
||||
WORKFORCE REPUTATION
|
||||
<TrendingUp className="w-3.5 h-3.5 text-[#0A39DF]" />
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<CareerScoreMini application={application} />
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2">
|
||||
<StatTile label="Reliability" value={reliability != null ? reliability : null} suffix="%" accent="#0A39DF" />
|
||||
<StatTile label="Attendance" value={attendance != null ? attendance : null} suffix="%" accent="#059669" />
|
||||
<StatTile label="Leadership" value={leadership != null ? leadership : null} suffix="%" accent="#0A39DF" />
|
||||
<StatTile label="Communication" value={commText} accent="#F59E0B" />
|
||||
<div className="flex flex-col items-center justify-center gap-1 px-2 py-3 rounded-xl bg-white/70 border border-[#E5E7EB]">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Star className="w-3.5 h-3.5 text-[#F9E547] fill-[#F9E547]" />
|
||||
<span className="text-[18px] font-bold leading-none text-[#111827]">
|
||||
{clientRating > 0 ? clientRating.toFixed(1) : '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-[#6B7280] tracking-wide font-medium">Client Rating</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{companies.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-[#E5E7EB]/80">
|
||||
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-2">WORKED</p>
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
{companies.map((c, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<div className={`w-7 h-7 rounded-lg ${colorFor(c)} flex items-center justify-center text-white text-[11px] font-bold shadow-sm`}>
|
||||
{initialsFor(c)}
|
||||
</div>
|
||||
<span className="text-[12px] font-semibold text-[#374151]">{c}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ReferencesEndorsements application={application} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
src/components/krow/proving/CapabilityTimeline.jsx
Normal file
38
src/components/krow/proving/CapabilityTimeline.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { ShieldCheck, Plus } from 'lucide-react';
|
||||
|
||||
// The transcript replacement — verified capabilities as visual chips,
|
||||
// each backed by evidence. Empty until the worker proves something.
|
||||
export default function CapabilityTimeline({ profile, compact }) {
|
||||
const caps = (profile?.capabilities || []).filter((c) => c.status === 'verified' || c.status === 'pending');
|
||||
return (
|
||||
<section className={compact ? 'py-2' : 'py-8'}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-[15px] font-semibold text-[#0F172A]">Proven skills</h2>
|
||||
<span className="text-[11px] text-[#94A3B8]">{caps.length} verified</span>
|
||||
</div>
|
||||
{!caps.length ? (
|
||||
<div className="rounded-2xl border border-dashed border-[#E2E8F0] bg-[#F8FAFC] p-6 text-center">
|
||||
<div className="w-10 h-10 rounded-full bg-white border border-[#E2E8F0] flex items-center justify-center mx-auto mb-2">
|
||||
<Plus className="w-4 h-4 text-[#94A3B8]" />
|
||||
</div>
|
||||
<p className="text-[12px] text-[#64748B]">No verified skills yet — prove a challenge to start your chain.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{caps.map((c, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1.5 rounded-full bg-[#F0FDF4] border border-[#BBF7D0] px-3 py-1.5">
|
||||
<ShieldCheck className="w-3.5 h-3.5 text-[#16A34A]" />
|
||||
<span className="text-[12px] font-medium text-[#166534]">{c.skill}</span>
|
||||
{c.verified_date && (
|
||||
<span className="text-[10px] text-[#16A34A]/70">
|
||||
{new Date(c.verified_date).toLocaleDateString('en-US', { month: 'short' })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
209
src/components/krow/proving/ChallengeRunner.jsx
Normal file
209
src/components/krow/proving/ChallengeRunner.jsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Lock, Video, Camera, MessageSquare, ScanLine, Loader2, XCircle, CheckCircle2, ShieldCheck, Award, Eye, ArrowRight } from 'lucide-react';
|
||||
import { isUnlocked } from '@/lib/provingGround';
|
||||
import { useSubmitChallenge, useVerifyEvidence } from '@/lib/krowHooks';
|
||||
import RoleplayChallenge from './RoleplayChallenge';
|
||||
import MediaChallenge from './MediaChallenge';
|
||||
import VideoRecorder from './VideoRecorder';
|
||||
import HazardIdentifier from './HazardIdentifier';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
|
||||
const STEPS = ['Learn', 'Demonstrate', 'Verify', 'Proof'];
|
||||
const TYPE_ICON = { roleplay: MessageSquare, photo: Camera, video: Video, photo_identify: ScanLine };
|
||||
const TYPE_LABEL = { roleplay: 'Chat with Owliver', photo: 'Photo Proof', video: 'Record Yourself', photo_identify: 'Identify Hazards' };
|
||||
|
||||
function Stepper({ active }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 mb-6">
|
||||
{STEPS.map((s, i) => {
|
||||
const done = i < active;
|
||||
const cur = i === active;
|
||||
return (
|
||||
<React.Fragment key={s}>
|
||||
<div className={`flex items-center gap-1.5 ${cur || done ? '' : 'opacity-50'}`}>
|
||||
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-[11px] font-bold ${done ? 'bg-[#0A39DF] text-white' : cur ? 'bg-[#0A39DF] text-white ring-4 ring-[#EFF6FF]' : 'bg-[#F1F5F9] text-[#94A3B8]'}`}>{done ? '✓' : i + 1}</div>
|
||||
<span className={`text-[12px] font-medium ${cur || done ? 'text-[#0F172A]' : 'text-[#94A3B8]'}`}>{s}</span>
|
||||
</div>
|
||||
{i < STEPS.length - 1 && <div className={`flex-1 h-px ${done ? 'bg-[#0A39DF]' : 'bg-[#E5E7EB]'}`} />}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ChallengeRunner({ course, profile, onPassed }) {
|
||||
const [step, setStep] = useState(0);
|
||||
const [evaluating, setEvaluating] = useState(false);
|
||||
const [result, setResult] = useState(null);
|
||||
const [evidence, setEvidence] = useState(null);
|
||||
const [supervisorVerified, setSupervisorVerified] = useState(false);
|
||||
const submit = useSubmitChallenge();
|
||||
const verify = useVerifyEvidence();
|
||||
const ch = course?.challenge || {};
|
||||
const type = ch.type || 'roleplay';
|
||||
const Icon = TYPE_ICON[type] || MessageSquare;
|
||||
const lock = isUnlocked(course, profile);
|
||||
|
||||
const handleSubmit = async (payload) => {
|
||||
setEvaluating(true);
|
||||
let mediaUrl = null, transcript = '', identified = null;
|
||||
if (type === 'roleplay') transcript = payload;
|
||||
else if (type === 'photo_identify') { mediaUrl = ch.image_url; identified = payload; }
|
||||
else mediaUrl = payload;
|
||||
try {
|
||||
const res = await submit.mutateAsync({ profile, course, type, mediaUrl, transcript, identified });
|
||||
setResult(res.result);
|
||||
setEvidence(res.evidence);
|
||||
setSupervisorVerified(false);
|
||||
setStep(2);
|
||||
if (res.passed && onPassed) onPassed();
|
||||
} catch {
|
||||
setStep(1);
|
||||
} finally {
|
||||
setEvaluating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const doVerify = async () => {
|
||||
if (!evidence) return;
|
||||
try {
|
||||
await verify.mutateAsync({ evidenceId: evidence.id });
|
||||
setSupervisorVerified(true);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const passed = evidence?.ai_verdict === 'verified';
|
||||
|
||||
// 0 — Learn
|
||||
if (step === 0) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-[#E5E7EB] bg-white p-6">
|
||||
<Stepper active={0} />
|
||||
{!lock.unlocked ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-[#0F172A] mb-2"><Lock className="w-4 h-4" /><span className="font-semibold text-[15px]">Locked — earn this one</span></div>
|
||||
<p className="text-[13px] text-[#64748B] mb-3">Real work unlocks learning. Complete these first:</p>
|
||||
<ul className="space-y-1.5">
|
||||
{lock.reasons.map((r, i) => (<li key={i} className="text-[13px] text-[#475569] flex items-center gap-2"><span className="w-1.5 h-1.5 rounded-full bg-[#94A3B8]" />{r}</li>))}
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-11 h-11 rounded-2xl bg-[#EFF6FF] flex items-center justify-center"><Icon className="w-5 h-5 text-[#0A39DF]" /></div>
|
||||
<div>
|
||||
<p className="text-[11px] font-medium uppercase tracking-wide text-[#94A3B8]">Demonstration</p>
|
||||
<p className="text-[16px] font-semibold text-[#0F172A]">{TYPE_LABEL[type]}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[14px] text-[#374151] leading-relaxed mb-4">{ch.prompt || course?.description}</p>
|
||||
<button onClick={() => setStep(1)} className="w-full h-11 rounded-xl bg-[#0A39DF] hover:bg-[#082EB4] text-white text-[14px] font-semibold flex items-center justify-center gap-2">Show me <ArrowRight className="w-4 h-4" /></button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Evaluating
|
||||
if (evaluating) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-[#E5E7EB] bg-white p-10 flex flex-col items-center">
|
||||
<Stepper active={1} />
|
||||
<Loader2 className="w-8 h-8 animate-spin text-[#0A39DF] mb-3" />
|
||||
<p className="text-[14px] font-medium text-[#0F172A]">Owliver is evaluating your proof…</p>
|
||||
<p className="text-[12px] text-[#64748B] mt-1">Measuring whether you can actually perform.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 1 — Demonstrate
|
||||
if (step === 1) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-[#E5E7EB] bg-white p-6">
|
||||
<Stepper active={1} />
|
||||
<div className="rounded-xl bg-[#F8FAFC] border border-[#F1F5F9] p-3 mb-4 flex items-start gap-2.5">
|
||||
<OwliverAvatar className="w-7 h-7 shrink-0" rounded="rounded-full" />
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-[#0A39DF] mb-0.5">Owliver</p>
|
||||
<p className="text-[13px] text-[#374151]">{ch.prompt || course?.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
{type === 'roleplay' && <RoleplayChallenge course={course} onSubmit={handleSubmit} submitting={submit.isPending} />}
|
||||
{type === 'photo' && <MediaChallenge course={course} onSubmit={handleSubmit} submitting={submit.isPending} />}
|
||||
{type === 'video' && <VideoRecorder onSubmit={handleSubmit} submitting={submit.isPending} />}
|
||||
{type === 'photo_identify' && <HazardIdentifier course={course} onSubmit={handleSubmit} submitting={submit.isPending} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 2 — Verify
|
||||
if (step === 2) {
|
||||
const rubric = result?.rubric || {};
|
||||
return (
|
||||
<div className="rounded-2xl border border-[#E5E7EB] bg-white p-6">
|
||||
<Stepper active={2} />
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
{passed ? <CheckCircle2 className="w-9 h-9 text-[#16A34A]" /> : <XCircle className="w-9 h-9 text-[#EF4444]" />}
|
||||
<div>
|
||||
<p className="text-[17px] font-semibold text-[#0F172A]">{passed ? 'Verified by Owliver' : 'Needs work'}</p>
|
||||
<p className="text-[12px] text-[#64748B]">AI confidence: {Math.round(evidence?.ai_score || 0)}/100</p>
|
||||
</div>
|
||||
</div>
|
||||
{result?.feedback && <p className="text-[13px] text-[#374151] leading-relaxed mb-4">{result.feedback}</p>}
|
||||
{Object.keys(rubric).length > 0 && (
|
||||
<div className="space-y-2 mb-4">
|
||||
{Object.entries(rubric).map(([k, v]) => (
|
||||
<div key={k}>
|
||||
<div className="flex justify-between text-[12px] mb-1"><span className="text-[#475569] capitalize">{k.replace(/_/g, ' ')}</span><span className="font-semibold text-[#0F172A]">{Math.round(v)}/100</span></div>
|
||||
<div className="h-1.5 rounded-full bg-[#F1F5F9] overflow-hidden"><div className="h-full bg-[#0A39DF]" style={{ width: `${Math.min(100, Math.round(v))}%` }} /></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className={`rounded-xl border p-4 flex items-center gap-3 mb-3 ${supervisorVerified ? 'bg-[#F0FDF4] border-[#BBF7D0]' : 'bg-[#FFFBEB] border-[#FDE68A]'}`}>
|
||||
<ShieldCheck className={`w-5 h-5 ${supervisorVerified ? 'text-[#16A34A]' : 'text-[#D97706]'}`} />
|
||||
<div className="flex-1">
|
||||
<p className="text-[13px] font-medium text-[#0F172A]">{supervisorVerified ? 'Supervisor verified' : 'Supervisor verification'}</p>
|
||||
<p className="text-[11px] text-[#64748B]">{supervisorVerified ? 'A supervisor confirmed this demonstration in person.' : 'A supervisor can confirm this demonstration on the floor.'}</p>
|
||||
</div>
|
||||
{!supervisorVerified ? (
|
||||
<button onClick={doVerify} disabled={verify.isPending} className="h-8 px-3 rounded-lg bg-[#0F172A] text-white text-[12px] font-semibold disabled:opacity-50">{verify.isPending ? '…' : 'Verify'}</button>
|
||||
) : (
|
||||
<CheckCircle2 className="w-5 h-5 text-[#16A34A]" />
|
||||
)}
|
||||
</div>
|
||||
{passed ? (
|
||||
<button onClick={() => setStep(3)} className="w-full h-11 rounded-xl bg-[#0A39DF] text-white text-[14px] font-semibold flex items-center justify-center gap-2">See your proof <ArrowRight className="w-4 h-4" /></button>
|
||||
) : (
|
||||
<button onClick={() => setStep(1)} className="w-full h-10 rounded-xl border border-[#E5E7EB] text-[13px] font-semibold text-[#0F172A] hover:bg-[#F8FAFC]">Try the demonstration again</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 3 — Proof
|
||||
return (
|
||||
<div className="rounded-2xl border border-[#E5E7EB] bg-white p-6">
|
||||
<Stepper active={3} />
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-11 h-11 rounded-2xl bg-[#F9E547] flex items-center justify-center"><Award className="w-5 h-5 text-[#92400E]" /></div>
|
||||
<div>
|
||||
<p className="text-[16px] font-semibold text-[#0F172A]">Proof stored</p>
|
||||
<p className="text-[12px] text-[#64748B]">Capability: {course.proof_skill || course.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-[#F8FAFC] border border-[#F1F5F9] p-4 space-y-2 mb-4">
|
||||
<div className="flex justify-between text-[12px]"><span className="text-[#64748B]">AI verdict</span><span className="font-semibold text-[#16A34A] capitalize">{evidence?.ai_verdict}</span></div>
|
||||
<div className="flex justify-between text-[12px]"><span className="text-[#64748B]">Score</span><span className="font-semibold text-[#0F172A]">{Math.round(evidence?.ai_score || 0)}/100</span></div>
|
||||
<div className="flex justify-between text-[12px]"><span className="text-[#64748B]">Supervisor</span><span className="font-semibold text-[#0F172A]">{supervisorVerified ? 'Verified' : 'Pending'}</span></div>
|
||||
<div className="flex justify-between text-[12px]"><span className="text-[#64748B]">Proof type</span><span className="font-semibold text-[#0F172A]">{TYPE_LABEL[type]}</span></div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-[#EFF6FF] border border-[#BFDBFE] p-4 flex items-start gap-2.5 mb-4">
|
||||
<Eye className="w-4 h-4 text-[#0A39DF] mt-0.5 shrink-0" />
|
||||
<p className="text-[12px] text-[#1E40AF]">Employers can now see this proof in your Career Wallet. Every skill is backed by evidence.</p>
|
||||
</div>
|
||||
<button onClick={() => setStep(0)} className="w-full h-10 rounded-xl border border-[#E5E7EB] text-[13px] font-semibold text-[#0F172A] hover:bg-[#F8FAFC]">Back to start</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
src/components/krow/proving/HazardIdentifier.jsx
Normal file
82
src/components/krow/proving/HazardIdentifier.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Trash2, Plus } from 'lucide-react';
|
||||
import { Image } from '@/components/ui/image';
|
||||
|
||||
const PRESET_TAGS = [
|
||||
'Raw meat next to ready-to-eat food',
|
||||
'Same board for raw + cooked',
|
||||
'Soiled towel on food surface',
|
||||
'Food in the temperature danger zone',
|
||||
'Unwashed hands',
|
||||
'Improper storage',
|
||||
'Cross-contact allergen',
|
||||
];
|
||||
|
||||
// Interactive hazard identification — tap a kitchen photo to mark hazards.
|
||||
export default function HazardIdentifier({ course, onSubmit, submitting }) {
|
||||
const imageUrl = course?.challenge?.image_url;
|
||||
const [markers, setMarkers] = useState([]);
|
||||
const [pending, setPending] = useState(null);
|
||||
const [customLabel, setCustomLabel] = useState('');
|
||||
|
||||
const addMarker = (e) => {
|
||||
if (submitting || pending) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) / rect.width;
|
||||
const y = (e.clientY - rect.top) / rect.height;
|
||||
setPending({ x, y });
|
||||
};
|
||||
|
||||
const confirm = (label) => {
|
||||
if (!pending) return;
|
||||
setMarkers([...markers, { ...pending, label }]);
|
||||
setPending(null);
|
||||
setCustomLabel('');
|
||||
};
|
||||
|
||||
const remove = (i) => setMarkers(markers.filter((_, j) => j !== i));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[13px] text-[#64748B] mb-3">Tap anywhere on the kitchen photo to mark a hazard. Label each one, then submit for AI evaluation.</p>
|
||||
<div className="relative rounded-2xl overflow-hidden border border-[#E5E7EB] mb-3 select-none cursor-crosshair" onClick={addMarker}>
|
||||
{imageUrl ? (
|
||||
<Image src={imageUrl} alt="Kitchen scene" className="w-full max-h-80 object-cover" fittingType="fill" />
|
||||
) : (
|
||||
<div className="w-full h-64 bg-[#F1F5F9] flex items-center justify-center text-[13px] text-[#94A3B8]">No scene image configured</div>
|
||||
)}
|
||||
{markers.map((m, i) => (
|
||||
<div key={i} className="absolute -translate-x-1/2 -translate-y-1/2 group" style={{ left: `${m.x * 100}%`, top: `${m.y * 100}%` }} onClick={(e) => e.stopPropagation()}>
|
||||
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-red-500 text-white text-[11px] font-bold shadow ring-2 ring-white">{i + 1}</span>
|
||||
<span className="absolute left-1/2 -translate-x-1/2 top-7 whitespace-nowrap text-[10px] bg-white/95 rounded px-1.5 py-0.5 shadow text-[#0F172A]">{m.label}</span>
|
||||
<button onClick={() => remove(i)} className="absolute -top-1.5 -right-1.5 w-4 h-4 rounded-full bg-white text-red-500 shadow flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"><Trash2 className="w-2.5 h-2.5" /></button>
|
||||
</div>
|
||||
))}
|
||||
{pending && (
|
||||
<div className="absolute -translate-x-1/2 -translate-y-1/2 pointer-events-none" style={{ left: `${pending.x * 100}%`, top: `${pending.y * 100}%` }}>
|
||||
<span className="block w-6 h-6 rounded-full bg-[#0A39DF] ring-4 ring-white animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pending && (
|
||||
<div className="rounded-xl border border-[#0A39DF] bg-[#EFF6FF] p-3 mb-3">
|
||||
<p className="text-[12px] font-medium text-[#1E40AF] mb-2">Label this hazard:</p>
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{PRESET_TAGS.map((t) => (
|
||||
<button key={t} onClick={() => confirm(t)} className="text-[11px] px-2.5 py-1 rounded-full bg-white border border-[#E5E7EB] text-[#374151] hover:border-[#0A39DF]">{t}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input value={customLabel} onChange={(e) => setCustomLabel(e.target.value)} placeholder="Or type your own…" className="flex-1 h-8 px-2.5 text-[12px] rounded-lg border border-[#E5E7EB] focus:border-[#0A39DF] focus:outline-none" />
|
||||
<button onClick={() => customLabel.trim() && confirm(customLabel.trim())} className="h-8 px-3 rounded-lg bg-[#0A39DF] text-white text-[12px] font-medium flex items-center gap-1"><Plus className="w-3.5 h-3.5" /> Add</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button onClick={() => onSubmit(markers)} disabled={markers.length === 0 || submitting || !!pending} className="w-full h-10 rounded-xl bg-[#0F172A] text-white text-[13px] font-semibold disabled:opacity-40">
|
||||
Submit {markers.length} hazard{markers.length !== 1 ? 's' : ''} for AI evaluation
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
src/components/krow/proving/MediaChallenge.jsx
Normal file
69
src/components/krow/proving/MediaChallenge.jsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Loader2, Camera } from 'lucide-react';
|
||||
import { base44 } from '@/api/base44Client';
|
||||
import { Image } from '@/components/ui/image';
|
||||
|
||||
// Photo or video proof — capture/upload, then submit for AI evaluation.
|
||||
export default function MediaChallenge({ course, onSubmit, submitting }) {
|
||||
const type = course?.challenge?.type || 'photo';
|
||||
const [mediaUrl, setMediaUrl] = useState('');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const accept = type === 'video' ? 'video/*' : 'image/*';
|
||||
const capture = type === 'photo' ? 'environment' : undefined;
|
||||
|
||||
const handleFile = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const { file_url } = await base44.integrations.Core.UploadFile({ file });
|
||||
setMediaUrl(file_url);
|
||||
} catch {
|
||||
setMediaUrl('');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[13px] text-[#64748B] mb-3">
|
||||
{type === 'video' ? 'Record or upload a short video demonstrating the challenge.' : 'Take or upload a photo showing your work.'}
|
||||
</p>
|
||||
{mediaUrl ? (
|
||||
<div className="mb-3 rounded-xl overflow-hidden border border-[#E5E7EB]">
|
||||
{type === 'video' ? (
|
||||
<video src={mediaUrl} controls className="w-full max-h-64 object-cover" />
|
||||
) : (
|
||||
<Image src={mediaUrl} alt="Your proof" className="w-full max-h-64 object-cover" fittingType="fill" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<label className="flex flex-col items-center justify-center h-40 rounded-xl border-2 border-dashed border-[#CBD5E1] cursor-pointer hover:border-[#0A39DF] transition-colors">
|
||||
{uploading ? (
|
||||
<Loader2 className="w-6 h-6 animate-spin text-[#0A39DF]" />
|
||||
) : (
|
||||
<>
|
||||
<Camera className="w-6 h-6 text-[#94A3B8] mb-2" />
|
||||
<span className="text-[13px] text-[#64748B]">Tap to {type === 'video' ? 'record' : 'capture'}</span>
|
||||
</>
|
||||
)}
|
||||
<input type="file" accept={accept} capture={capture} onChange={handleFile} className="hidden" disabled={uploading || submitting} />
|
||||
</label>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
{mediaUrl && (
|
||||
<button onClick={() => setMediaUrl('')} className="h-10 px-4 rounded-xl border border-[#E5E7EB] text-[13px] font-medium text-[#64748B]">Retake</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onSubmit(mediaUrl)}
|
||||
disabled={!mediaUrl || uploading || submitting}
|
||||
className="flex-1 h-10 rounded-xl bg-[#0F172A] text-white text-[13px] font-semibold disabled:opacity-40"
|
||||
>
|
||||
Submit proof for AI evaluation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
src/components/krow/proving/RoleplayChallenge.jsx
Normal file
82
src/components/krow/proving/RoleplayChallenge.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Send, Loader2 } from 'lucide-react';
|
||||
import { challengeFollowUp } from '@/lib/provingGround';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
|
||||
// AI roleplay challenge — scenario prompt, worker responds, KROW follows up,
|
||||
// then the worker submits the transcript for AI evaluation.
|
||||
export default function RoleplayChallenge({ course, onSubmit, submitting }) {
|
||||
const initial = course?.challenge?.prompt || course?.description || 'Demonstrate the skill.';
|
||||
const [messages, setMessages] = useState([{ role: 'ai', content: initial }]);
|
||||
const [input, setInput] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const scrollRef = useRef(null);
|
||||
|
||||
useEffect(() => { scrollRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]);
|
||||
|
||||
const send = async () => {
|
||||
const text = input.trim();
|
||||
if (!text || busy) return;
|
||||
const next = [...messages, { role: 'user', content: text }];
|
||||
setMessages(next);
|
||||
setInput('');
|
||||
setBusy(true);
|
||||
try {
|
||||
const follow = await challengeFollowUp(course, next);
|
||||
setMessages([...next, { role: 'ai', content: follow }]);
|
||||
} catch {
|
||||
setMessages([...next, { role: 'ai', content: 'Thanks. Submit your proof when ready.' }]);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const transcript = messages.map((m) => `${m.role === 'user' ? 'Worker' : 'Owliver'}: ${m.content}`).join('\n');
|
||||
const hasResponded = messages.some((m) => m.role === 'user');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<OwliverAvatar className="w-8 h-8" rounded="rounded-full" />
|
||||
<div>
|
||||
<p className="text-[13px] font-semibold text-[#0F172A]">Owliver</p>
|
||||
<p className="text-[10px] text-[#94A3B8]">Your AI skills coach</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 mb-4 max-h-72 overflow-y-auto pr-1">
|
||||
{messages.map((m, i) => {
|
||||
const isAI = m.role !== 'user';
|
||||
return (
|
||||
<div key={i} className={`flex items-end gap-2 ${isAI ? 'justify-start' : 'justify-end'}`}>
|
||||
{isAI && <OwliverAvatar className="w-7 h-7 shrink-0" rounded="rounded-full" />}
|
||||
<div className={`max-w-[80%] px-3.5 py-2.5 rounded-2xl text-[13px] leading-relaxed ${
|
||||
m.role === 'user' ? 'bg-[#0A39DF] text-white rounded-br-sm' : 'bg-[#F1F5F9] text-[#0F172A] rounded-bl-sm'
|
||||
}`}>{m.content}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={scrollRef} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-full bg-white pl-4 pr-1.5 py-1.5 shadow-[0_10px_30px_rgba(10,57,223,0.14),0_2px_6px_rgba(51,63,72,0.06)] ring-1 ring-black/5">
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && send()}
|
||||
disabled={submitting || busy}
|
||||
placeholder="Type your response…"
|
||||
className="flex-1 h-9 bg-transparent px-1 text-[13px] focus:outline-none placeholder:text-[#9CA3AF]"
|
||||
/>
|
||||
<button onClick={send} disabled={busy || submitting} className="w-9 h-9 rounded-full bg-[#0A39DF] text-white flex items-center justify-center disabled:opacity-50 shrink-0">
|
||||
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onSubmit(transcript)}
|
||||
disabled={!hasResponded || submitting || busy}
|
||||
className="mt-3 w-full h-10 rounded-xl bg-[#0F172A] text-white text-[13px] font-semibold disabled:opacity-40"
|
||||
>
|
||||
Submit proof for AI evaluation
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
src/components/krow/proving/VideoRecorder.jsx
Normal file
103
src/components/krow/proving/VideoRecorder.jsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { Loader2, Video, Square, RotateCcw } from 'lucide-react';
|
||||
import { base44 } from '@/api/base44Client';
|
||||
|
||||
// Real in-browser video recording — "record yourself washing your hands."
|
||||
export default function VideoRecorder({ onSubmit, submitting }) {
|
||||
const videoRef = useRef(null);
|
||||
const mediaRecorderRef = useRef(null);
|
||||
const chunksRef = useRef([]);
|
||||
const timerRef = useRef(null);
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [recordedUrl, setRecordedUrl] = useState(null);
|
||||
const [blob, setBlob] = useState(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [seconds, setSeconds] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let s;
|
||||
navigator.mediaDevices?.getUserMedia({ video: { facingMode: 'user' }, audio: true })
|
||||
.then((stream) => {
|
||||
s = stream;
|
||||
if (active && videoRef.current) videoRef.current.srcObject = stream;
|
||||
})
|
||||
.catch(() => setError('Camera access denied. Grant camera permission or use a mobile device to record.'));
|
||||
return () => {
|
||||
active = false;
|
||||
if (s) s.getTracks().forEach((t) => t.stop());
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const start = () => {
|
||||
if (!videoRef.current?.srcObject) return;
|
||||
chunksRef.current = [];
|
||||
const mr = new MediaRecorder(videoRef.current.srcObject);
|
||||
mr.ondataavailable = (e) => { if (e.data.size) chunksRef.current.push(e.data); };
|
||||
mr.onstop = () => {
|
||||
const b = new Blob(chunksRef.current, { type: 'video/webm' });
|
||||
setBlob(b);
|
||||
setRecordedUrl(URL.createObjectURL(b));
|
||||
};
|
||||
mr.start();
|
||||
mediaRecorderRef.current = mr;
|
||||
setRecording(true);
|
||||
setSeconds(0);
|
||||
timerRef.current = setInterval(() => setSeconds((v) => v + 1), 1000);
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
mediaRecorderRef.current?.stop();
|
||||
setRecording(false);
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
};
|
||||
|
||||
const reset = () => { setRecordedUrl(null); setBlob(null); setSeconds(0); };
|
||||
|
||||
const submit = async () => {
|
||||
if (!blob) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const file = new File([blob], `proof-${Date.now()}.webm`, { type: 'video/webm' });
|
||||
const { file_url } = await base44.integrations.Core.UploadFile({ file });
|
||||
onSubmit(file_url);
|
||||
} catch {
|
||||
setError('Upload failed. Try again.');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{error && <p className="text-[12px] text-[#EF4444] mb-3">{error}</p>}
|
||||
<div className="relative rounded-2xl overflow-hidden bg-black aspect-video mb-3">
|
||||
{recordedUrl ? (
|
||||
<video src={recordedUrl} controls className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<video ref={videoRef} autoPlay muted playsInline className="w-full h-full object-cover" />
|
||||
)}
|
||||
{recording && (
|
||||
<div className="absolute top-3 left-3 flex items-center gap-1.5 bg-black/60 rounded-full px-2.5 py-1">
|
||||
<span className="w-2 h-2 rounded-full bg-red-500 animate-pulse" />
|
||||
<span className="text-[12px] text-white font-mono">{seconds}s</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!recordedUrl ? (
|
||||
<button onClick={recording ? stop : start} disabled={uploading || submitting} className={`w-full h-11 rounded-xl font-semibold text-[14px] flex items-center justify-center gap-2 text-white ${recording ? 'bg-red-500' : 'bg-[#0A39DF]'}`}>
|
||||
{recording ? <><Square className="w-4 h-4" /> Stop recording</> : <><Video className="w-4 h-4" /> Record yourself</>}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<button onClick={reset} className="h-11 px-4 rounded-xl border border-[#E5E7EB] text-[13px] font-medium text-[#64748B] flex items-center gap-1.5"><RotateCcw className="w-4 h-4" /> Retake</button>
|
||||
<button onClick={submit} disabled={uploading || submitting} className="flex-1 h-11 rounded-xl bg-[#0F172A] text-white text-[13px] font-semibold flex items-center justify-center gap-2 disabled:opacity-50">
|
||||
{uploading ? <Loader2 className="w-4 h-4 animate-spin" /> : null} Submit proof for AI evaluation
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
src/components/krow/talent/CareerGrowth.jsx
Normal file
36
src/components/krow/talent/CareerGrowth.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
|
||||
const LADDER = ['Beginner', 'Cross-Trained', 'Skilled', 'Lead', 'Manager'];
|
||||
|
||||
export default function CareerGrowth({ profile }) {
|
||||
const lp = Number(profile.leadership_potential) || 0;
|
||||
const currentIdx = lp >= 80 ? 3 : lp >= 60 ? 2 : lp >= 40 ? 1 : 0;
|
||||
return (
|
||||
<section className="rounded-2xl bg-white border border-[#E5E7EB] p-6 sm:p-8">
|
||||
<h2 className="text-[18px] font-bold text-[#0F172A]">Career Growth</h2>
|
||||
<p className="text-[13px] text-[#6B7280] mb-5">People don't just work. They level up.</p>
|
||||
<div className="flex flex-col">
|
||||
{LADDER.map((step, i) => {
|
||||
const done = i < currentIdx;
|
||||
const current = i === currentIdx;
|
||||
const last = i === LADDER.length - 1;
|
||||
return (
|
||||
<div key={step} className="flex gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className={`w-9 h-9 rounded-full flex items-center justify-center text-[12px] font-bold shrink-0 ${current ? 'bg-[#0A39DF] text-white' : done ? 'bg-[#ECFDF5] text-[#16A34A] border border-[#A7F3D0]' : 'bg-[#F3F4F6] text-[#9CA3AF]'}`}>
|
||||
{done ? '✓' : i + 1}
|
||||
</div>
|
||||
{!last && <div className="w-0.5 flex-1 bg-[#E5E7EB] my-1 min-h-[16px]" />}
|
||||
</div>
|
||||
<div className={`pb-4 pt-1.5 text-[14px] font-medium ${current ? 'text-[#0A39DF]' : done ? 'text-[#0F172A]' : 'text-[#9CA3AF]'}`}>
|
||||
{step}
|
||||
{current && <span className="ml-2 text-[11px] font-normal text-[#6B7280]">You are here</span>}
|
||||
{last && <span className="ml-2 text-[11px] font-normal text-[#6B7280]">Target</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
70
src/components/krow/talent/CareerPassport.jsx
Normal file
70
src/components/krow/talent/CareerPassport.jsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import { ShieldCheck } from 'lucide-react';
|
||||
|
||||
const ITEMS = [
|
||||
'Identity',
|
||||
'Food Handler',
|
||||
'RBS',
|
||||
'Allergens',
|
||||
'Knife Skills',
|
||||
'References',
|
||||
'AI Interview',
|
||||
'Background Check',
|
||||
];
|
||||
|
||||
export default function CareerPassport({ profile }) {
|
||||
const certs = (profile.certifications || []).map((c) => c.toLowerCase());
|
||||
const skills = (profile.skills || []).map((s) => s.toLowerCase());
|
||||
const badges = (profile.earned_badges || []).map((b) => (b.name || '').toLowerCase());
|
||||
|
||||
const has = (label) => {
|
||||
switch (label) {
|
||||
case 'Identity':
|
||||
return !!profile.selfie_url || !!profile.full_name;
|
||||
case 'Food Handler':
|
||||
return certs.some((c) => c.includes('food'));
|
||||
case 'RBS':
|
||||
return certs.some((c) => c.includes('rbs'));
|
||||
case 'Allergens':
|
||||
return certs.some((c) => c.includes('allergen'));
|
||||
case 'Knife Skills':
|
||||
return skills.some((s) => s.includes('knife')) || badges.some((b) => b.includes('knife'));
|
||||
case 'References':
|
||||
return (profile.experience || []).length >= 2;
|
||||
case 'AI Interview':
|
||||
return (profile.ai_interview_score || 0) > 0;
|
||||
case 'Background Check':
|
||||
return (profile.shifts_completed || 0) >= 10;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const done = ITEMS.filter(has);
|
||||
const pct = Math.round((done.length / ITEMS.length) * 100);
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl bg-white border border-[#E5E7EB] p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-[15px] font-semibold text-[#0F172A]">Career Passport</h2>
|
||||
<span className="text-[12px] font-semibold text-[#0A39DF]">{pct}% Complete</span>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
{ITEMS.map((label) => {
|
||||
const ok = has(label);
|
||||
return (
|
||||
<div
|
||||
key={label}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-xl border ${
|
||||
ok ? 'border-[#BBF7D0] bg-[#F0FDF4]' : 'border-[#E5E7EB] bg-[#F8FAFC]'
|
||||
}`}
|
||||
>
|
||||
<ShieldCheck className={`w-4 h-4 shrink-0 ${ok ? 'text-[#16A34A]' : 'text-[#CBD5E1]'}`} />
|
||||
<span className={`text-[13px] ${ok ? 'text-[#0F172A] font-medium' : 'text-[#94A3B8]'}`}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
63
src/components/krow/talent/CareerScoreGauge.jsx
Normal file
63
src/components/krow/talent/CareerScoreGauge.jsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
import { Info, RefreshCw } from 'lucide-react';
|
||||
import { getTalentScore, toFICO, getScoreBand } from '@/lib/talentHome';
|
||||
|
||||
const FICO_MIN = 300;
|
||||
const FICO_MAX = 850;
|
||||
const ARC_R = 64;
|
||||
const CX = 80;
|
||||
const CY = 80;
|
||||
const BLUE = '#0055FF';
|
||||
const TRACK = '#E1E5E9';
|
||||
|
||||
function arcPoint(p) {
|
||||
const ang = Math.PI - p * Math.PI;
|
||||
return { x: CX + ARC_R * Math.cos(ang), y: CY - ARC_R * Math.sin(ang) };
|
||||
}
|
||||
function arcPath(startP, endP) {
|
||||
const a = arcPoint(startP);
|
||||
const b = arcPoint(endP);
|
||||
const large = endP - startP > 0.5 ? 1 : 0;
|
||||
return `M ${a.x} ${a.y} A ${ARC_R} ${ARC_R} 0 ${large} 1 ${b.x} ${b.y}`;
|
||||
}
|
||||
|
||||
export default function CareerScoreGauge({ profile }) {
|
||||
const raw = getTalentScore(profile);
|
||||
const score = toFICO(raw);
|
||||
const band = getScoreBand(raw);
|
||||
const pct = Math.max(0, Math.min(1, (score - FICO_MIN) / (FICO_MAX - FICO_MIN)));
|
||||
const gap = 0.02;
|
||||
const filledEnd = Math.max(gap, Math.min(1 - gap, pct));
|
||||
const marker = arcPoint(filledEnd);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="relative" style={{ width: CX * 2, height: CY + 28 }}>
|
||||
<svg width={CX * 2} height={CY + 28} viewBox={`0 0 ${CX * 2} ${CY + 28}`} className="overflow-visible">
|
||||
<path d={arcPath(gap, 1 - gap)} fill="none" stroke={TRACK} strokeWidth="12" strokeLinecap="round" />
|
||||
{pct > gap && (
|
||||
<path d={arcPath(gap, filledEnd)} fill="none" stroke={BLUE} strokeWidth="12" strokeLinecap="round" />
|
||||
)}
|
||||
<circle cx={marker.x} cy={marker.y} r="8" fill={BLUE} stroke="#fff" strokeWidth="3" />
|
||||
</svg>
|
||||
|
||||
<div className="absolute inset-x-0 bottom-2 flex flex-col items-center">
|
||||
<span className="text-[9px] uppercase tracking-wide text-[#A0AAB9] font-semibold">Career Score</span>
|
||||
<span className="text-[30px] font-bold leading-none mt-0.5" style={{ color: BLUE }}>{score}</span>
|
||||
<span className="text-[10px] text-[#8690A1] font-medium mt-0.5">{FICO_MIN}-{FICO_MAX}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="mt-2 inline-flex items-center gap-1 text-[12px] font-semibold px-3 py-1 rounded-full"
|
||||
style={{ color: BLUE, background: '#F0F4F9' }}>
|
||||
{band.label}
|
||||
<Info className="w-3 h-3" style={{ color: BLUE }} />
|
||||
</span>
|
||||
|
||||
<div className="inline-flex items-center gap-1.5 mt-2 text-[11px] text-[#7D8590]">
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
Next update in 31 days
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user