Initial commit
This commit is contained in:
11
.claude/launch.json
Normal file
11
.claude/launch.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "krow-demo",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 5173
|
||||
}
|
||||
]
|
||||
}
|
||||
32
.gitignore
vendored
Normal file
32
.gitignore
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
#env
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Logs
|
||||
/logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
.env
|
||||
.vite
|
||||
base44/.app.jsonc
|
||||
123
README.md
Normal file
123
README.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# KROW Demo
|
||||
|
||||
AI-powered hiring for event and hospitality staffing — a standalone demo build of KROW Forge.
|
||||
|
||||
Runs with no backend, no API keys, and no login. `npm install && npm run dev` and the app is fully
|
||||
populated.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Vite + React 18, React Router 6, React Query 5, Tailwind + shadcn/ui, Framer Motion, Recharts.
|
||||
|
||||
```
|
||||
src/
|
||||
api/ data client — entity store, demo dataset, local AI engine
|
||||
assets/ brand asset references
|
||||
components/
|
||||
ui/ 50 shadcn primitives (design system)
|
||||
krow/ domain components
|
||||
talent/ talent-portal components
|
||||
proving/ Proving Ground challenge components
|
||||
hooks/ shared hooks
|
||||
layouts/ Layout — header, nav pill, control bar, mobile sheet
|
||||
lib/ data hooks, score engine, AI workflows, auth context, utils
|
||||
pages/ 18 route components
|
||||
utils/ helpers
|
||||
```
|
||||
|
||||
### The backend seam
|
||||
|
||||
The production app talks to a Base44 backend through `src/api/base44Client.js`. This demo keeps
|
||||
that module path, export name, and full method contract, and swaps only the transport:
|
||||
|
||||
| Contract | Demo implementation |
|
||||
| --- | --- |
|
||||
| `base44.entities.<Name>.list/filter/get/create/update/delete` | `api/store.js` — in-memory records, mirrored to `localStorage` |
|
||||
| `base44.integrations.Core.InvokeLLM` | `api/aiEngine.js` — local, deterministic |
|
||||
| `base44.integrations.Core.UploadFile` | blob URLs, session-scoped |
|
||||
| `base44.auth.*` | seeded employer/admin user, always signed in |
|
||||
|
||||
Because the seam did not move, everything above it — `lib/krowHooks.js`, `lib/krowAi.js`,
|
||||
`lib/provingGround.js`, `lib/krowScore.js`, every page and component — is unchanged from the
|
||||
reference implementation.
|
||||
|
||||
### The AI engine
|
||||
|
||||
Every AI workflow in the app funnels through one call, `InvokeLLM({ prompt, response_json_schema })`.
|
||||
`api/aiEngine.js` reimplements that function locally. It recognizes each workflow by the phrase its
|
||||
prompt opens with, reads the structured fields the prompt already carries (`Years Experience: 6`,
|
||||
`Required Certifications: …`, the talent-pool JSON block), and scores them deterministically.
|
||||
|
||||
Nine workflows are implemented: candidate screening, job-description generation, résumé building,
|
||||
interview questions, interview evaluation, talent matching, Owliver's conversation, Career DNA
|
||||
building, and Proving Ground evaluation.
|
||||
|
||||
Results respond to the data rather than being canned — screening a 7-year native-English candidate
|
||||
and a 1-year candidate against the same posting returns genuinely different scores, breakdowns,
|
||||
strengths and gaps. The same input always returns the same output.
|
||||
|
||||
### Owliver
|
||||
|
||||
Two assistants share one visual language:
|
||||
|
||||
- **`OwliverAssistant`** — the hiring assistant, mounted on **Overview**, **Candidates** and
|
||||
**Analytics** only. Answers from the same React Query caches the pages render from, so what it
|
||||
says and what the dashboard shows can never disagree.
|
||||
- **`OwliverChatBubble`** — the talent-facing profile builder, mounted in the Talent role.
|
||||
|
||||
## Demo data
|
||||
|
||||
Seeded in `src/api/seed.js`: 7 positions (5 open), 22 applicants, 9 AI-scored candidates averaging
|
||||
76, 4 AI interviews, 3 hires, 6 talent-pool profiles, 4 Proving Ground courses, and an activity log.
|
||||
|
||||
Edits persist to `localStorage` — screen candidates, hire someone, create a position, and the
|
||||
numbers move everywhere. To restore the shipped dataset:
|
||||
|
||||
```js
|
||||
// in the browser console
|
||||
localStorage.removeItem('krow_demo_db'); location.reload();
|
||||
```
|
||||
|
||||
Bumping `STORAGE_VERSION` in `src/api/store.js` invalidates saved snapshots automatically, so
|
||||
changing the seed does not require a manual clear.
|
||||
|
||||
## Roles
|
||||
|
||||
The header role switcher moves between three surfaces, persisted in `localStorage`:
|
||||
|
||||
- **Employer** — Overview, Positions, Candidates, Hired History, Talent Pool, Analytics
|
||||
- **Admin** — adds Control Center, KROW Forge, Activity
|
||||
- **Talent** — My Portal, KROW Forge, Opportunities
|
||||
|
||||
## Design system
|
||||
|
||||
Tokens live in `src/index.css` as HSL CSS variables consumed by `tailwind.config.js`.
|
||||
|
||||
| Token | Value |
|
||||
| --- | --- |
|
||||
| Primary — KROW Blue | `#0A39DF` |
|
||||
| Accent — KROW Yellow | `#F9E547` |
|
||||
| Pale Yellow | `#F8E08E` |
|
||||
| Dark Navy | `#333F48` |
|
||||
| Mint | `#D1E0D7` |
|
||||
| Radius | `0.75rem` |
|
||||
| Headings | Sora |
|
||||
| Body | Inter |
|
||||
|
||||
The page background is `bg-[#F8FAFC]` under the `gradient-mesh` utility — four radial gradients in
|
||||
blue, yellow and mint. Surfaces use `glass` and `glass-card` (backdrop blur with saturation).
|
||||
|
||||
## Scripts
|
||||
|
||||
| Command | Purpose |
|
||||
| --- | --- |
|
||||
| `npm run dev` | Dev server (honors `PORT`) |
|
||||
| `npm run build` | Production build |
|
||||
| `npm run preview` | Serve the build |
|
||||
| `npm run lint` | ESLint |
|
||||
| `npm run lint:fix` | ESLint with `--fix` |
|
||||
21
components.json
Normal file
21
components.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": false,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
60
eslint.config.js
Normal file
60
eslint.config.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import globals from "globals";
|
||||
import pluginJs from "@eslint/js";
|
||||
import pluginReact from "eslint-plugin-react";
|
||||
import pluginReactHooks from "eslint-plugin-react-hooks";
|
||||
import pluginUnusedImports from "eslint-plugin-unused-imports";
|
||||
|
||||
export default [
|
||||
{
|
||||
files: [
|
||||
"src/components/**/*.{js,mjs,cjs,jsx}",
|
||||
"src/pages/**/*.{js,mjs,cjs,jsx}",
|
||||
"src/Layout.jsx",
|
||||
],
|
||||
ignores: ["src/lib/**/*", "src/components/ui/**/*"],
|
||||
...pluginJs.configs.recommended,
|
||||
...pluginReact.configs.flat.recommended,
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: "module",
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: "detect",
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
react: pluginReact,
|
||||
"react-hooks": pluginReactHooks,
|
||||
"unused-imports": pluginUnusedImports,
|
||||
},
|
||||
rules: {
|
||||
"no-unused-vars": "off",
|
||||
"react/jsx-uses-vars": "error",
|
||||
"react/jsx-uses-react": "error",
|
||||
"unused-imports/no-unused-imports": "error",
|
||||
"unused-imports/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
vars: "all",
|
||||
varsIgnorePattern: "^_",
|
||||
args: "after-used",
|
||||
argsIgnorePattern: "^_",
|
||||
},
|
||||
],
|
||||
"react/prop-types": "off",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/no-unknown-property": [
|
||||
"error",
|
||||
{ ignore: ["cmdk-input-wrapper", "toast-close"] },
|
||||
],
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
},
|
||||
},
|
||||
];
|
||||
17
index.html
Normal file
17
index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/krow-mark.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet" />
|
||||
<title>KROW Forge — AI-Powered Hiring</title>
|
||||
<meta name="description" content="KROW replaces quizzes with real AI voice interviews. Hire ready talent in minutes, not weeks." />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
21
jsconfig.json
Normal file
21
jsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"jsx": "react-jsx",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["esnext", "dom"],
|
||||
"target": "esnext",
|
||||
"checkJs": true,
|
||||
"skipLibCheck": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/components/**/*.js", "src/pages/**/*.jsx", "src/layouts/**/*.jsx"],
|
||||
"exclude": ["node_modules", "dist", "src/components/ui", "src/api", "src/lib"]
|
||||
}
|
||||
9224
package-lock.json
generated
Normal file
9224
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
88
package.json
Normal file
88
package.json
Normal file
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"name": "krow-demo",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint . --quiet",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"typecheck": "tsc -p ./jsconfig.json",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^4.1.2",
|
||||
"@radix-ui/react-accordion": "^1.2.3",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.2",
|
||||
"@radix-ui/react-avatar": "^1.1.3",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
"@radix-ui/react-collapsible": "^1.1.3",
|
||||
"@radix-ui/react-context-menu": "^2.2.6",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
||||
"@radix-ui/react-hover-card": "^1.1.6",
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-menubar": "^1.1.6",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.5",
|
||||
"@radix-ui/react-popover": "^1.1.6",
|
||||
"@radix-ui/react-progress": "^1.1.2",
|
||||
"@radix-ui/react-radio-group": "^1.2.3",
|
||||
"@radix-ui/react-scroll-area": "^1.2.3",
|
||||
"@radix-ui/react-select": "^2.1.6",
|
||||
"@radix-ui/react-separator": "^1.1.2",
|
||||
"@radix-ui/react-slider": "^1.2.3",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-switch": "^1.1.3",
|
||||
"@radix-ui/react-tabs": "^1.1.3",
|
||||
"@radix-ui/react-toast": "^1.2.2",
|
||||
"@radix-ui/react-toggle": "^1.1.2",
|
||||
"@radix-ui/react-toggle-group": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@tanstack/react-query": "^5.84.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"embla-carousel-react": "^8.5.2",
|
||||
"framer-motion": "^11.16.4",
|
||||
"html2canvas": "^1.4.1",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.475.0",
|
||||
"moment": "^2.30.1",
|
||||
"next-themes": "^0.4.4",
|
||||
"react": "^18.2.0",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"recharts": "^2.15.4",
|
||||
"sonner": "^2.0.1",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@types/node": "^22.13.5",
|
||||
"@types/react": "^18.2.66",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.19.0",
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.18",
|
||||
"eslint-plugin-unused-imports": "^4.3.0",
|
||||
"globals": "^15.14.0",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.8.2",
|
||||
"vite": "^6.1.0"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
public/krow-logo.webp
Normal file
BIN
public/krow-logo.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.9 KiB |
86
src/App.jsx
Normal file
86
src/App.jsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Toaster } from "@/components/ui/toaster"
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { queryClientInstance } from '@/lib/query-client'
|
||||
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
|
||||
import PageNotFound from './lib/PageNotFound';
|
||||
import { AuthProvider, useAuth } from '@/lib/AuthContext';
|
||||
import ScrollToTop from './components/ScrollToTop';
|
||||
import ProtectedRoute from '@/components/ProtectedRoute';
|
||||
import Layout from '@/layouts/Layout';
|
||||
import Overview from '@/pages/Overview';
|
||||
import Positions from '@/pages/Positions';
|
||||
import PositionDetail from '@/pages/PositionDetail';
|
||||
import Candidates from '@/pages/Candidates';
|
||||
import HiredHistory from '@/pages/HiredHistory';
|
||||
import TalentPool from '@/pages/TalentPool';
|
||||
import Apply from '@/pages/Apply';
|
||||
import UserTracking from '@/pages/UserTracking';
|
||||
import Analytics from '@/pages/Analytics';
|
||||
import CreatePosition from '@/pages/CreatePosition';
|
||||
import Profile from '@/pages/Profile';
|
||||
import University from '@/pages/University';
|
||||
import CourseDetail from '@/pages/CourseDetail';
|
||||
import WorkerProfile from '@/pages/WorkerProfile';
|
||||
import KrowIdentity from '@/pages/KrowIdentity';
|
||||
import Owliver from '@/pages/Owliver';
|
||||
import AdminDashboard from '@/pages/AdminDashboard';
|
||||
import EmployeeDashboard from '@/pages/EmployeeDashboard';
|
||||
import DesignSystem from '@/pages/DesignSystem';
|
||||
|
||||
const AuthenticatedApp = () => {
|
||||
const { isLoadingAuth, isLoadingPublicSettings } = useAuth();
|
||||
|
||||
if (isLoadingPublicSettings || isLoadingAuth) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<Overview />} />
|
||||
<Route path="/positions" element={<Positions />} />
|
||||
<Route path="/positions/new" element={<CreatePosition />} />
|
||||
<Route path="/positions/:id" element={<PositionDetail />} />
|
||||
<Route path="/candidates" element={<Candidates />} />
|
||||
<Route path="/hired" element={<HiredHistory />} />
|
||||
<Route path="/talent-pool" element={<TalentPool />} />
|
||||
<Route path="/apply" element={<Apply />} />
|
||||
<Route path="/tracking" element={<UserTracking />} />
|
||||
<Route path="/analytics" element={<Analytics />} />
|
||||
<Route path="/profile" element={<Profile />} />
|
||||
<Route path="/university" element={<University />} />
|
||||
<Route path="/university/:id" element={<CourseDetail />} />
|
||||
<Route path="/me" element={<WorkerProfile />} />
|
||||
<Route path="/identity" element={<KrowIdentity />} />
|
||||
<Route path="/owliver" element={<Owliver />} />
|
||||
<Route path="/admin" element={<AdminDashboard />} />
|
||||
<Route path="/employee" element={<EmployeeDashboard />} />
|
||||
{/* Design system reference — not in product navigation. */}
|
||||
<Route path="/design-system" element={<DesignSystem />} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="*" element={<PageNotFound />} />
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClientInstance}>
|
||||
<Router>
|
||||
<ScrollToTop />
|
||||
<AuthenticatedApp />
|
||||
</Router>
|
||||
<Toaster />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
809
src/api/aiEngine.js
Normal file
809
src/api/aiEngine.js
Normal file
@@ -0,0 +1,809 @@
|
||||
/**
|
||||
* Local AI engine for the KROW demo.
|
||||
*
|
||||
* The app funnels every AI workflow through a single call —
|
||||
* `integrations.Core.InvokeLLM({ prompt, response_json_schema, model })` — so
|
||||
* reimplementing that one function keeps `lib/krowAi.js` and
|
||||
* `lib/provingGround.js` byte-for-byte identical to the reference.
|
||||
*
|
||||
* Each workflow is recognized by a stable phrase its prompt opens with, and the
|
||||
* structured fields the prompt already carries ("Years Experience: 6",
|
||||
* "Required Certifications: …", the talent-pool JSON block) are read back out
|
||||
* and scored deterministically. Same input, same output — no network, no key,
|
||||
* and results that actually respond to the data instead of being canned.
|
||||
*/
|
||||
|
||||
const clamp = (n, min = 0, max = 100) => Math.max(min, Math.min(max, Math.round(Number(n) || 0)));
|
||||
|
||||
/** Simulated model latency so streaming/pending UI behaves as designed. */
|
||||
const think = (ms = 900) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/* ── Prompt field readers ──────────────────────────────────────────────── */
|
||||
|
||||
/** Reads a `Label: value` line out of a prompt. */
|
||||
function field(prompt, label) {
|
||||
const match = prompt.match(new RegExp(`^${label}:[ \\t]*(.*)$`, 'mi'));
|
||||
return match ? match[1].trim() : '';
|
||||
}
|
||||
|
||||
function numField(prompt, label) {
|
||||
const raw = field(prompt, label);
|
||||
const match = raw.match(/-?\d+(\.\d+)?/);
|
||||
return match ? Number(match[0]) : 0;
|
||||
}
|
||||
|
||||
/** Reads a comma-separated line, treating the app's "None" sentinel as empty. */
|
||||
function listField(prompt, label) {
|
||||
const raw = field(prompt, label);
|
||||
if (!raw || /^(none|not specified|not provided)$/i.test(raw)) return [];
|
||||
return raw.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
/** Pulls the first triple-quoted block (transcripts, free-text resumes). */
|
||||
function quotedBlock(prompt) {
|
||||
const match = prompt.match(/"""\s*([\s\S]*?)\s*"""/);
|
||||
return match ? match[1].trim() : '';
|
||||
}
|
||||
|
||||
const ENGLISH_RANK = { basic: 1, conversational: 2, fluent: 3, native: 4 };
|
||||
|
||||
const isSpanish = (prompt) => /Spanish \(español\)/.test(prompt) && !/default to English/.test(prompt);
|
||||
|
||||
/* ── 1. Candidate screening ────────────────────────────────────────────── */
|
||||
|
||||
function screenCandidate(prompt) {
|
||||
// Weights are stated inline: "experience 25%, english 20%, …"
|
||||
const weightOf = (name) => {
|
||||
const match = prompt.match(new RegExp(`${name} (\\d+)%`));
|
||||
return match ? Number(match[1]) : 20;
|
||||
};
|
||||
const weights = {
|
||||
experience: weightOf('experience'),
|
||||
english: weightOf('english'),
|
||||
reliability: weightOf('reliability'),
|
||||
certifications: weightOf('certifications'),
|
||||
availability: weightOf('availability'),
|
||||
};
|
||||
|
||||
const minYears = numField(prompt, 'Min Experience');
|
||||
const requiredEnglish = ENGLISH_RANK[field(prompt, 'English Required').toLowerCase()] || 1;
|
||||
const requiredCertNames = listField(prompt, 'Required Certifications');
|
||||
const requiredCerts = requiredCertNames.map((c) => c.toLowerCase());
|
||||
const customRequirements = field(prompt, 'Custom Requirements');
|
||||
|
||||
const name = field(prompt, 'Name');
|
||||
const years = numField(prompt, 'Years Experience');
|
||||
const englishLevel = ENGLISH_RANK[field(prompt, 'English Level').toLowerCase()] || 1;
|
||||
const certs = listField(prompt, 'Certifications');
|
||||
const skills = listField(prompt, 'Skills');
|
||||
const availability = listField(prompt, 'Availability');
|
||||
const summary = field(prompt, 'Professional Summary');
|
||||
const hasSummary = Boolean(summary) && !/^not provided$/i.test(summary);
|
||||
|
||||
// Experience: meeting the minimum is a pass, exceeding it earns headroom.
|
||||
const experience = minYears > 0
|
||||
? clamp(years >= minYears ? 78 + Math.min(21, (years - minYears) * 4) : (years / minYears) * 70)
|
||||
: clamp(45 + years * 8);
|
||||
|
||||
// English: at or above the requirement scores well, each level short costs.
|
||||
const english = clamp(englishLevel >= requiredEnglish
|
||||
? 84 + (englishLevel - requiredEnglish) * 5
|
||||
: 84 - (requiredEnglish - englishLevel) * 24);
|
||||
|
||||
// Certifications: fraction of required credentials actually held.
|
||||
const held = requiredCerts.filter((req) =>
|
||||
certs.some((c) => c.toLowerCase().includes(req) || req.includes(c.toLowerCase())));
|
||||
const certifications = requiredCerts.length
|
||||
? clamp((held.length / requiredCerts.length) * 92 + (certs.length > requiredCerts.length ? 8 : 0))
|
||||
: clamp(certs.length ? 88 : 55);
|
||||
|
||||
const availabilityScore = clamp(availability.length ? 52 + availability.length * 14 : 40);
|
||||
|
||||
// Reliability has no direct field — infer it from tenure, credentials and
|
||||
// whether the candidate bothered to describe their own work.
|
||||
const reliability = clamp(
|
||||
45 + Math.min(30, years * 4) + (certifications > 70 ? 14 : 0) + (hasSummary ? 8 : 0)
|
||||
);
|
||||
|
||||
const weightTotal = Object.values(weights).reduce((a, b) => a + b, 0) || 100;
|
||||
const overall = clamp(
|
||||
(experience * weights.experience +
|
||||
english * weights.english +
|
||||
reliability * weights.reliability +
|
||||
certifications * weights.certifications +
|
||||
availabilityScore * weights.availability) / weightTotal
|
||||
);
|
||||
|
||||
const skillDepth = clamp(skills.length ? 55 + skills.length * 9 : 30);
|
||||
const seniority = clamp(years * 8);
|
||||
|
||||
const label = overall >= 85 ? 'Excellent Match'
|
||||
: overall >= 70 ? 'Good Match'
|
||||
: overall >= 50 ? 'Possible Fit'
|
||||
: 'Not a Fit';
|
||||
const recommendation = overall >= 85 ? 'Shortlisted'
|
||||
: overall >= 65 ? 'Interview'
|
||||
: overall >= 50 ? 'Maybe'
|
||||
: 'Reject';
|
||||
|
||||
const strengths = [];
|
||||
if (years >= Math.max(1, minYears)) strengths.push(`${years} year${years === 1 ? '' : 's'} of relevant experience`);
|
||||
if (held.length) strengths.push(`${held.length === requiredCerts.length ? 'All' : 'Some'} required certifications on file`);
|
||||
if (englishLevel >= requiredEnglish) strengths.push(`${field(prompt, 'English Level') || 'Adequate'} English meets the requirement`);
|
||||
if (availability.length >= 2) strengths.push(`Broad availability (${availability.join(', ')})`);
|
||||
if (skills.length >= 3) strengths.push(`Relevant skills: ${skills.slice(0, 3).join(', ')}`);
|
||||
if (!strengths.length) strengths.push('Available and actively seeking work');
|
||||
|
||||
const gaps = [];
|
||||
if (minYears > 0 && years < minYears) gaps.push(`${minYears - years} year${minYears - years === 1 ? '' : 's'} short of the experience minimum`);
|
||||
// Report the credential using the posting's own capitalization.
|
||||
const missing = requiredCertNames.filter((c) => !held.includes(c.toLowerCase()));
|
||||
if (missing.length) gaps.push(`Missing certification${missing.length > 1 ? 's' : ''}: ${missing.join(', ')}`);
|
||||
if (englishLevel < requiredEnglish) gaps.push('English level below the posting requirement');
|
||||
if (availability.length <= 1) gaps.push('Narrow availability window');
|
||||
if (!skills.length) gaps.push('No skills listed on the application');
|
||||
if (!hasSummary) gaps.push('No professional summary provided');
|
||||
if (!gaps.length) gaps.push('No material gaps against this posting');
|
||||
|
||||
const verdictSentence = overall >= 85
|
||||
? `Strong fit for this posting.`
|
||||
: overall >= 70
|
||||
? `Meets the core requirements for this posting.`
|
||||
: overall >= 50
|
||||
? `Partial fit — worth a conversation, with caveats.`
|
||||
: `Below the requirement bar for this posting.`;
|
||||
|
||||
return {
|
||||
overall_score: overall,
|
||||
match_label: label,
|
||||
summary: `${verdictSentence} ${name || 'The candidate'} brings ${years} year${years === 1 ? '' : 's'} of experience with ${certs.length ? certs.join(' and ') : 'no listed certifications'}. ${gaps[0]}.`,
|
||||
strengths: strengths.slice(0, 4),
|
||||
gaps: gaps.slice(0, 3),
|
||||
recommendation,
|
||||
score_breakdown: {
|
||||
experience,
|
||||
english,
|
||||
reliability,
|
||||
certifications,
|
||||
availability: availabilityScore,
|
||||
personality: clamp(58 + skillDepth * 0.25 + (hasSummary ? 10 : 0)),
|
||||
culture_fit: clamp(overall * 0.9 + 6),
|
||||
communication_style: clamp(english * 0.85 + (hasSummary ? 12 : 0)),
|
||||
attendance_expectations: clamp(reliability * 0.95 + availabilityScore * 0.1),
|
||||
physical_requirements: clamp(70 + Math.min(20, years * 3)),
|
||||
leadership_expectations: clamp(seniority * 0.8 + (/lead|captain|chef|supervisor|manager/i.test(summary) ? 22 : 0)),
|
||||
job_related_answers: clamp(overall * 0.92 + skillDepth * 0.08),
|
||||
verified_skills: skillDepth,
|
||||
scenario_judgment: clamp(overall * 0.88 + (hasSummary ? 8 : 0)),
|
||||
employer_requirements: clamp(customRequirements && !/^none$/i.test(customRequirements)
|
||||
? overall * 0.85
|
||||
: overall * 0.95),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/* ── 2. Job description generator ──────────────────────────────────────── */
|
||||
|
||||
const RESPONSIBILITY_LIBRARY = {
|
||||
Bartender: [
|
||||
'Set up and break down a full service bar',
|
||||
'Pour to spec at event pace without sacrificing presentation',
|
||||
'Verify guest age and refuse service responsibly',
|
||||
'Track consumption and flag low stock before service',
|
||||
'Keep the bar clean and compliant throughout the shift',
|
||||
'Reconcile counts and close out the bar after service',
|
||||
],
|
||||
Server: [
|
||||
'Execute synchronized coursed service for seated events',
|
||||
'Polish and set glassware, china, and flatware',
|
||||
'Describe menus and handle allergen questions accurately',
|
||||
'Clear and reset tables discreetly between courses',
|
||||
'Support captains through arrivals, toasts, and departures',
|
||||
'Complete side work and floor close-out',
|
||||
],
|
||||
Security: [
|
||||
'Staff entry points and verify credentials',
|
||||
'Conduct bag checks per venue policy',
|
||||
'De-escalate guest conflicts calmly and early',
|
||||
'Document incidents accurately and promptly',
|
||||
'Coordinate with venue staff and local authorities',
|
||||
'Patrol assigned zones throughout the event',
|
||||
],
|
||||
Chef: [
|
||||
'Design and cost menus to target margins',
|
||||
'Lead and schedule the kitchen brigade',
|
||||
'Own food safety compliance across every kitchen',
|
||||
'Manage vendor relationships and food cost',
|
||||
'Execute tastings and client consultations',
|
||||
'Hold plate standards at volume',
|
||||
],
|
||||
Picker: [
|
||||
'Pick and stage orders against pick lists',
|
||||
'Scan and verify SKUs for accuracy',
|
||||
'Stage outbound pallets for dispatch',
|
||||
'Report damaged inventory immediately',
|
||||
'Keep aisles and staging lanes clear',
|
||||
'Meet daily accuracy and throughput targets',
|
||||
],
|
||||
};
|
||||
|
||||
const DEFAULT_RESPONSIBILITIES = [
|
||||
'Deliver consistent, guest-ready service every shift',
|
||||
'Set up and break down your station',
|
||||
'Communicate clearly with leads and teammates',
|
||||
'Follow safety and compliance standards',
|
||||
'Adapt to changing event timing without losing composure',
|
||||
'Complete close-out duties before leaving',
|
||||
];
|
||||
|
||||
function generateJobDescription(prompt) {
|
||||
const title = field(prompt, 'Role Title');
|
||||
const category = field(prompt, 'Role Category');
|
||||
const minYears = numField(prompt, 'Min Experience');
|
||||
const english = field(prompt, 'English Level Required');
|
||||
const certs = listField(prompt, 'Required Certifications');
|
||||
const payMin = numField(prompt, 'Pay Range');
|
||||
const payMatch = prompt.match(/Pay Range: \$(\d+(?:\.\d+)?)–\$(\d+(?:\.\d+)?)/);
|
||||
const payMax = payMatch ? Number(payMatch[2]) : payMin;
|
||||
const location = field(prompt, 'Location');
|
||||
const custom = field(prompt, 'Custom Requirements');
|
||||
const hasCustom = custom && !/^none$/i.test(custom);
|
||||
const hasLocation = location && !/^not specified$/i.test(location);
|
||||
const role = title || category || 'team member';
|
||||
|
||||
const payLine = payMax > 0 ? `$${payMin}–$${payMax}/hr` : 'a competitive hourly rate';
|
||||
const whereLine = hasLocation ? ` in ${location}` : '';
|
||||
|
||||
const description = [
|
||||
`We are hiring ${/^[aeiou]/i.test(role) ? 'an' : 'a'} ${role}${whereLine} for premium events where the details are the product. This is hands-on work with a team that plans carefully, briefs properly, and backs you up when the room gets busy.`,
|
||||
`You will own your station from setup through close. ${minYears > 0 ? `We are looking for at least ${minYears} year${minYears === 1 ? '' : 's'} in a comparable role` : 'We will train the right person from the ground up'}, ${english ? `${english} English` : 'clear communication'}, and the judgment to make a call without waiting to be told. ${certs.length ? `${certs.join(' and ')} must be current.` : 'No specific certifications required.'}`,
|
||||
`We pay ${payLine}, publish schedules ahead of time, and promote from within — most of our leads started on the floor. ${hasCustom ? `One thing worth flagging: ${custom}` : 'If you take pride in work guests notice, you will fit here.'}`,
|
||||
].join('\n\n');
|
||||
|
||||
const responsibilities = (RESPONSIBILITY_LIBRARY[category] || DEFAULT_RESPONSIBILITIES).slice(0, 6);
|
||||
|
||||
const qualifications = [
|
||||
minYears > 0
|
||||
? `${minYears}+ year${minYears === 1 ? '' : 's'} of ${category ? `${category.toLowerCase()} ` : ''}experience`
|
||||
: 'Willingness to learn and take direction',
|
||||
english ? `${english.charAt(0).toUpperCase()}${english.slice(1)} English` : 'Clear communication',
|
||||
...certs.map((c) => `${c} — current and verifiable`),
|
||||
'Reliable transportation and consistent attendance',
|
||||
'Comfortable on your feet for a full shift',
|
||||
].filter(Boolean).slice(0, 6);
|
||||
|
||||
const niceToHaves = [
|
||||
`Prior ${hasLocation ? `${location} ` : ''}event experience`,
|
||||
'Bilingual',
|
||||
'Additional certifications beyond the requirement',
|
||||
'Experience training newer staff',
|
||||
].slice(0, 4);
|
||||
|
||||
return { description, responsibilities, qualifications, nice_to_haves: niceToHaves };
|
||||
}
|
||||
|
||||
/* ── 3. Resume builder ─────────────────────────────────────────────────── */
|
||||
|
||||
const SKILL_KEYWORDS = [
|
||||
['bartend', 'Bartending'], ['cocktail', 'Cocktail preparation'], ['serve', 'Table service'],
|
||||
['server', 'Table service'], ['banquet', 'Banquet service'], ['cook', 'Line cooking'],
|
||||
['chef', 'Kitchen leadership'], ['kitchen', 'Kitchen operations'], ['security', 'Event security'],
|
||||
['guard', 'Access control'], ['warehouse', 'Warehouse operations'], ['forklift', 'Forklift operation'],
|
||||
['pick', 'Order picking'], ['host', 'Guest relations'], ['cashier', 'Cash handling'],
|
||||
['clean', 'Sanitation'], ['train', 'Training others'], ['lead', 'Team leadership'],
|
||||
['supervis', 'Supervision'], ['manage', 'People management'], ['wine', 'Wine service'],
|
||||
['catering', 'Catering operations'], ['inventory', 'Inventory control'],
|
||||
];
|
||||
|
||||
const CERT_KEYWORDS = [
|
||||
['servsafe', 'ServSafe'], ['tips', 'TIPS Certified'], ['rbs', 'RBS Alcohol Server'],
|
||||
['guard card', 'Guard Card'], ['cpr', 'CPR / First Aid'], ['first aid', 'CPR / First Aid'],
|
||||
['food handler', 'Food Handler Card'], ['forklift', 'Forklift Operator'], ['osha', 'OSHA 10'],
|
||||
];
|
||||
|
||||
function buildResume(prompt) {
|
||||
const text = quotedBlock(prompt);
|
||||
const lower = text.toLowerCase();
|
||||
|
||||
const yearsMatch = lower.match(/(\d+)\s*(?:\+)?\s*(?:years?|yrs?)/);
|
||||
const years = yearsMatch ? Number(yearsMatch[1]) : 0;
|
||||
|
||||
const skills = [...new Set(
|
||||
SKILL_KEYWORDS.filter(([key]) => lower.includes(key)).map(([, label]) => label)
|
||||
)].slice(0, 8);
|
||||
|
||||
const certifications = [...new Set(
|
||||
CERT_KEYWORDS.filter(([key]) => lower.includes(key)).map(([, label]) => label)
|
||||
)];
|
||||
|
||||
// A capitalized two-word opener is almost always the person naming themselves.
|
||||
const nameMatch = text.match(/\b([A-Z][a-z]+ [A-Z][a-z]+)\b/);
|
||||
const inferredName = nameMatch ? nameMatch[1] : '';
|
||||
|
||||
const primary = skills[0] || 'hospitality';
|
||||
const professionalSummary = text
|
||||
? `${years > 0 ? `${years} year${years === 1 ? '' : 's'} of` : 'Hands-on'} experience in ${primary.toLowerCase()}${skills[1] ? ` and ${skills[1].toLowerCase()}` : ''}. ${certifications.length ? `Holds ${certifications.join(', ')}.` : 'Ready to certify as required.'} Dependable, quick to learn, and comfortable in a fast-moving service environment.`
|
||||
: '';
|
||||
|
||||
const coverLetter = text
|
||||
? `I am applying because this role matches what I already do well. ${years > 0 ? `Over ${years} year${years === 1 ? '' : 's'} I have` : 'I have'} worked in ${primary.toLowerCase()}${skills[1] ? `, with hands-on ${skills[1].toLowerCase()}` : ''}, and I show up ready for the shift I am given.\n\n${certifications.length ? `My ${certifications.join(' and ')} ${certifications.length > 1 ? 'are' : 'is'} current. ` : ''}I would welcome the chance to prove this on a trial shift rather than on paper.`
|
||||
: '';
|
||||
|
||||
return {
|
||||
inferred_name: inferredName,
|
||||
years_experience: years,
|
||||
skills,
|
||||
certifications,
|
||||
professional_summary: professionalSummary,
|
||||
cover_letter: coverLetter,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── 4. Interview questions ────────────────────────────────────────────── */
|
||||
|
||||
const INTERVIEW_QUESTIONS = {
|
||||
en: [
|
||||
(role) => `Thanks for joining. To start — what drew you to ${role} work, and how long have you been doing it?`,
|
||||
() => `Walk me through your busiest shift. What actually happened, and what did you do about it?`,
|
||||
() => `Something goes wrong mid-service and your lead is unreachable. Talk me through your next three moves.`,
|
||||
() => `Tell me about feedback that changed how you work. What did you do differently afterward?`,
|
||||
() => `Last one — what does your schedule realistically look like, and what do you want to be doing a year from now?`,
|
||||
],
|
||||
es: [
|
||||
(role) => `Gracias por acompañarme. Para empezar — ¿qué te atrajo al trabajo de ${role}, y cuánto tiempo llevas haciéndolo?`,
|
||||
() => `Cuéntame de tu turno más ocupado. ¿Qué pasó realmente, y qué hiciste al respecto?`,
|
||||
() => `Algo sale mal a mitad del servicio y no puedes contactar a tu supervisor. Explícame tus siguientes tres pasos.`,
|
||||
() => `Háblame de algún comentario que cambió tu manera de trabajar. ¿Qué hiciste diferente después?`,
|
||||
() => `La última — ¿cómo es tu disponibilidad real, y qué te gustaría estar haciendo en un año?`,
|
||||
],
|
||||
};
|
||||
|
||||
function interviewQuestion(prompt) {
|
||||
const lang = isSpanish(prompt) ? 'es' : 'en';
|
||||
const numberMatch = prompt.match(/Ask question (\d+) of 5/);
|
||||
const index = Math.min(4, Math.max(0, (numberMatch ? Number(numberMatch[1]) : 1) - 1));
|
||||
const roleMatch = prompt.match(/AI interviewer for a (.+?) position/);
|
||||
const role = roleMatch ? roleMatch[1] : 'this';
|
||||
return INTERVIEW_QUESTIONS[lang][index](role);
|
||||
}
|
||||
|
||||
const OWLIVER_QUESTIONS = {
|
||||
en: [
|
||||
(name) => `Hey ${name} — good to meet you. Tell me about yourself, in your own words.`,
|
||||
() => `I like that. Tell me about something you got through that you're genuinely proud of.`,
|
||||
() => `Picture this: you're the only one on shift, 300 guests arrive early, and the kitchen is behind. What do you do?`,
|
||||
() => `What's a piece of feedback that stuck with you — and what changed after it?`,
|
||||
() => `What do you want to be known for in five years?`,
|
||||
() => `That's everything I needed. Anything you want to add before I put this together?`,
|
||||
],
|
||||
es: [
|
||||
(name) => `Hola ${name} — un gusto conocerte. Cuéntame de ti, con tus propias palabras.`,
|
||||
() => `Me gusta eso. Cuéntame de algo que superaste y de lo que estés realmente orgulloso.`,
|
||||
() => `Imagina: estás solo en el turno, llegan 300 invitados antes de tiempo y la cocina va atrasada. ¿Qué haces?`,
|
||||
() => `¿Qué comentario se te quedó grabado — y qué cambió después?`,
|
||||
() => `¿Por qué te gustaría ser reconocido en cinco años?`,
|
||||
() => `Eso es todo lo que necesitaba. ¿Algo que quieras agregar antes de que lo prepare?`,
|
||||
],
|
||||
};
|
||||
|
||||
function owliverQuestion(prompt) {
|
||||
const lang = isSpanish(prompt) ? 'es' : 'en';
|
||||
const numberMatch = prompt.match(/you are on question (\d+)/);
|
||||
const index = Math.min(5, Math.max(0, (numberMatch ? Number(numberMatch[1]) : 1) - 1));
|
||||
const nameMatch = prompt.match(/voice chat with (.+?)\./);
|
||||
const name = nameMatch ? nameMatch[1].split(' ')[0] : 'there';
|
||||
return OWLIVER_QUESTIONS[lang][index](name);
|
||||
}
|
||||
|
||||
/* ── 5. Interview evaluation ───────────────────────────────────────────── */
|
||||
|
||||
/** Scores a transcript on how substantive the candidate's own answers are. */
|
||||
function readTranscript(prompt) {
|
||||
const transcript = prompt.split('TRANSCRIPT:')[1] || '';
|
||||
const answers = transcript
|
||||
.split('\n')
|
||||
.filter((line) => /^Candidate:|^Worker:/.test(line.trim()))
|
||||
.map((line) => line.replace(/^\w+:\s*/, '').trim())
|
||||
.filter(Boolean);
|
||||
const words = answers.reduce((sum, a) => sum + a.split(/\s+/).length, 0);
|
||||
const avgWords = answers.length ? words / answers.length : 0;
|
||||
// Concrete answers name specifics; vague ones stay abstract.
|
||||
const specifics = (transcript.match(/\b\d+\b/g) || []).length;
|
||||
return { answers, words, avgWords, specifics };
|
||||
}
|
||||
|
||||
function evaluateInterview(prompt) {
|
||||
const { answers, avgWords, specifics } = readTranscript(prompt);
|
||||
const fastMatch = prompt.match(/(\d+) candidate response\(s\) were suspiciously fast/);
|
||||
const fast = fastMatch ? Number(fastMatch[1]) : 0;
|
||||
const spanish = isSpanish(prompt);
|
||||
const role = field(prompt, 'Job Title') || 'the role';
|
||||
const candidate = field(prompt, 'Candidate') || 'The candidate';
|
||||
|
||||
// Depth of answers is the dominant signal; specifics lift it, silence sinks it.
|
||||
const depth = clamp(avgWords * 2.1);
|
||||
const engagement = clamp(answers.length * 16);
|
||||
const concreteness = clamp(46 + specifics * 9);
|
||||
const base = clamp(depth * 0.45 + engagement * 0.25 + concreteness * 0.3);
|
||||
|
||||
const integrity = clamp(100 - fast * 12);
|
||||
const flags = [];
|
||||
if (fast >= 2) flags.push(`${fast} responses returned in under 8 seconds`);
|
||||
if (avgWords > 90) flags.push('Answer length and register suggest possible assistance');
|
||||
if (answers.length < 3) flags.push('Interview ended before enough signal was gathered');
|
||||
|
||||
const overall = clamp(base - (fast >= 3 ? 8 : 0));
|
||||
const verdict = overall >= 78 ? 'hire' : overall >= 55 ? 'maybe' : 'no';
|
||||
|
||||
const strengths = spanish
|
||||
? ['Respuestas claras y concretas', 'Se mantuvo tranquilo bajo presión']
|
||||
: ['Clear, concrete answers', 'Stayed composed describing pressure'];
|
||||
const concerns = spanish
|
||||
? ['Profundidad limitada en algunas respuestas']
|
||||
: ['Limited depth on some answers'];
|
||||
if (fast >= 2) concerns.push(spanish ? 'Tiempos de respuesta inusualmente rápidos' : 'Unusually fast response times');
|
||||
|
||||
return {
|
||||
overall_interview_score: overall,
|
||||
verdict,
|
||||
hire_recommendation: spanish
|
||||
? verdict === 'hire'
|
||||
? `Recomiendo avanzar con ${candidate} para ${role}.`
|
||||
: verdict === 'maybe'
|
||||
? `Vale un turno de prueba antes de comprometerse.`
|
||||
: `No recomiendo avanzar para este puesto.`
|
||||
: verdict === 'hire'
|
||||
? `Move forward with ${candidate} for ${role}.`
|
||||
: verdict === 'maybe'
|
||||
? `Worth a trial shift before committing to the season.`
|
||||
: `Not a fit for this posting as interviewed.`,
|
||||
integrity_score: integrity,
|
||||
ai_flags: flags,
|
||||
category_scores: {
|
||||
communication: clamp(depth * 0.6 + concreteness * 0.4),
|
||||
confidence: clamp(base * 0.95),
|
||||
experience_relevance: clamp(concreteness * 0.8 + depth * 0.2),
|
||||
culture_fit: clamp(base * 0.92 + 4),
|
||||
problem_solving: clamp(concreteness * 0.7 + depth * 0.3),
|
||||
personality: clamp(base * 0.98),
|
||||
communication_style: clamp(depth * 0.55 + 30),
|
||||
attendance_expectations: clamp(base * 0.9 + 8),
|
||||
reliability: clamp(base * 0.93 + 6),
|
||||
physical_requirements: clamp(72 + specifics * 2),
|
||||
leadership_expectations: clamp(base * 0.78),
|
||||
scenario_judgment: clamp(concreteness * 0.75 + depth * 0.25),
|
||||
job_related_answers: clamp(base * 0.96),
|
||||
verified_skills: clamp(base * 0.88),
|
||||
},
|
||||
strengths,
|
||||
concerns,
|
||||
best_fit_roles: [role],
|
||||
summary: spanish
|
||||
? `${candidate} dio ${answers.length} respuestas con detalle concreto. El criterio es sólido; la profundidad técnica aún está en desarrollo.`
|
||||
: `${candidate} gave ${answers.length} answers grounded in specifics rather than generalities. Judgment reads sound; technical depth is still developing.`,
|
||||
reasoning: spanish
|
||||
? `Puntuación basada en la profundidad de las respuestas (promedio ${Math.round(avgWords)} palabras) y ${specifics} detalles concretos. Integridad ${integrity} con ${fast} respuesta(s) rápida(s).`
|
||||
: `Scored on answer depth (${Math.round(avgWords)} words average) and ${specifics} concrete details cited. Integrity ${integrity} with ${fast} fast response(s).`,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── 6. Talent matching ────────────────────────────────────────────────── */
|
||||
|
||||
function matchTalent(prompt) {
|
||||
const block = prompt.match(/AVAILABLE TALENT \(JSON\):\s*(\[[\s\S]*?\])\s*\n\nYou are NEVER/);
|
||||
let pool = [];
|
||||
try {
|
||||
pool = block ? JSON.parse(block[1]) : [];
|
||||
} catch {
|
||||
pool = [];
|
||||
}
|
||||
|
||||
const title = (field(prompt, 'Title') || '').toLowerCase();
|
||||
const category = (field(prompt, 'Category') || '').toLowerCase();
|
||||
const minYears = numField(prompt, 'Min Experience');
|
||||
const requiredCerts = listField(prompt, 'Required Certifications').map((c) => c.toLowerCase());
|
||||
|
||||
const scored = pool.map((p) => {
|
||||
const wants = `${p.desired_position || ''} ${p.current_position || ''}`.toLowerCase();
|
||||
const roleFit = (title && wants.includes(title)) || (category && wants.includes(category)) ? 30
|
||||
: (title && title.split(' ').some((w) => w.length > 3 && wants.includes(w))) ? 18
|
||||
: 0;
|
||||
|
||||
const yearsFit = minYears > 0
|
||||
? Math.min(20, ((p.experience_years || 0) / minYears) * 20)
|
||||
: Math.min(20, (p.experience_years || 0) * 3);
|
||||
|
||||
const certs = (p.certifications || []).map((c) => c.toLowerCase());
|
||||
const certFit = requiredCerts.length
|
||||
? (requiredCerts.filter((r) => certs.some((c) => c.includes(r) || r.includes(c))).length / requiredCerts.length) * 15
|
||||
: (certs.length ? 12 : 5);
|
||||
|
||||
const scoreFit = ((p.krow_score || 0) / 100) * 20;
|
||||
const verifiedFit = Math.min(10, (p.capabilities || []).length * 5);
|
||||
const reliabilityFit = ((p.attendance_score || 0) / 100) * 5;
|
||||
|
||||
const match = clamp(roleFit + yearsFit + certFit + scoreFit + verifiedFit + reliabilityFit);
|
||||
|
||||
const reasons = [];
|
||||
if (roleFit >= 18) reasons.push(`Targeting ${p.desired_position || p.current_position} — aligned with this posting`);
|
||||
if ((p.experience_years || 0) >= minYears && minYears > 0) reasons.push(`${p.experience_years} years experience meets the ${minYears}-year minimum`);
|
||||
if ((p.capabilities || []).length) reasons.push(`Verified: ${p.capabilities.join(', ')}`);
|
||||
if ((p.krow_score || 0) >= 75) reasons.push(`Career score ${p.krow_score} with ${p.shifts_completed || 0} shifts completed`);
|
||||
if (!reasons.length) reasons.push(`New to KROW — ${p.experience_years || 0} years experience, no verified evidence yet`);
|
||||
|
||||
return {
|
||||
profile_id: p.id,
|
||||
match_score: match,
|
||||
match_label: match >= 80 ? 'Strong Match' : match >= 65 ? 'Good Match' : match >= 50 ? 'Possible Fit' : 'Weak Fit',
|
||||
reasons: reasons.slice(0, 3),
|
||||
recommendation: match >= 80 ? 'Hire' : match >= 65 ? 'Interview' : match >= 50 ? 'Maybe' : 'Pass',
|
||||
};
|
||||
}).sort((a, b) => b.match_score - a.match_score);
|
||||
|
||||
const qualified = scored.filter((m) => m.match_score >= 50);
|
||||
return { matches: qualified.length ? qualified : scored.slice(0, 5) };
|
||||
}
|
||||
|
||||
/* ── 7. Owliver profile builder ────────────────────────────────────────── */
|
||||
|
||||
/** Job titles a worker might name for themselves, most specific first. */
|
||||
const ROLE_TITLES = [
|
||||
[/banquet captain|captain/, 'Banquet Captain'],
|
||||
[/executive chef/, 'Executive Chef'],
|
||||
[/sous chef/, 'Sous Chef'],
|
||||
[/line cook/, 'Line Cook'],
|
||||
[/\bchef\b/, 'Chef'],
|
||||
[/bartender|bartend/, 'Bartender'],
|
||||
[/barback/, 'Barback'],
|
||||
[/event server|banquet server|\bserver\b/, 'Event Server'],
|
||||
[/security officer|guard/, 'Security Officer'],
|
||||
[/\bhost(ess)?\b/, 'Host'],
|
||||
[/picker|warehouse/, 'Picker'],
|
||||
[/supervisor/, 'Supervisor'],
|
||||
[/manager/, 'Manager'],
|
||||
];
|
||||
|
||||
const AVAILABILITY_TOKENS = [
|
||||
[/weekend|saturday|sunday|sábado|domingo/i, 'Weekends'],
|
||||
[/weekday|monday|tuesday|wednesday|thursday|friday|semana/i, 'Weekdays'],
|
||||
[/evening|night|noche|tarde/i, 'Evenings'],
|
||||
[/morning|mañana|early/i, 'Mornings'],
|
||||
[/overnight|graveyard/i, 'Overnight'],
|
||||
[/on.?call|flexible/i, 'On-Call'],
|
||||
];
|
||||
|
||||
function buildCareerDna(prompt) {
|
||||
const transcript = quotedBlock(prompt);
|
||||
const lower = transcript.toLowerCase();
|
||||
const { answers, avgWords, specifics } = (() => {
|
||||
const lines = transcript.split('\n').filter((l) => /^Worker:/.test(l.trim()));
|
||||
const texts = lines.map((l) => l.replace(/^Worker:\s*/, '').trim()).filter(Boolean);
|
||||
const words = texts.reduce((s, t) => s + t.split(/\s+/).length, 0);
|
||||
return {
|
||||
answers: texts,
|
||||
avgWords: texts.length ? words / texts.length : 0,
|
||||
specifics: (transcript.match(/\b\d+\b/g) || []).length,
|
||||
};
|
||||
})();
|
||||
|
||||
const nameMatch = prompt.match(/worker named (.+?),/) || prompt.match(/worker named (.+?)\b/);
|
||||
const workerName = nameMatch ? nameMatch[1].trim() : 'This worker';
|
||||
const spanish = isSpanish(prompt);
|
||||
|
||||
// People mention several spans ("4 years as captain, 7 years total") — the
|
||||
// largest is the career total the profile wants.
|
||||
const years = (lower.match(/(\d+)\s*(?:\+)?\s*(?:years?|yrs?|años)/g) || [])
|
||||
.map((m) => Number(m.match(/\d+/)[0]))
|
||||
.reduce((max, n) => Math.max(max, n), 0);
|
||||
|
||||
// A stated job title, not a skill label — an empty string is more honest than
|
||||
// labelling someone "Table service".
|
||||
const role = ROLE_TITLES.find(([re]) => re.test(lower));
|
||||
const roleTitle = role ? role[1] : '';
|
||||
|
||||
const skills = [...new Set(
|
||||
SKILL_KEYWORDS.filter(([key]) => lower.includes(key)).map(([, label]) => label)
|
||||
)].slice(0, 8);
|
||||
const certifications = [...new Set(
|
||||
CERT_KEYWORDS.filter(([key]) => lower.includes(key)).map(([, label]) => label)
|
||||
)];
|
||||
const availability = [...new Set(
|
||||
AVAILABILITY_TOKENS.filter(([re]) => re.test(transcript)).map(([, token]) => token)
|
||||
)];
|
||||
|
||||
const languages = ['English'];
|
||||
if (/spanish|español/i.test(transcript) || spanish) languages.push('Spanish');
|
||||
|
||||
// Depth of the conversation is the only honest basis for these scores, so
|
||||
// they stay conservative — the reference prompt is explicit about that.
|
||||
const depth = clamp(avgWords * 1.9);
|
||||
const engagement = clamp(answers.length * 15);
|
||||
const base = clamp(depth * 0.5 + engagement * 0.25 + clamp(40 + specifics * 8) * 0.25);
|
||||
const leadership = clamp(base * 0.7 + (/lead|supervis|manage|train|captain|chef/i.test(lower) ? 20 : 0));
|
||||
|
||||
const transportation = /\b(car|drive|vehicle|truck|coche|carro)\b/i.test(transcript)
|
||||
? 'Own vehicle'
|
||||
: /\b(bus|transit|train|bart|metro)\b/i.test(transcript)
|
||||
? 'Public transit'
|
||||
: '';
|
||||
|
||||
const primary = skills[0] || (spanish ? 'hospitalidad' : 'hospitality');
|
||||
|
||||
return {
|
||||
career_goals: answers.length
|
||||
? spanish
|
||||
? `Crecer en ${primary.toLowerCase()} y asumir más responsabilidad en el equipo.`
|
||||
: `Grow within ${primary.toLowerCase()} and take on more responsibility on the floor.`
|
||||
: '',
|
||||
desired_position: roleTitle,
|
||||
current_position: years > 0 ? roleTitle : '',
|
||||
experience_years: years,
|
||||
experience: years > 0 && roleTitle
|
||||
? [{ company: spanish ? 'Empleador anterior' : 'Previous employer', role: roleTitle, years }]
|
||||
: [],
|
||||
skills,
|
||||
languages,
|
||||
availability,
|
||||
transportation,
|
||||
certifications,
|
||||
personality: answers.length
|
||||
? spanish
|
||||
? 'Directo y tranquilo. Explica su razonamiento sin adornos y asume lo que le corresponde.'
|
||||
: 'Direct and level. Explains their reasoning without embellishment and owns their part.'
|
||||
: '',
|
||||
strengths: answers.length
|
||||
? spanish
|
||||
? ['Mantiene la calma bajo presión', 'Comunica con claridad', 'Toma iniciativa']
|
||||
: ['Keeps composure under pressure', 'Communicates clearly', 'Takes initiative']
|
||||
: [],
|
||||
weaknesses: answers.length
|
||||
? spanish ? ['Aún desarrollando experiencia de liderazgo'] : ['Still building formal leadership experience']
|
||||
: [],
|
||||
industries: skills.length ? ['Hospitality', 'Events'] : [],
|
||||
salary_expectations: '',
|
||||
leadership_potential: leadership,
|
||||
communication_style: answers.length
|
||||
? spanish
|
||||
? 'Conciso y concreto; da ejemplos en lugar de generalidades.'
|
||||
: 'Concise and concrete; reaches for examples rather than generalities.'
|
||||
: '',
|
||||
summary: answers.length
|
||||
? spanish
|
||||
? `${workerName} habló con detalle sobre su trabajo real. Muestra criterio práctico y ganas de crecer.`
|
||||
: `${workerName} talked about real work in real detail. Practical judgment, and clearly interested in getting better at it.`
|
||||
: '',
|
||||
career_dna: {
|
||||
identity: answers.length
|
||||
? spanish
|
||||
? `Alguien que resuelve en el momento y aprende sobre la marcha.`
|
||||
: `Someone who solves problems in the moment and learns on the way through.`
|
||||
: '',
|
||||
learning_speed: clamp(base * 0.9),
|
||||
// Reliability is unverified until real shifts land, so it is held down.
|
||||
reliability: clamp(base * 0.6 + 10),
|
||||
communication_score: clamp(depth * 0.6 + 28),
|
||||
leadership_index: leadership,
|
||||
adaptability_score: clamp(base * 0.95),
|
||||
work_style: /\bteam|equipo\b/i.test(lower) ? 'Team player' : /\balone|independ\b/i.test(lower) ? 'Independent' : 'Operator',
|
||||
culture_match: spanish
|
||||
? ['Equipos con expectativas claras', 'Operaciones de ritmo rápido']
|
||||
: ['Teams with clear expectations', 'Fast-paced service operations'],
|
||||
// Baseline trust from one conversation — deliberately modest.
|
||||
reputation_score: clamp(30 + base * 0.35),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/* ── 8. Proving Ground evaluator ───────────────────────────────────────── */
|
||||
|
||||
function evaluateChallenge(prompt) {
|
||||
const criteriaMatch = prompt.match(/rubric" object: (.+?)\.\n/);
|
||||
const criteria = criteriaMatch
|
||||
? criteriaMatch[1].split(',').map((c) => c.trim()).filter(Boolean)
|
||||
: ['overall_performance'];
|
||||
|
||||
const isPhotoIdentify = /marked these hazards/.test(prompt);
|
||||
const isMedia = /uploaded a (photo|video)/.test(prompt);
|
||||
|
||||
let base;
|
||||
let strengths;
|
||||
let concerns;
|
||||
|
||||
if (isPhotoIdentify) {
|
||||
const marks = (prompt.match(/^- "/gm) || []).length;
|
||||
// Real hazard scenes have a handful of genuine risks; marking everything is
|
||||
// as wrong as marking nothing.
|
||||
base = clamp(marks === 0 ? 18 : 46 + Math.min(38, marks * 13) - Math.max(0, marks - 4) * 9);
|
||||
strengths = marks ? [`Identified ${marks} hazard${marks === 1 ? '' : 's'}`] : [];
|
||||
concerns = marks === 0
|
||||
? ['No hazards marked']
|
||||
: marks > 4
|
||||
? ['Several marks are likely false positives']
|
||||
: ['Some lower-visibility risks were missed'];
|
||||
} else if (isMedia) {
|
||||
base = 74;
|
||||
strengths = ['Submitted a clear demonstration', 'Followed the challenge brief'];
|
||||
concerns = ['Some steps happen off-camera and cannot be verified'];
|
||||
} else {
|
||||
const { answers, avgWords, specifics } = readTranscript(
|
||||
`TRANSCRIPT:\n${quotedBlock(prompt).replace(/^Worker:/gm, 'Candidate:')}`
|
||||
);
|
||||
const spoken = answers.length ? answers : quotedBlock(prompt) ? ['x'] : [];
|
||||
base = spoken.length
|
||||
? clamp(avgWords * 2.4 + specifics * 6 + 20)
|
||||
: 0;
|
||||
strengths = base >= 70
|
||||
? ['Went straight to a concrete remedy', 'Kept the guest experience intact']
|
||||
: base > 0 ? ['Engaged with the scenario'] : [];
|
||||
concerns = base >= 70
|
||||
? ['Could state the escalation path explicitly']
|
||||
: ['Response lacks the specifics an employer would need to trust it'];
|
||||
}
|
||||
|
||||
const verdict = base >= 70 ? 'verified' : base >= 50 ? 'needs_work' : 'failed';
|
||||
|
||||
// Spread the overall score across the rubric so criteria are not identical.
|
||||
const rubric = {};
|
||||
criteria.forEach((criterion, i) => {
|
||||
rubric[criterion] = clamp(base + (i % 3 === 0 ? 4 : i % 3 === 1 ? -3 : 1));
|
||||
});
|
||||
|
||||
return {
|
||||
verdict,
|
||||
score: base,
|
||||
rubric,
|
||||
feedback: verdict === 'verified'
|
||||
? `This holds up. You handled the core of the scenario the way the job actually needs it handled. Tighten the closing steps and this becomes strong evidence.`
|
||||
: verdict === 'needs_work'
|
||||
? `You are close. The instinct is right, but an employer reading this could not yet tell you would do it consistently. Be more specific about what you do, in what order.`
|
||||
: `This does not yet demonstrate the skill. Work through the scenario again and walk through your actual steps out loud, in order, with specifics.`,
|
||||
strengths,
|
||||
concerns,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Router ────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Recognizes each workflow by the phrase its prompt opens with. Order matters
|
||||
* only in that every branch is mutually exclusive by design.
|
||||
*/
|
||||
const ROUTES = [
|
||||
[/^You are KROW's AI screening engine/m, screenCandidate, 1100],
|
||||
[/^You are an expert hiring copywriter/m, generateJobDescription, 1400],
|
||||
[/Extract and infer a structured resume/m, buildResume, 1200],
|
||||
[/^You are "KROW", a friendly but sharp AI interviewer/m, interviewQuestion, 700],
|
||||
[/^You are KROW's proactive talent-matching engine/m, matchTalent, 1300],
|
||||
[/^You are KROW's AI interview evaluator/m, evaluateInterview, 1500],
|
||||
[/^You are "Owliver"/m, owliverQuestion, 800],
|
||||
[/^You are Owliver's Career DNA engine/m, buildCareerDna, 1600],
|
||||
[/^You are KROW's Proving Ground evaluator/m, evaluateChallenge, 1400],
|
||||
[/^You are "KROW", running a short proving-ground challenge/m,
|
||||
() => 'Walk me through the exact order you would do that in — what happens first, and who do you tell?', 700],
|
||||
];
|
||||
|
||||
/** Drop-in replacement for `integrations.Core.InvokeLLM`. */
|
||||
export async function invokeLLM({ prompt = '', response_json_schema: schema } = {}) {
|
||||
const route = ROUTES.find(([pattern]) => pattern.test(prompt));
|
||||
|
||||
if (!route) {
|
||||
// An unrecognized prompt should surface loudly in the demo rather than
|
||||
// silently returning a shape the caller cannot use.
|
||||
console.warn('[krow-demo] Unrecognized AI prompt — returning an empty result.', prompt.slice(0, 120));
|
||||
await think(400);
|
||||
return schema ? {} : '';
|
||||
}
|
||||
|
||||
const [, handler, latency] = route;
|
||||
await think(latency);
|
||||
return handler(prompt);
|
||||
}
|
||||
|
||||
/** Drop-in replacement for `integrations.Core.UploadFile`. */
|
||||
export async function uploadFile({ file } = {}) {
|
||||
await think(500);
|
||||
if (!file) return { file_url: '' };
|
||||
// A blob URL keeps uploaded media viewable for the rest of the session
|
||||
// without any storage backend.
|
||||
return { file_url: URL.createObjectURL(file), file_name: file.name, file_size: file.size };
|
||||
}
|
||||
104
src/api/base44Client.js
Normal file
104
src/api/base44Client.js
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Application data client.
|
||||
*
|
||||
* The reference app talks to a Base44 backend through this module. The demo
|
||||
* keeps the module path, the export name, and the full method contract, and
|
||||
* swaps the transport for the local store in `store.js` and the local AI engine
|
||||
* in `aiEngine.js`. Nothing downstream — hooks, pages, components — knows or
|
||||
* cares, which is exactly the point: the seam stays where it was.
|
||||
*/
|
||||
|
||||
import { createEntity, initStore, resetStore } from './store';
|
||||
import { invokeLLM, uploadFile } from './aiEngine';
|
||||
import { DEMO_USER, seedData } from './seed';
|
||||
|
||||
initStore(seedData);
|
||||
|
||||
const ENTITY_NAMES = [
|
||||
'JobPosting', 'JobApplication', 'AIInterview', 'Staff', 'WorkerProfile',
|
||||
'Course', 'Badge', 'LearningPath', 'Certification', 'RoleCategory',
|
||||
'UserActivity', 'Evidence', 'User',
|
||||
];
|
||||
|
||||
const entities = Object.fromEntries(
|
||||
ENTITY_NAMES.map((name) => [name, createEntity(name)])
|
||||
);
|
||||
|
||||
/* ── Auth ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
const SESSION_KEY = 'krow_demo_user';
|
||||
|
||||
function loadUser() {
|
||||
try {
|
||||
const raw = localStorage.getItem(SESSION_KEY);
|
||||
return raw ? { ...DEMO_USER, ...JSON.parse(raw) } : { ...DEMO_USER };
|
||||
} catch {
|
||||
return { ...DEMO_USER };
|
||||
}
|
||||
}
|
||||
|
||||
let currentUser = loadUser();
|
||||
|
||||
const auth = {
|
||||
/** The demo is always signed in as the seeded employer/admin user. */
|
||||
async me() {
|
||||
return { ...currentUser };
|
||||
},
|
||||
|
||||
async updateMe(patch) {
|
||||
currentUser = { ...currentUser, ...patch };
|
||||
try {
|
||||
localStorage.setItem(SESSION_KEY, JSON.stringify(currentUser));
|
||||
} catch {
|
||||
// Non-persistent session is fine.
|
||||
}
|
||||
return { ...currentUser };
|
||||
},
|
||||
|
||||
isAuthenticated() {
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* There is no identity provider to sign out of, so this clears the local
|
||||
* session and returns to the requested page.
|
||||
*/
|
||||
logout(redirectTo = '/') {
|
||||
try {
|
||||
localStorage.removeItem(SESSION_KEY);
|
||||
} catch {
|
||||
// Ignore.
|
||||
}
|
||||
currentUser = { ...DEMO_USER };
|
||||
window.location.href = typeof redirectTo === 'string' ? redirectTo : '/';
|
||||
},
|
||||
|
||||
redirectToLogin() {
|
||||
window.location.href = '/';
|
||||
},
|
||||
};
|
||||
|
||||
/* ── Integrations & analytics ───────────────────────────────────────────── */
|
||||
|
||||
const integrations = {
|
||||
Core: {
|
||||
InvokeLLM: invokeLLM,
|
||||
UploadFile: uploadFile,
|
||||
},
|
||||
};
|
||||
|
||||
const analytics = {
|
||||
track({ eventName, properties } = {}) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.debug('[krow-demo] analytics', eventName, properties || {});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const base44 = { entities, auth, integrations, analytics };
|
||||
|
||||
/** Restores the shipped demo data, discarding local edits. */
|
||||
export function resetDemoData() {
|
||||
resetStore(seedData);
|
||||
window.location.reload();
|
||||
}
|
||||
1418
src/api/seed.js
Normal file
1418
src/api/seed.js
Normal file
File diff suppressed because it is too large
Load Diff
173
src/api/store.js
Normal file
173
src/api/store.js
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* In-memory entity store backing the KROW demo.
|
||||
*
|
||||
* Mirrors the Base44 entity API the app was built against
|
||||
* (`list` / `filter` / `get` / `create` / `update` / `delete`) so every hook,
|
||||
* page and component consumes data exactly as it did against the real backend.
|
||||
* Records live in memory and are mirrored to localStorage, so demo edits —
|
||||
* screening a candidate, hiring, creating a position — survive a reload.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'krow_demo_db';
|
||||
const STORAGE_VERSION = 3;
|
||||
|
||||
/** Simulated network latency, in ms, so loading states are real. */
|
||||
const LATENCY = { read: 140, write: 220 };
|
||||
|
||||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/** Structured clone with a JSON fallback for older Safari. */
|
||||
const clone = (value) =>
|
||||
typeof structuredClone === 'function'
|
||||
? structuredClone(value)
|
||||
: JSON.parse(JSON.stringify(value));
|
||||
|
||||
let _counter = 0;
|
||||
export function makeId(prefix = 'rec') {
|
||||
_counter += 1;
|
||||
return `${prefix}_${Date.now().toString(36)}${_counter.toString(36).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
/* ── Persistence ───────────────────────────────────────────────────────── */
|
||||
|
||||
let db = {};
|
||||
|
||||
function persist() {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ version: STORAGE_VERSION, db }));
|
||||
} catch {
|
||||
// Private browsing / quota — the demo still works from memory.
|
||||
}
|
||||
}
|
||||
|
||||
function readPersisted() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
// A seed-data change bumps STORAGE_VERSION, which invalidates stale copies.
|
||||
return parsed?.version === STORAGE_VERSION ? parsed.db : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the store: persisted snapshot if one matches the current seed version,
|
||||
* otherwise the seed itself.
|
||||
*/
|
||||
export function initStore(seed) {
|
||||
db = readPersisted() || clone(seed);
|
||||
// A seed that gained an entity after the snapshot was written still resolves.
|
||||
for (const [name, records] of Object.entries(seed)) {
|
||||
if (!db[name]) db[name] = clone(records);
|
||||
}
|
||||
persist();
|
||||
}
|
||||
|
||||
/** Drops all local edits and restores the shipped demo data. */
|
||||
export function resetStore(seed) {
|
||||
db = clone(seed);
|
||||
persist();
|
||||
}
|
||||
|
||||
/* ── Query helpers ─────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Applies a Base44-style sort string: `'-ai_score'` descending,
|
||||
* `'created_date'` ascending. Unknown fields leave order untouched.
|
||||
*/
|
||||
function applySort(records, sort) {
|
||||
if (!sort) return records;
|
||||
const desc = sort.startsWith('-');
|
||||
const field = desc ? sort.slice(1) : sort;
|
||||
return [...records].sort((a, b) => {
|
||||
const av = a?.[field];
|
||||
const bv = b?.[field];
|
||||
if (av === bv) return 0;
|
||||
if (av === undefined || av === null) return 1;
|
||||
if (bv === undefined || bv === null) return -1;
|
||||
const result = typeof av === 'number' && typeof bv === 'number'
|
||||
? av - bv
|
||||
: String(av).localeCompare(String(bv));
|
||||
return desc ? -result : result;
|
||||
});
|
||||
}
|
||||
|
||||
/** Shallow equality match across every key of `query`, array fields included. */
|
||||
function matches(record, query) {
|
||||
return Object.entries(query).every(([key, want]) => {
|
||||
const got = record?.[key];
|
||||
if (Array.isArray(want)) return want.includes(got);
|
||||
return got === want;
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Entity API ────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Builds the client surface for one entity. Every method is async and returns
|
||||
* cloned records, so callers can never mutate the store by reference.
|
||||
*/
|
||||
export function createEntity(name) {
|
||||
const table = () => (db[name] ||= []);
|
||||
|
||||
return {
|
||||
entityName: name,
|
||||
|
||||
async list(sort = '-created_date', limit = 100) {
|
||||
await delay(LATENCY.read);
|
||||
return clone(applySort(table(), sort).slice(0, limit));
|
||||
},
|
||||
|
||||
async filter(query = {}, sort = '-created_date', limit = 100) {
|
||||
await delay(LATENCY.read);
|
||||
const found = table().filter((record) => matches(record, query));
|
||||
return clone(applySort(found, sort).slice(0, limit));
|
||||
},
|
||||
|
||||
async get(id) {
|
||||
await delay(LATENCY.read);
|
||||
const found = table().find((record) => record.id === id);
|
||||
if (!found) throw new Error(`${name} ${id} not found`);
|
||||
return clone(found);
|
||||
},
|
||||
|
||||
async create(data) {
|
||||
await delay(LATENCY.write);
|
||||
const now = new Date().toISOString();
|
||||
const record = {
|
||||
id: makeId(name.toLowerCase()),
|
||||
created_date: now,
|
||||
updated_date: now,
|
||||
...data,
|
||||
};
|
||||
table().unshift(record);
|
||||
persist();
|
||||
return clone(record);
|
||||
},
|
||||
|
||||
async update(id, data) {
|
||||
await delay(LATENCY.write);
|
||||
const index = table().findIndex((record) => record.id === id);
|
||||
if (index === -1) throw new Error(`${name} ${id} not found`);
|
||||
const next = { ...table()[index], ...data, updated_date: new Date().toISOString() };
|
||||
table()[index] = next;
|
||||
persist();
|
||||
return clone(next);
|
||||
},
|
||||
|
||||
async delete(id) {
|
||||
await delay(LATENCY.write);
|
||||
db[name] = table().filter((record) => record.id !== id);
|
||||
persist();
|
||||
return { id };
|
||||
},
|
||||
|
||||
async bulkCreate(records = []) {
|
||||
const created = [];
|
||||
for (const record of records) created.push(await this.create(record));
|
||||
return created;
|
||||
},
|
||||
};
|
||||
}
|
||||
9
src/assets/brand.js
Normal file
9
src/assets/brand.js
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Krow brand assets, served from `public/` so the dashboard has no remote image
|
||||
* dependency. Referenced through this module rather than inline strings so a
|
||||
* swap is a one-line change.
|
||||
*
|
||||
* Owliver is a separate product and owns its own assets — nothing here.
|
||||
*/
|
||||
|
||||
export const KROW_LOGO_URL = '/krow-logo.webp';
|
||||
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>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user