first commit
This commit is contained in:
42
.dockerignore
Normal file
42
.dockerignore
Normal file
@@ -0,0 +1,42 @@
|
||||
# Build context excludes — keep the image reproducible and free of host state.
|
||||
#
|
||||
# The Dockerfile installs dependencies with `npm ci` and then `COPY . .`. Without
|
||||
# this file that copy would drop the host's node_modules on top of the ones just
|
||||
# installed in the image: those are built for the host (macOS/arm64), the image
|
||||
# is Linux, and esbuild/rollup ship native binaries — so `npm run build` would
|
||||
# fail or emit a broken bundle. It would also bake `.env` into the image, where
|
||||
# Vite would read it and override the API URLs passed as build args.
|
||||
|
||||
# Dependencies — reinstalled in the image from the lockfile
|
||||
node_modules
|
||||
|
||||
# Build output — produced inside the image by `npm run build`
|
||||
dist
|
||||
dist-ssr
|
||||
|
||||
# Local environment / secrets — never baked into the image; API URLs come from
|
||||
# build args (see the Dockerfile). Keep .env.example so the repo still documents
|
||||
# the knobs, but it is not needed in the build either.
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Version control and project metadata
|
||||
.git
|
||||
.gitignore
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
*.md
|
||||
README*
|
||||
|
||||
# Editor / OS cruft
|
||||
.vscode
|
||||
.idea
|
||||
.DS_Store
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Tooling caches
|
||||
.eslintcache
|
||||
coverage
|
||||
.cache
|
||||
17
.env.example
Normal file
17
.env.example
Normal file
@@ -0,0 +1,17 @@
|
||||
# The API base the browser calls. Keep this a SAME-ORIGIN PATH in development.
|
||||
#
|
||||
# The session is an HttpOnly, SameSite=Lax cookie. A Lax cookie is not sent on a
|
||||
# cross-site request, and a browser treats localhost:5173 and 127.0.0.1:8080 as
|
||||
# different sites — so an absolute URL here means every call after login arrives
|
||||
# without the cookie and is answered 401. Vite proxies this path instead
|
||||
# (see vite.config.js), which makes the cookie first-party.
|
||||
VITE_API_BASE_URL=/api/v1
|
||||
|
||||
# Where the Vite dev proxy forwards /api and /health. Read by vite.config.js
|
||||
# only; never by client code.
|
||||
VITE_API_PROXY_TARGET=http://127.0.0.1:8080
|
||||
|
||||
# The agent run endpoint Owliver posts to. Same origin, same reason.
|
||||
# Leave it empty to run without the assistant: the panel then says so plainly
|
||||
# rather than failing at request time.
|
||||
VITE_AGENT_API=/api/v1
|
||||
38
.gitignore
vendored
Normal file
38
.gitignore
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
# Local configuration is never committed. This rule read "#env" — commented out,
|
||||
# so it matched nothing and every .env in this directory was committable. The
|
||||
# `!.env.example` negation below only makes sense against a rule that excludes
|
||||
# them, which is what gave it away.
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# ...but the template belongs in the repository: it is the one place the API's
|
||||
# location is documented, and a checkout with no .env needs it.
|
||||
!.env.example
|
||||
|
||||
# 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?
|
||||
|
||||
|
||||
base44/.app.jsonc
|
||||
66
Dockerfile
Normal file
66
Dockerfile
Normal file
@@ -0,0 +1,66 @@
|
||||
# ---- Build stage ----
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Safety valve for the 2 GB host: without a cap a runaway build can exhaust the
|
||||
# server's RAM and take down neighbouring containers. Measured peak for this app
|
||||
# is ~550 MB, so 1 GB leaves headroom while still failing the build rather than
|
||||
# the server. Raise this only if the host gets more RAM.
|
||||
ENV NODE_OPTIONS=--max-old-space-size=1024
|
||||
|
||||
# Install deps from the lockfile first so this layer is reused when only src changes
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --no-audit --no-fund
|
||||
|
||||
COPY . .
|
||||
|
||||
# The API origin, baked into the bundle at build time.
|
||||
#
|
||||
# Vite inlines VITE_* variables during `npm run build`; there is no runtime
|
||||
# configuration for a static bundle. It has to be an environment variable here
|
||||
# because .dockerignore excludes .env*, so no env file ever reaches this stage —
|
||||
# and without a value the client falls back to a relative /api/v1, which the
|
||||
# nginx runtime stage below serves as a static path. That answers a login POST
|
||||
# with 405 Method Not Allowed, because static file serving permits only
|
||||
# GET/HEAD.
|
||||
#
|
||||
# It must include the /api/v1 suffix: httpClient.js builds each URL as
|
||||
# `${API_BASE_URL}${path}` where path is `/auth/login`.
|
||||
#
|
||||
# Override per environment with:
|
||||
# docker build --build-arg VITE_API_BASE_URL=https://other.example.com/api/v1 .
|
||||
ARG VITE_API_BASE_URL=https://mcp.krowforce.com/api/v1
|
||||
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
||||
|
||||
# Owliver's agent endpoint, for exactly the same reason and with exactly the
|
||||
# same failure if it is missing.
|
||||
#
|
||||
# In development this is left relative and vite's proxy carries it to the
|
||||
# backend, which is why the .env in the repository sets `/api/v1`. That value
|
||||
# cannot reach this build — .dockerignore excludes .env* — and a relative path
|
||||
# here would be served by the nginx stage as a static file, so a run POST would
|
||||
# come back 405 Method Not Allowed rather than an answer.
|
||||
#
|
||||
# Empty is not neutral either: with no value the panel reports that no agent is
|
||||
# configured, which is honest but is not what a deployment carrying agents
|
||||
# should say.
|
||||
ARG VITE_AGENT_API=https://mcp.krowforce.com/api/v1
|
||||
ENV VITE_AGENT_API=$VITE_AGENT_API
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# ---- Runtime stage ----
|
||||
FROM nginx:alpine
|
||||
|
||||
# Remove Nginx's default "Welcome" page files completely
|
||||
RUN rm -rf /usr/share/nginx/html/*
|
||||
|
||||
# Only the compiled assets reach the final image — no node_modules, no source
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
EXPOSE 80 3000
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
209
README.md
Normal file
209
README.md
Normal file
@@ -0,0 +1,209 @@
|
||||
# krow-employee
|
||||
|
||||
The KROW Employer console — the hiring surface for `platform.krowforce.com`.
|
||||
|
||||
A standalone React frontend over the existing KROW Go backend. It has no backend
|
||||
and no database of its own: every figure on every page is read from
|
||||
`/api/v1`, and every write goes back through the same API.
|
||||
|
||||
---
|
||||
|
||||
## Running it
|
||||
|
||||
`npm run dev` starts the API for you. It checks the host that `vite.config.js`
|
||||
proxies `/api` to, and if nothing is listening there it launches
|
||||
`../krow-backend`, waits for `/health`, and only then starts Vite — so the dev
|
||||
server never comes up pointing at an API that does not exist.
|
||||
|
||||
```bash
|
||||
# One-time backend setup (separate repo, ../krow-backend)
|
||||
cd ../krow-backend
|
||||
make migrate-up && make seed
|
||||
make import-agents ORG=krow-dev # required, or Owliver 404s
|
||||
make ingest ORG=krow-dev # required, or retrieval finds nothing
|
||||
|
||||
# This app — starts the API too
|
||||
cd ../Krow-employee
|
||||
npm install
|
||||
cp .env.example .env
|
||||
npm run dev # http://localhost:5173
|
||||
```
|
||||
|
||||
PostgreSQL still has to be running; the API needs it and will not start without
|
||||
it. `pg_isready` should say `accepting connections`.
|
||||
|
||||
| Variable | Meaning |
|
||||
|---|---|
|
||||
| `KROW_BACKEND_DIR` | the API repo, if it is not `../krow-backend` |
|
||||
| `KROW_SKIP_API_CHECK=1` | skip the preflight |
|
||||
|
||||
`npm run dev:vite` runs Vite alone, for frontend work with no API.
|
||||
|
||||
### `502 Bad Gateway` on `/api/v1/...`
|
||||
|
||||
This means nothing is listening on the proxy target — the API is a *separate
|
||||
process*, and a frontend that looks perfectly healthy will still 502 on every
|
||||
call without it. It is not a backend bug and not a CORS problem.
|
||||
|
||||
`npm run dev` now prevents it at startup. If you see it anyway, the API exited
|
||||
mid-session; the terminal running `npm run dev` prints the target and the fix,
|
||||
and the response body carries `code: "api_not_running"` rather than a generic
|
||||
status-502 failure. Restart with `npm run dev`, or `cd ../krow-backend && make run`.
|
||||
|
||||
`GET /api/v1/version` reports the endpoint count. Owliver's agent routes are
|
||||
registered only when `ANTHROPIC_API_KEY` is set on the backend; without it the
|
||||
assistant panel says so plainly instead of failing at request time.
|
||||
|
||||
### Signing in
|
||||
|
||||
The seed ships one user, `demo@krow.app`, with role `admin` and **no password** —
|
||||
passwords never travel in the seed. Set one, and add an employer to test the role
|
||||
gate against:
|
||||
|
||||
```sql
|
||||
-- only if employer@krow.app does not already exist
|
||||
INSERT INTO users (org_id, email, full_name, role, account_type, status)
|
||||
SELECT id, 'employer@krow.app', 'Employer Demo', 'employer', 'employer', 'active'
|
||||
FROM organizations WHERE slug = 'krow-dev';
|
||||
```
|
||||
|
||||
```bash
|
||||
cd ../krow-backend/go-api
|
||||
go run ./cmd/setpassword -email employer@krow.app
|
||||
```
|
||||
|
||||
Without an `employer` user every local session runs as `admin`, and the role gate
|
||||
in `routes/EmployerRoute.jsx` is never actually exercised.
|
||||
|
||||
---
|
||||
|
||||
## Why the dev server proxies the API
|
||||
|
||||
`VITE_API_BASE_URL` must stay a **same-origin path** (`/api/v1`) in development.
|
||||
|
||||
The session is an HttpOnly, `SameSite=Lax` cookie. A Lax cookie is not sent on a
|
||||
cross-site request, and a browser treats `localhost:5173` and `127.0.0.1:8080` as
|
||||
different sites — so an absolute URL there means every call after login arrives
|
||||
without the cookie and is answered `401`. `vite.config.js` proxies `/api` and
|
||||
`/health` instead, which makes the cookie first-party. Nothing in client code
|
||||
names a backend host.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Employer UI → lib/krowHooks.js → api/krowClient.js → api/httpClient.js
|
||||
(TanStack Query) (entities, auth, (fetch, credentials:
|
||||
workflows) 'include')
|
||||
↓
|
||||
Existing KROW Go API → PostgreSQL
|
||||
```
|
||||
|
||||
| Directory | What lives there |
|
||||
|---|---|
|
||||
| `src/api/` | `httpClient.js` (fetch, `{data, meta}` unwrapping, `KrowApiError`), `krowClient.js` (entities, auth, workflows, Owliver suggestions), `aiEngine.js` |
|
||||
| `src/components/ds/` | The KROW design system. One import surface: `@/components/ds` |
|
||||
| `src/components/ui/` | Radix primitives, restyled to the tokens |
|
||||
| `src/components/ai-assistant/` | Owliver: the panel, the SSE transport, the block vocabulary |
|
||||
| `src/components/krow/` | Domain components — candidate cards, talent modals, analytics blocks |
|
||||
| `src/lib/` | Hooks, scoring, the hiring ladder, the skill and agent registries |
|
||||
| `src/agents/`, `src/skills/` | Markdown definitions, loaded by `import.meta.glob` at build time |
|
||||
| `src/pages/hiring/` | The hiring pages |
|
||||
|
||||
### Routes
|
||||
|
||||
`/login` · `/dashboard` · `/positions` · `/positions/new` · `/positions/:id` ·
|
||||
`/candidates` · `/candidates/:id` · `/hired` · `/talent-pool` · `/analytics` ·
|
||||
`/activity` · `/profile` · `/settings`
|
||||
|
||||
### Canonical paths — read this before editing a route
|
||||
|
||||
Route literals in the source are written in **canonical** form (`/admin/positions`),
|
||||
not the addresses this app serves (`/positions`). That is deliberate.
|
||||
|
||||
This console was lifted out of `krow-demo`, where the same hiring pages are
|
||||
mounted twice — once under `/admin` and once under `/employer`. Well over a
|
||||
hundred route literals are written against `/admin`, and not only in components:
|
||||
`lib/skills/registry.js`, `lib/skills/actions.js` and `components/ai-assistant/`
|
||||
emit and match canonical routes too, and `pageKeyForRoute` derives from them the
|
||||
page id the backend's `GET /owliver/suggestions?page=` accepts — a closed
|
||||
vocabulary this app does not own (`control-center`, `positions`, `hired-history`…).
|
||||
|
||||
So `lib/product.js` translates in one place instead:
|
||||
|
||||
- `rebase('/admin/positions')` → `/positions` — on the way out, at every link
|
||||
- `canonicalize('/positions')` → `/admin/positions` — on the way in, for the
|
||||
placement, skill and agent tables
|
||||
|
||||
`useProduct.js` is the hook layer. A page still says
|
||||
`navigate('/admin/positions/123')`; the address bar says `/positions/123`.
|
||||
|
||||
---
|
||||
|
||||
## What this console deliberately does not have
|
||||
|
||||
The backend has no endpoint for any of these, so none of them are built. A
|
||||
control that looks live and does nothing is worse than an absent one.
|
||||
|
||||
- **Interview scheduling and candidate messaging.** No table, no endpoint. The
|
||||
card offers a real `tel:` dialler and real stage transitions instead.
|
||||
- **Notifications.** Nothing in the backend sends any. The Activity page and
|
||||
Owliver's suggestions carry "what needs attention".
|
||||
- **Password change, 2FA, session management.** Passwords are set by the
|
||||
`setpassword` CLI on the server.
|
||||
- **Organisation settings, team members, billing.** No `/organizations` or
|
||||
`/users` route exists.
|
||||
- **File and avatar upload.** `selfie_url` and friends are plain text columns.
|
||||
- **Deleting a position.** `DELETE /job-postings/{id}` answers 405 by design.
|
||||
|
||||
Analytics is computed in the browser from raw list endpoints, because the
|
||||
backend has no aggregate routes and no date-range filters. The SQL for those
|
||||
aggregations already exists in `krow-backend/go-api/internal/tools/*.go` and
|
||||
would be the natural extension when data volume outgrows the 1000-row cap.
|
||||
|
||||
AI screening, job-description generation and talent matching run through
|
||||
`api/aiEngine.js`, a deterministic local stand-in, and persist to the real
|
||||
score columns. It sits behind `krow.integrations.Core.InvokeLLM`, so replacing
|
||||
it with a server endpoint is a one-file change.
|
||||
|
||||
---
|
||||
|
||||
## Data traps
|
||||
|
||||
These come from the backend's own handover notes and have produced wrong numbers
|
||||
before. The ladder lives once, in `src/lib/hiringRecords.js`, and everything
|
||||
counts through it.
|
||||
|
||||
- **`screened_at` is vestigial.** Nothing writes it. Screened means the
|
||||
application is at or beyond `ai_screened` — use `atOrBeyond`.
|
||||
- **A score of `0` means "not rated", never "rated zero"** — `ai_score`,
|
||||
`client_rating`, `krow_score`, `reliability_score`, `attendance_score`,
|
||||
`performance_score`, `experience_years`. Average with a `> 0` filter and state
|
||||
the basis count.
|
||||
- **Genuine zeros, do not filter:** `overtime_hours`, `minutes_late`, `xp`,
|
||||
`profile_completion`, `actual_hours`.
|
||||
- **`rejected` still counts as screened.** It is terminal from any stage and
|
||||
overwrites the stage it was reached from; `rankOf` puts it back at the
|
||||
screened rung.
|
||||
- **`assigned` counts as hired** — a hire who has been rostered. `HIRED_STATUSES`.
|
||||
- **`attendance_score` defaults to 100** and must never be a scoring input.
|
||||
- **Array columns are not server-filterable** — `skills`, `certifications`,
|
||||
`availability`. Filter them client-side.
|
||||
- **Most resources have no `GET /{id}`.** Only `job-postings` and `courses` do;
|
||||
everything else answers 405. `createEntity` uses `?id=<uuid>` on the list
|
||||
route, so pages do not have to know.
|
||||
|
||||
---
|
||||
|
||||
## Checks
|
||||
|
||||
```bash
|
||||
npm run build # production bundle
|
||||
npm run lint # eslint, must be clean
|
||||
npm run typecheck # tsc over JSDoc; see the note below
|
||||
```
|
||||
|
||||
`typecheck` reports pre-existing errors inherited from `krow-demo` (mostly
|
||||
`InvokeLLM` call shapes and `import.meta.glob`, which `tsc` does not model). It
|
||||
is a signal to read, not a gate that currently passes.
|
||||
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"
|
||||
}
|
||||
61
eslint.config.js
Normal file
61
eslint.config.js
Normal file
@@ -0,0 +1,61 @@
|
||||
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/layouts/**/*.{js,mjs,cjs,jsx}",
|
||||
"src/hooks/**/*.{js,mjs,cjs,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/png" href="/favicon.png" />
|
||||
<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 — Hiring</title>
|
||||
<meta name="description" content="KROW for hiring teams — post roles, screen candidates and build your workforce." />
|
||||
</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/**/*"],
|
||||
"exclude": ["node_modules", "dist", "src/components/ui", "src/api", "src/lib"]
|
||||
}
|
||||
70
nginx.conf
Normal file
70
nginx.conf
Normal file
@@ -0,0 +1,70 @@
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
|
||||
error_log /dev/stderr warn;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
access_log /dev/stdout;
|
||||
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
keepalive_timeout 65;
|
||||
server_tokens off;
|
||||
|
||||
# Compression: takes the main JS bundle from ~1.9 MB down to ~530 kB on the wire
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_min_length 1024;
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
text/xml
|
||||
text/javascript
|
||||
application/javascript
|
||||
application/json
|
||||
application/xml
|
||||
image/svg+xml
|
||||
font/woff
|
||||
font/woff2;
|
||||
|
||||
server {
|
||||
# 80 is the conventional port; 3000 is what Dokploy targets by default in
|
||||
# its domain config. Binding both means Traefik reaches the app whichever
|
||||
# one the dashboard's Port field is set to, so a deploy can't 502 purely
|
||||
# because those two numbers disagree.
|
||||
listen 80;
|
||||
listen 3000;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Vite writes content-hashed filenames here, so they can be cached forever.
|
||||
# Only add_header is used — combining it with `expires` emits two
|
||||
# conflicting Cache-Control headers.
|
||||
location /assets/ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# index.html must be revalidated every time or deploys won't take effect
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache";
|
||||
}
|
||||
|
||||
location / {
|
||||
# This line forces Nginx to pass routing back to React Router
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
}
|
||||
8171
package-lock.json
generated
Normal file
8171
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
63
package.json
Normal file
63
package.json
Normal file
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "krow-employee",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node scripts/dev.mjs",
|
||||
"dev:vite": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint . --quiet",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"typecheck": "tsc -p ./jsconfig.json",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/material": "^9.3.1",
|
||||
"@mui/x-charts": "^9.11.1",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
"@radix-ui/react-collapsible": "^1.1.3",
|
||||
"@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-popover": "^1.1.6",
|
||||
"@radix-ui/react-radio-group": "^1.2.3",
|
||||
"@radix-ui/react-select": "^2.1.6",
|
||||
"@radix-ui/react-slider": "^1.2.3",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-switch": "^1.1.3",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@tanstack/react-query": "^5.84.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^11.16.4",
|
||||
"lucide-react": "^0.475.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"recharts": "^3.10.1",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@types/node": "^22.13.5",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@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-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/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.8 KiB |
BIN
public/favicon.png
Normal file
BIN
public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
BIN
public/krow-logo-visual.jpeg
Normal file
BIN
public/krow-logo-visual.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
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 |
BIN
public/owliver-avatar.png
Normal file
BIN
public/owliver-avatar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.6 KiB |
238
scripts/dev.mjs
Normal file
238
scripts/dev.mjs
Normal file
@@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Development launcher.
|
||||
*
|
||||
* Why this exists
|
||||
* ---------------
|
||||
* `vite` alone serves the frontend and proxies `/api` and `/health` to the Go
|
||||
* API (see vite.config.js). Nothing in that arrangement checks that the API is
|
||||
* actually running. When it is not, the proxy's connection is refused and every
|
||||
* call — `POST /api/v1/auth/login` first of all — comes back as a bare
|
||||
* `502 Bad Gateway` in the browser. The status says "the gateway got a bad
|
||||
* response", which points at the backend's *response*; the truth is that there
|
||||
* was no backend to respond to at all. That misdirection is what made this
|
||||
* recur: the frontend looks healthy, so the missing process is the last thing
|
||||
* anyone checks.
|
||||
*
|
||||
* So the dev server no longer starts without a reachable API. If the proxy
|
||||
* target is loopback and nothing is listening, this starts the API, waits for
|
||||
* `/health`, and only then hands over to Vite. If it cannot, it says exactly
|
||||
* what is wrong instead of letting Vite boot into a guaranteed 502.
|
||||
*
|
||||
* Escape hatches:
|
||||
* npm run dev:vite frontend only, no API check (offline UI work)
|
||||
* KROW_BACKEND_DIR=/path/to the API repo, if it is not ../krow-backend
|
||||
* KROW_SKIP_API_CHECK=1 skip the preflight entirely
|
||||
*/
|
||||
import { spawn } from 'node:child_process';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
const c = {
|
||||
dim: (s) => `\x1b[2m${s}\x1b[0m`,
|
||||
red: (s) => `\x1b[31m${s}\x1b[0m`,
|
||||
yellow: (s) => `\x1b[33m${s}\x1b[0m`,
|
||||
green: (s) => `\x1b[32m${s}\x1b[0m`,
|
||||
bold: (s) => `\x1b[1m${s}\x1b[0m`,
|
||||
};
|
||||
const log = (m) => console.log(`${c.dim('[dev]')} ${m}`);
|
||||
const warn = (m) => console.log(`${c.yellow('[dev]')} ${m}`);
|
||||
const err = (m) => console.error(`${c.red('[dev]')} ${m}`);
|
||||
|
||||
/**
|
||||
* Read a dotenv file the way Vite's `loadEnv` does for our purposes: plain
|
||||
* KEY=VALUE, `#` comments, optional surrounding quotes. Deliberately minimal —
|
||||
* this only needs to find one variable, and pulling in a parser to do it would
|
||||
* be a dependency for nothing.
|
||||
*/
|
||||
function readEnvFile(file) {
|
||||
if (!existsSync(file)) return {};
|
||||
const out = {};
|
||||
for (const raw of readFileSync(file, 'utf8').split('\n')) {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith('#')) continue;
|
||||
const eq = line.indexOf('=');
|
||||
if (eq === -1) continue;
|
||||
let value = line.slice(eq + 1).trim().replace(/\s+#.*$/, '');
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
out[line.slice(0, eq).trim()] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Same precedence and the same default as vite.config.js. If these two ever
|
||||
// disagree the preflight would check one host while the proxy forwards to
|
||||
// another, which is worse than no check at all.
|
||||
const fileEnv = {
|
||||
...readEnvFile(path.join(ROOT, '.env')),
|
||||
...readEnvFile(path.join(ROOT, '.env.local')),
|
||||
};
|
||||
const TARGET =
|
||||
process.env.VITE_API_PROXY_TARGET ||
|
||||
fileEnv.VITE_API_PROXY_TARGET ||
|
||||
'https://mcp.krowforce.com';
|
||||
|
||||
let targetURL;
|
||||
try {
|
||||
targetURL = new URL(TARGET);
|
||||
} catch {
|
||||
err(`VITE_API_PROXY_TARGET is not a valid URL: ${JSON.stringify(TARGET)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const HEALTH = new URL('/health', targetURL).href;
|
||||
const IS_LOCAL = ['localhost', '127.0.0.1', '::1', '0.0.0.0'].includes(targetURL.hostname);
|
||||
const BACKEND_DIR = process.env.KROW_BACKEND_DIR || path.resolve(ROOT, '..', 'krow-backend');
|
||||
|
||||
async function apiIsUp(timeoutMs = 2500) {
|
||||
try {
|
||||
const res = await fetch(HEALTH, { signal: AbortSignal.timeout(timeoutMs) });
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** The API process this script started, if any. Left null when one was already
|
||||
* running, so that Ctrl-C never kills a server it did not launch. */
|
||||
let api = null;
|
||||
const apiLog = [];
|
||||
|
||||
function startApi() {
|
||||
// `make run` is `cd go-api && go run ./cmd/api`; running it directly skips a
|
||||
// make → go → binary process layer that only makes the tree harder to kill.
|
||||
api = spawn('go', ['run', './cmd/api'], {
|
||||
cwd: path.join(BACKEND_DIR, 'go-api'),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
// Own process group, so shutdown can signal `go run` *and* the binary it
|
||||
// execs. Signalling only the parent leaves the child holding port 8080,
|
||||
// and the next `npm run dev` then fails to bind for reasons that look
|
||||
// nothing like the original problem.
|
||||
detached: true,
|
||||
});
|
||||
|
||||
const capture = (chunk) => {
|
||||
for (const line of String(chunk).split('\n')) {
|
||||
if (line.trim()) apiLog.push(line);
|
||||
}
|
||||
if (apiLog.length > 200) apiLog.splice(0, apiLog.length - 200);
|
||||
};
|
||||
api.stdout.on('data', capture);
|
||||
api.stderr.on('data', capture);
|
||||
api.on('error', (e) => {
|
||||
err(`could not launch the API: ${e.message}`);
|
||||
if (e.code === 'ENOENT') err('`go` is not on PATH. Install Go, or start the API yourself.');
|
||||
});
|
||||
}
|
||||
|
||||
function tailApiLog(n = 15) {
|
||||
if (!apiLog.length) return;
|
||||
err('last output from the API:');
|
||||
for (const line of apiLog.slice(-n)) console.error(` ${c.dim(line)}`);
|
||||
}
|
||||
|
||||
async function ensureApi() {
|
||||
if (process.env.KROW_SKIP_API_CHECK === '1') {
|
||||
warn('KROW_SKIP_API_CHECK=1 — skipping the API preflight. /api calls will 502 if it is down.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (await apiIsUp()) {
|
||||
log(`API reachable at ${c.bold(TARGET)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// A remote target is not ours to start, and quietly proceeding is fine —
|
||||
// it may simply be gated, or briefly redeploying.
|
||||
if (!IS_LOCAL) {
|
||||
warn(`API at ${c.bold(TARGET)} did not answer /health. Starting Vite anyway; /api may 502.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!existsSync(path.join(BACKEND_DIR, 'go-api'))) {
|
||||
err(`Nothing is listening on ${c.bold(TARGET)} and the API repo was not found.`);
|
||||
err(`Looked in: ${BACKEND_DIR}`);
|
||||
err('Set KROW_BACKEND_DIR to the krow-backend checkout, or start the API yourself:');
|
||||
err(c.bold(' cd <krow-backend> && make run'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
log(`Nothing on ${c.bold(TARGET)} — starting the API from ${BACKEND_DIR}`);
|
||||
startApi();
|
||||
|
||||
// A cold `go run` compiles the module first, which is comfortably the slowest
|
||||
// step here; 90s is sized for that, not for the HTTP request.
|
||||
const deadline = Date.now() + 90_000;
|
||||
let announced = false;
|
||||
while (Date.now() < deadline) {
|
||||
if (api?.exitCode !== null && api?.exitCode !== undefined) {
|
||||
err(`the API exited with code ${api.exitCode} before it became healthy.`);
|
||||
tailApiLog();
|
||||
process.exit(1);
|
||||
}
|
||||
if (await apiIsUp(1500)) {
|
||||
log(c.green(`API is up at ${TARGET}`));
|
||||
return;
|
||||
}
|
||||
if (!announced && Date.now() > deadline - 80_000) {
|
||||
log(c.dim('compiling the API (first run takes a moment)...'));
|
||||
announced = true;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
|
||||
err(`the API did not become healthy within 90s (${HEALTH}).`);
|
||||
tailApiLog();
|
||||
err('Common causes: PostgreSQL is not running, or krow-backend/.env is wrong.');
|
||||
err(c.bold(' pg_isready') + c.dim(' # should say "accepting connections"'));
|
||||
shutdown(1);
|
||||
}
|
||||
|
||||
let shuttingDown = false;
|
||||
function shutdown(code) {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
if (api?.pid) {
|
||||
log('stopping the API it started...');
|
||||
// Negative pid signals the whole group — see the `detached` note above.
|
||||
try {
|
||||
process.kill(-api.pid, 'SIGTERM');
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
// Vite is a child of this process, so a Ctrl-C in a terminal reaches it on
|
||||
// its own via the foreground process group. A bare SIGTERM to this script
|
||||
// does not, and the orphan keeps holding 5173 — after which the next
|
||||
// `npm run dev` quietly moves to 5174 and any bookmark, or anything relying
|
||||
// on the port, points at a dead server.
|
||||
if (vite?.pid && vite.exitCode === null) {
|
||||
try {
|
||||
vite.kill('SIGTERM');
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => shutdown(0));
|
||||
process.on('SIGTERM', () => shutdown(0));
|
||||
|
||||
await ensureApi();
|
||||
|
||||
let vite = null;
|
||||
vite = spawn(
|
||||
process.execPath,
|
||||
[path.join(ROOT, 'node_modules', 'vite', 'bin', 'vite.js'), ...process.argv.slice(2)],
|
||||
{ cwd: ROOT, stdio: 'inherit' },
|
||||
);
|
||||
vite.on('exit', (code, signal) => shutdown(signal ? 0 : (code ?? 0)));
|
||||
110
src/App.jsx
Normal file
110
src/App.jsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Toaster } from '@/components/ui/toaster';
|
||||
import { Toaster as HotToaster } from 'react-hot-toast';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { queryClientInstance } from '@/lib/query-client';
|
||||
import { Navigate, BrowserRouter as Router, Route, Routes } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from '@/lib/AuthContext';
|
||||
|
||||
import PageNotFound from '@/lib/PageNotFound';
|
||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||
import ScrollToTop from '@/components/ScrollToTop';
|
||||
import ProtectedRoute from '@/components/ProtectedRoute';
|
||||
import EmployerRoute from '@/routes/EmployerRoute';
|
||||
import RoleHome from '@/routes/RoleHome';
|
||||
import EmployerLayout from '@/layouts/EmployerLayout';
|
||||
|
||||
import Login from '@/pages/Login';
|
||||
import OwliverHome from '@/pages/OwliverHome';
|
||||
import Dashboard from '@/pages/hiring/Overview';
|
||||
import Positions from '@/pages/hiring/Positions';
|
||||
import CreatePosition from '@/pages/hiring/CreatePosition';
|
||||
import PositionDetail from '@/pages/hiring/PositionDetail';
|
||||
import Candidates from '@/pages/hiring/Candidates';
|
||||
import CandidateProfile from '@/pages/hiring/CandidateProfile';
|
||||
import HiredHistory from '@/pages/hiring/HiredHistory';
|
||||
import TalentPool from '@/pages/hiring/TalentPool';
|
||||
import Analytics from '@/pages/hiring/Analytics';
|
||||
import Activity from '@/pages/Activity';
|
||||
import Profile from '@/pages/Profile';
|
||||
import Settings from '@/pages/Settings';
|
||||
|
||||
const AuthenticatedApp = () => {
|
||||
const { isLoadingAuth, isLoadingPublicSettings } = useAuth();
|
||||
|
||||
if (isLoadingPublicSettings || isLoadingAuth) {
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-slate-200 border-t-slate-800" />
|
||||
<span className="sr-only">Checking your session</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
{/* Sign-in sits outside the protected boundary, for the obvious reason. */}
|
||||
<Route path="/login" element={<Login />} />
|
||||
|
||||
<Route element={<ProtectedRoute />}>
|
||||
{/* Everyone who reaches here has a session — `ProtectedRoute` saw to
|
||||
that — so `/` is a question about who they are, not whether they are
|
||||
signed in. A role with no console in this build lands back on the
|
||||
sign-in page, where it says so. */}
|
||||
<Route path="/" element={<RoleHome />} />
|
||||
|
||||
<Route element={<EmployerRoute />}>
|
||||
<Route element={<EmployerLayout />}>
|
||||
{/* The Owliver AI home — the post-login landing. A separate page
|
||||
from the Dashboard, which keeps its own address below. */}
|
||||
<Route path="/home" element={<OwliverHome />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/positions" element={<Positions />} />
|
||||
{/* Static before dynamic, so `positions/new` cannot be read as a
|
||||
position whose id is "new". */}
|
||||
<Route path="/positions/new" element={<CreatePosition />} />
|
||||
<Route path="/positions/:id" element={<PositionDetail />} />
|
||||
<Route path="/candidates" element={<Candidates />} />
|
||||
{/* The full candidate record is a page rather than a drawer: it
|
||||
carries around forty fields and fifteen screening dimensions,
|
||||
which a 480px column cannot show without hiding most of it. */}
|
||||
<Route path="/candidates/:id" element={<CandidateProfile />} />
|
||||
<Route path="/hired" element={<HiredHistory />} />
|
||||
<Route path="/talent-pool" element={<TalentPool />} />
|
||||
<Route path="/analytics" element={<Analytics />} />
|
||||
<Route path="/activity" element={<Activity />} />
|
||||
<Route path="/profile" element={<Profile />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* The addresses this console was lifted from. Bookmarks and links
|
||||
written against the two-console build still resolve. */}
|
||||
<Route path="/admin" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/employer" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/overview" element={<Navigate to="/dashboard" replace />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<PageNotFound />} />
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClientInstance}>
|
||||
<Router>
|
||||
<ScrollToTop />
|
||||
<AuthenticatedApp />
|
||||
</Router>
|
||||
<Toaster />
|
||||
{/* The design system's `toast` helper wraps react-hot-toast, which
|
||||
needs its own renderer mounted. Without this every toast in the
|
||||
app fires into nothing. */}
|
||||
<HotToaster position="bottom-right" />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
45
src/agents/activity-agent.md
Normal file
45
src/agents/activity-agent.md
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
id: activity-agent
|
||||
name: Activity Agent
|
||||
description: The audit trail — what happened in this workspace, who did it, and what looks unusual.
|
||||
icon: activity
|
||||
status: published
|
||||
version: 1
|
||||
reasoning: balanced
|
||||
trigger: Use on Activity, for the event log, who did what, and anything that looks out of pattern.
|
||||
pages:
|
||||
- activity
|
||||
skills:
|
||||
- activity-analysis
|
||||
- anomaly-detection
|
||||
- operational-risk
|
||||
starters:
|
||||
- label: What happened recently?
|
||||
prompt: What has happened in the workspace recently?
|
||||
- label: Anything unusual?
|
||||
prompt: Is there any unusual activity?
|
||||
permissions:
|
||||
owner: demo@krow.app
|
||||
access: all
|
||||
tools:
|
||||
- activity_breakdown
|
||||
- activity_signals
|
||||
---
|
||||
|
||||
# Activity Agent
|
||||
|
||||
## Instructions
|
||||
|
||||
Answer about what has happened in this workspace: which events, by which
|
||||
account, and when.
|
||||
|
||||
Report something as unusual only when it genuinely departs from the pattern in
|
||||
the log. Flagging ordinary activity trains the reader to ignore the flag.
|
||||
|
||||
This agent carries no skills of its own; Activity answers from its own page
|
||||
reader.
|
||||
|
||||
## Purpose
|
||||
|
||||
- Report recent workspace events and who performed them.
|
||||
- Surface activity that departs from the usual pattern.
|
||||
50
src/agents/analytics-agent.md
Normal file
50
src/agents/analytics-agent.md
Normal file
@@ -0,0 +1,50 @@
|
||||
---
|
||||
id: analytics-agent
|
||||
name: Analytics Agent
|
||||
description: Hiring performance over time — trends, conversion, and how departments compare.
|
||||
icon: bar-chart
|
||||
status: published
|
||||
version: 1
|
||||
reasoning: balanced
|
||||
trigger: Use on Analytics, for trends over time, conversion rates and department comparisons.
|
||||
pages:
|
||||
- analytics
|
||||
skills:
|
||||
- analytics-insights
|
||||
- workforce-analytics
|
||||
- attendance-analysis
|
||||
- overtime-analysis
|
||||
- hiring-pulse-analysis
|
||||
starters:
|
||||
- label: What is the hiring trend?
|
||||
prompt: What is the hiring trend?
|
||||
- label: Where does the funnel lose people?
|
||||
prompt: Where does the funnel lose candidates?
|
||||
permissions:
|
||||
owner: demo@krow.app
|
||||
access: all
|
||||
tools:
|
||||
- workspace_summary
|
||||
- workforce_attendance
|
||||
- workforce_overtime
|
||||
- workforce_coverage
|
||||
- candidates_quality
|
||||
- hires_performance
|
||||
- activity_breakdown
|
||||
---
|
||||
|
||||
# Analytics Agent
|
||||
|
||||
## Instructions
|
||||
|
||||
Answer about performance over time: how hiring is trending, where the funnel
|
||||
converts and where it leaks, and how departments compare.
|
||||
|
||||
Explain the figures the Analytics page is already showing rather than producing
|
||||
different ones. When a movement is small enough to be noise, say so rather than
|
||||
narrating it as a trend.
|
||||
|
||||
## Purpose
|
||||
|
||||
- Explain hiring trend and conversion.
|
||||
- Compare department performance, and identify where the funnel loses people.
|
||||
47
src/agents/candidates-agent.md
Normal file
47
src/agents/candidates-agent.md
Normal file
@@ -0,0 +1,47 @@
|
||||
---
|
||||
id: candidates-agent
|
||||
name: Candidates Agent
|
||||
description: The applicant pool — who is waiting on a decision, who is strongest, and where people are dropping off.
|
||||
icon: users
|
||||
status: published
|
||||
version: 1
|
||||
reasoning: balanced
|
||||
trigger: Use on Candidates, for screening, shortlisting and pipeline questions about applicants.
|
||||
pages:
|
||||
- candidates
|
||||
- candidates-analysis
|
||||
skills:
|
||||
- candidate-search
|
||||
- candidate-analysis
|
||||
starters:
|
||||
- label: Who needs a decision?
|
||||
prompt: Which candidates are waiting on a decision?
|
||||
- label: Who is strongest?
|
||||
prompt: Who are the strongest candidates right now?
|
||||
permissions:
|
||||
owner: demo@krow.app
|
||||
access: all
|
||||
tools:
|
||||
- candidates_quality
|
||||
- talent_pool
|
||||
- hires_recent
|
||||
- candidates_awaiting
|
||||
- move_application
|
||||
---
|
||||
|
||||
# Candidates Agent
|
||||
|
||||
## Instructions
|
||||
|
||||
Answer about the people who have applied: who is waiting, who scores well, who
|
||||
has not been screened, and where the pipeline is losing candidates.
|
||||
|
||||
Quote a score only where one has been computed. An unscored candidate is
|
||||
unscored — say so rather than implying a low score.
|
||||
|
||||
Never advance, decline or hire a candidate without being asked to.
|
||||
|
||||
## Purpose
|
||||
|
||||
- Report who is waiting on a decision, and who is strongest.
|
||||
- Find candidates matching what a role asks for.
|
||||
57
src/agents/control-center-agent.md
Normal file
57
src/agents/control-center-agent.md
Normal file
@@ -0,0 +1,57 @@
|
||||
---
|
||||
id: control-center-agent
|
||||
name: Control Center Agent
|
||||
description: The operational picture — what needs attention across the workspace today.
|
||||
icon: layers
|
||||
status: published
|
||||
version: 1
|
||||
reasoning: balanced
|
||||
trigger: Use on the Control Center, for workspace health, urgency and what to do next.
|
||||
pages:
|
||||
- control-center
|
||||
skills:
|
||||
- executive-summary
|
||||
- staffing-risk
|
||||
- operational-risk
|
||||
- anomaly-detection
|
||||
- attendance-analysis
|
||||
- overtime-analysis
|
||||
- hiring-pulse-analysis
|
||||
starters:
|
||||
- label: What needs my attention?
|
||||
prompt: What needs my attention right now?
|
||||
- label: How is the pipeline?
|
||||
prompt: How healthy is my hiring pipeline?
|
||||
permissions:
|
||||
owner: demo@krow.app
|
||||
access: all
|
||||
tools:
|
||||
- knowledge_search
|
||||
- workspace_summary
|
||||
- operations_risk
|
||||
- activity_signals
|
||||
- positions_risk
|
||||
- workforce_coverage
|
||||
- candidates_awaiting
|
||||
sources:
|
||||
- policy_docs
|
||||
---
|
||||
|
||||
# Control Center Agent
|
||||
|
||||
## Instructions
|
||||
|
||||
Answer about the state of the workspace as a whole: what is urgent, where the
|
||||
funnel is losing people, and what the reader should do next.
|
||||
|
||||
Read the figures the Control Center already shows rather than recomputing them,
|
||||
so the answer and the dashboard beside it can never disagree.
|
||||
|
||||
This agent carries no skills of its own. That is deliberate — the Control
|
||||
Center answers from its own page reader, and inventing skills to fill the list
|
||||
would promise capabilities that do not exist.
|
||||
|
||||
## Purpose
|
||||
|
||||
- Say what needs attention across the workspace.
|
||||
- Explain where the hiring funnel is losing candidates.
|
||||
44
src/agents/hired-history-agent.md
Normal file
44
src/agents/hired-history-agent.md
Normal file
@@ -0,0 +1,44 @@
|
||||
---
|
||||
id: hired-history-agent
|
||||
name: Hired History Agent
|
||||
description: Completed hires — who was hired, for which role, how quickly, and how well.
|
||||
icon: user-check
|
||||
status: published
|
||||
version: 1
|
||||
reasoning: balanced
|
||||
trigger: Use on Hired History, for hiring outcomes, time-to-hire and quality by department.
|
||||
pages:
|
||||
- hired-history
|
||||
skills:
|
||||
- hiring-history-analysis
|
||||
starters:
|
||||
- label: Who did we hire recently?
|
||||
prompt: Who did we hire recently?
|
||||
- label: How is hire quality?
|
||||
prompt: How is hire quality by department?
|
||||
permissions:
|
||||
owner: demo@krow.app
|
||||
access: all
|
||||
tools:
|
||||
- hires_recent
|
||||
- hires_performance
|
||||
---
|
||||
|
||||
# Hired History Agent
|
||||
|
||||
## Instructions
|
||||
|
||||
Answer about hires that have already happened: who, for which role, how long it
|
||||
took and how they scored.
|
||||
|
||||
This is the record after the decision, not the pipeline before it. A question
|
||||
about people still being considered belongs to Candidates.
|
||||
|
||||
This agent carries no skills of its own. Hired History answers from its own
|
||||
page reader, and a placeholder skill would promise a capability that does not
|
||||
exist.
|
||||
|
||||
## Purpose
|
||||
|
||||
- Report recent hires, and how quickly they were made.
|
||||
- Compare hiring outcomes across departments.
|
||||
44
src/agents/krow-forge-agent.md
Normal file
44
src/agents/krow-forge-agent.md
Normal file
@@ -0,0 +1,44 @@
|
||||
---
|
||||
id: krow-forge-agent
|
||||
name: KROW Forge Agent
|
||||
description: The training library — what exists, what is published, and how the workforce is progressing.
|
||||
icon: graduation-cap
|
||||
status: published
|
||||
version: 1
|
||||
reasoning: balanced
|
||||
trigger: Use on KROW Forge, for training paths, challenges, verification and skill progression.
|
||||
pages:
|
||||
- krow-forge
|
||||
skills:
|
||||
- forge-skill-management
|
||||
- learning-analysis
|
||||
starters:
|
||||
- label: What is in the library?
|
||||
prompt: What training does the library hold?
|
||||
- label: Where are the gaps?
|
||||
prompt: Where are the gaps in workforce training?
|
||||
permissions:
|
||||
owner: demo@krow.app
|
||||
access: all
|
||||
tools:
|
||||
- workforce_training
|
||||
- talent_pool
|
||||
---
|
||||
|
||||
# KROW Forge Agent
|
||||
|
||||
## Instructions
|
||||
|
||||
Answer about the training library and what the workforce has proved: which
|
||||
paths exist, which are published, what a challenge checks, and where coverage
|
||||
is thin.
|
||||
|
||||
A skill in Forge is something a person learns and is verified in. It is not an
|
||||
Owliver capability — never describe the two as the same thing.
|
||||
|
||||
Never publish or archive training without being asked to.
|
||||
|
||||
## Purpose
|
||||
|
||||
- Report what the training library holds and what is live.
|
||||
- Identify gaps between what roles need and what is taught.
|
||||
110
src/agents/krow-workforce-agent.md
Normal file
110
src/agents/krow-workforce-agent.md
Normal file
@@ -0,0 +1,110 @@
|
||||
---
|
||||
id: krow-workforce-agent
|
||||
name: Krow Workforce Agent
|
||||
description: The general workforce agent. Reasons across every Krow domain, within whatever page you are on.
|
||||
icon: owliver
|
||||
status: published
|
||||
version: 1
|
||||
reasoning: balanced
|
||||
trigger: Use when a question spans more than one Krow domain, or when you are on a page whose own agent cannot help.
|
||||
pages:
|
||||
- control-center
|
||||
- positions
|
||||
- create-position
|
||||
- candidates
|
||||
- candidates-analysis
|
||||
- hired-history
|
||||
- talent-pool
|
||||
- krow-forge
|
||||
- analytics
|
||||
- activity
|
||||
- profile
|
||||
# The agent workspace. Carries no operational skill, so standing here the
|
||||
# root agent answers about agents and skills and nothing else — which is the
|
||||
# point: configuring the Analytics Agent must not put the reader on Analytics.
|
||||
- workspace-agent-configure
|
||||
# Settings and the rest of the workspace. Nobody wrote a specialist for a
|
||||
# configuration screen and nobody should: these pages hold no workforce
|
||||
# records, so what they need is a general agent, not a Settings Agent with
|
||||
# invented skills. Listing them here is the whole of the fallback — a page
|
||||
# named by this agent has an agent, and Owliver is alive on it.
|
||||
- settings
|
||||
- workspace
|
||||
- workspace-agents
|
||||
- workspace-skills
|
||||
- workspace-skill-configure
|
||||
- skill-development
|
||||
skills:
|
||||
- create-position
|
||||
- hiring-activity-assistant
|
||||
- candidate-search
|
||||
- analytics-insights
|
||||
- forge-skill-management
|
||||
- staffing-risk
|
||||
- attendance-analysis
|
||||
- overtime-analysis
|
||||
- candidate-analysis
|
||||
- talent-pool-analysis
|
||||
- workforce-analytics
|
||||
- anomaly-detection
|
||||
- activity-analysis
|
||||
- operational-risk
|
||||
- executive-summary
|
||||
- hiring-history-analysis
|
||||
- learning-analysis
|
||||
- hiring-pulse-analysis
|
||||
subagents:
|
||||
- control-center-agent
|
||||
- positions-agent
|
||||
- candidates-agent
|
||||
- hired-history-agent
|
||||
- talent-pool-agent
|
||||
- krow-forge-agent
|
||||
- analytics-agent
|
||||
- activity-agent
|
||||
knowledge:
|
||||
- id: page-boundary
|
||||
label: What this agent can see
|
||||
kind: note
|
||||
body: Owliver answers from the page you are on. Covering every page does not mean reading every page at once — the page you are standing on decides which records are in reach.
|
||||
starters:
|
||||
- label: What needs my attention?
|
||||
prompt: What needs my attention right now?
|
||||
- label: Summarize this page
|
||||
prompt: Summarize what this page is showing
|
||||
permissions:
|
||||
owner: demo@krow.app
|
||||
access: all
|
||||
people:
|
||||
- user: demo@krow.app
|
||||
role: manager
|
||||
tools:
|
||||
- workspace_summary
|
||||
- operations_risk
|
||||
- positions_risk
|
||||
- workforce_attendance
|
||||
- workforce_coverage
|
||||
- candidates_quality
|
||||
- talent_pool
|
||||
sources:
|
||||
- policy_docs
|
||||
---
|
||||
|
||||
# Krow Workforce Agent
|
||||
|
||||
## Instructions
|
||||
|
||||
Answer from the records this workspace holds, for the page the reader is on.
|
||||
|
||||
State a figure only where a skill has read it. When a reading needs a position
|
||||
or a candidate and none is open, ask which one rather than choosing one.
|
||||
|
||||
Covering every page is not permission to read every page at once. The page in
|
||||
front of the reader decides what is in reach; a question that belongs somewhere
|
||||
else should be answered by naming where it belongs, not by reaching for it.
|
||||
|
||||
## Purpose
|
||||
|
||||
- Answer questions that span more than one Krow domain.
|
||||
- Stand in on pages whose own agent carries no skills.
|
||||
- Hand a question that clearly belongs to another page back to that page.
|
||||
51
src/agents/positions-agent.md
Normal file
51
src/agents/positions-agent.md
Normal file
@@ -0,0 +1,51 @@
|
||||
---
|
||||
id: positions-agent
|
||||
name: Positions Agent
|
||||
description: Open roles — what they need, who has applied, and which are at risk of going unfilled.
|
||||
icon: briefcase
|
||||
status: published
|
||||
version: 1
|
||||
reasoning: balanced
|
||||
trigger: Use on Positions, for open roles, applicant flow, and specifying a new role.
|
||||
pages:
|
||||
- positions
|
||||
- create-position
|
||||
skills:
|
||||
- create-position
|
||||
- hiring-activity-assistant
|
||||
- staffing-risk
|
||||
starters:
|
||||
- label: Which positions need attention?
|
||||
prompt: Which positions need attention?
|
||||
- label: Show hiring activity
|
||||
prompt: Show hiring activity as a flow
|
||||
permissions:
|
||||
owner: demo@krow.app
|
||||
access: all
|
||||
tools:
|
||||
- positions_risk
|
||||
- open_positions
|
||||
- available_workers
|
||||
- workforce_coverage
|
||||
- candidates_quality
|
||||
- assign_worker
|
||||
- candidates_awaiting
|
||||
- move_application
|
||||
---
|
||||
|
||||
# Positions Agent
|
||||
|
||||
## Instructions
|
||||
|
||||
Answer about the roles this workspace has open: how they are filling, which are
|
||||
starved of applicants, and what a role still needs before it can be published.
|
||||
|
||||
When a question names a role, answer about that role. When it does not and one
|
||||
is open on the page, answer about that one. When neither is true, ask which.
|
||||
|
||||
Never create or publish a position without being asked to.
|
||||
|
||||
## Purpose
|
||||
|
||||
- Report how open roles are filling, and which are at risk.
|
||||
- Help specify a new role and its screening weights.
|
||||
44
src/agents/talent-pool-agent.md
Normal file
44
src/agents/talent-pool-agent.md
Normal file
@@ -0,0 +1,44 @@
|
||||
---
|
||||
id: talent-pool-agent
|
||||
name: Talent Pool Agent
|
||||
description: Available talent — who is in the pool, who is verified, and who is ready to place.
|
||||
icon: layers
|
||||
status: published
|
||||
version: 1
|
||||
reasoning: balanced
|
||||
trigger: Use on Talent Pool, for supply, availability and readiness of known workers.
|
||||
pages:
|
||||
- talent-pool
|
||||
skills:
|
||||
- talent-pool-analysis
|
||||
starters:
|
||||
- label: Who is available?
|
||||
prompt: Who is available in the talent pool?
|
||||
- label: How verified is the pool?
|
||||
prompt: How much of the talent pool is verified?
|
||||
permissions:
|
||||
owner: demo@krow.app
|
||||
access: all
|
||||
tools:
|
||||
- talent_pool
|
||||
- workforce_training
|
||||
- available_workers
|
||||
---
|
||||
|
||||
# Talent Pool Agent
|
||||
|
||||
## Instructions
|
||||
|
||||
Answer about the people this workspace already knows: who is in the pool, what
|
||||
they are verified in, and who could be placed now.
|
||||
|
||||
This is supply, not applicants. Someone in the pool has not applied to anything
|
||||
by being here — do not describe them as a candidate for a role.
|
||||
|
||||
This agent carries no skills of its own; Talent Pool answers from its own page
|
||||
reader.
|
||||
|
||||
## Purpose
|
||||
|
||||
- Report who is available, and how ready they are.
|
||||
- Describe the pool's segments and verification coverage.
|
||||
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 };
|
||||
}
|
||||
71
src/api/demoUser.js
Normal file
71
src/api/demoUser.js
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* The shape of a signed-in user, before the server has answered.
|
||||
*
|
||||
* This is a **default shape, not a record**. The user lives in PostgreSQL and
|
||||
* arrives from `GET /me`; what is here is the set of keys that must resolve
|
||||
* during the first render, in the fraction of a second before that response
|
||||
* lands.
|
||||
*
|
||||
* It exists as its own module for one reason: `krowClient.js` needs exactly
|
||||
* this, and it used to reach into `api/seed.js` to get it — a 1,900-line
|
||||
* fixture of demo positions, candidates, interviews, staff and shift records,
|
||||
* every byte of which was then in the production bundle so that three booleans
|
||||
* could be defaulted. The fixture is still the right thing for the test scripts
|
||||
* that read it (`scripts/skill-check.mjs`, `scripts/owliver-capture.mjs`); it
|
||||
* was never the right thing for the running app. `seed.js` re-exports this
|
||||
* constant, so those scripts are unchanged and the production import chain no
|
||||
* longer reaches them.
|
||||
*
|
||||
* Nothing here is a source of truth for anything. `preferences` is the one part
|
||||
* that is read: `auth.preferences()` is synchronous — `AssistantPanelContext`
|
||||
* decides whether Owliver starts open in a `useState` initialiser — so a key
|
||||
* the server has never stored still has to resolve to something rather than to
|
||||
* `undefined`.
|
||||
*/
|
||||
export const DEMO_USER = {
|
||||
id: 'user_demo',
|
||||
full_name: 'Alex Rivera',
|
||||
email: 'demo@krow.app',
|
||||
role: 'admin',
|
||||
account_type: 'employer',
|
||||
created_date: '2026-06-01T09:00:00.000Z',
|
||||
/* Product preferences travel with the account rather than in a store of their
|
||||
own, so there is one record to persist and one thing to read. */
|
||||
preferences: {
|
||||
owliverDefault: true,
|
||||
compactDensity: false,
|
||||
emailDigest: true,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The employer account.
|
||||
*
|
||||
* The platform has had three roles since the policy table was written —
|
||||
* `admin`, `employer`, `talent` — and exactly one seeded user, an
|
||||
* administrator. That made the employer half of `domain/policy.go` unreachable:
|
||||
* `operators = {admin, employer}` was enforced on every request and never once
|
||||
* exercised as an employer, because there was nobody to sign in as.
|
||||
*
|
||||
* This is that person. Same organisation as the demo administrator, so both
|
||||
* consoles read the same postings, applications, staff and profiles and can be
|
||||
* compared against each other.
|
||||
*
|
||||
* `role` is what authorizes and `account_type` is what a page may call you.
|
||||
* They agree here, which makes this a poor test of the distinction — the point
|
||||
* of keeping them separate is that `PATCH /me` can change the second and never
|
||||
* the first.
|
||||
*/
|
||||
export const EMPLOYER_USER = {
|
||||
id: 'user_employer',
|
||||
full_name: 'Jordan Blake',
|
||||
email: 'employer@krow.app',
|
||||
role: 'employer',
|
||||
account_type: 'employer',
|
||||
created_date: '2026-06-01T09:00:00.000Z',
|
||||
preferences: {
|
||||
owliverDefault: true,
|
||||
compactDensity: false,
|
||||
emailDigest: true,
|
||||
},
|
||||
};
|
||||
313
src/api/httpClient.js
Normal file
313
src/api/httpClient.js
Normal file
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* HTTP transport for the entity API.
|
||||
*
|
||||
* This is the module that replaces `store.js`. It exposes the same
|
||||
* `createEntity(name)` factory with the same six methods and the same
|
||||
* signatures, so `krowClient.js` swaps one import and nothing above it
|
||||
* changes — not a hook, not a page, not a component.
|
||||
*
|
||||
* Everything here is a faithful translation of what `store.js` did locally into
|
||||
* what `docs/api-contract.md` specifies over the wire. Where the two could
|
||||
* differ, the local behaviour wins, because the callers were written against
|
||||
* it:
|
||||
*
|
||||
* - `list`/`filter` return a bare array; `get`/`create`/`update` return a bare
|
||||
* object; `delete` returns `{ id }`. The API's `{ data, meta }` envelope is
|
||||
* unwrapped here and never seen above.
|
||||
* - A failure throws an `Error` whose `message` is the server's message,
|
||||
* because `store.js` threw and several callers depend on the throw
|
||||
* (`useQuery`'s `isError`, and half a dozen `.catch(() => …)` fallbacks).
|
||||
* - The default `sort` and `limit` on every method are the ones `store.js`
|
||||
* declared, so a call site that passes neither still gets what it always
|
||||
* got.
|
||||
*
|
||||
* The one thing that is genuinely new is the failure mode. A local store could
|
||||
* not be unreachable; an API can, and "Failed to fetch" names neither the
|
||||
* problem nor the fix. `request` turns that into a message that says which URL
|
||||
* did not answer.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Where the API lives.
|
||||
*
|
||||
* A same-origin path, not a host. The browser asks its own origin for
|
||||
* `/api/v1/...`; in development the Vite proxy forwards that to the Go API (see
|
||||
* `vite.config.js`), and in production the same path is served by the same
|
||||
* origin as the app (see `nginx.conf`).
|
||||
*
|
||||
* This is a requirement of the session cookie rather than a preference. The
|
||||
* cookie is HttpOnly with SameSite=Lax, and a Lax cookie is not sent on a
|
||||
* cross-site request — so a page on `localhost:5173` fetching
|
||||
* `http://127.0.0.1:8080` would authenticate once at login and then be a
|
||||
* stranger on every request after it.
|
||||
*
|
||||
* VITE_API_BASE_URL can still point somewhere else, and `credentials` below is
|
||||
* set so that it works, but the cross-origin path needs CORS credentials
|
||||
* configured on the server and is not the supported arrangement.
|
||||
*/
|
||||
const DEFAULT_BASE_URL = '/api/v1';
|
||||
|
||||
export const API_BASE_URL = String(
|
||||
import.meta.env?.VITE_API_BASE_URL || DEFAULT_BASE_URL
|
||||
).replace(/\/+$/, '');
|
||||
|
||||
/**
|
||||
* Shout if the API has been pointed at another origin.
|
||||
*
|
||||
* This exists because the failure it catches is silent and misleading. Set
|
||||
* `VITE_API_BASE_URL` to `http://127.0.0.1:8080/api/v1` and the browser sends a
|
||||
* preflight, the server answers it `204`, and then the real request is never
|
||||
* dispatched at all — because `credentials: 'include'` obliges the browser to
|
||||
* require `Access-Control-Allow-Credentials: true` on that preflight, and the
|
||||
* API does not send it. The server log shows an OPTIONS and nothing else; the
|
||||
* page shows a request that never completes. Nothing names the cause.
|
||||
*
|
||||
* Even if CORS were opened up, the session cookie is `SameSite=Lax` and would
|
||||
* not be sent on a cross-site request, so login would appear to succeed once
|
||||
* and then every subsequent request would arrive as a stranger.
|
||||
*
|
||||
* Development only: `import.meta.env.DEV` is statically replaced at build time,
|
||||
* so this whole block is dropped from the production bundle.
|
||||
*/
|
||||
if (import.meta.env?.DEV && /^https?:\/\//i.test(API_BASE_URL)) {
|
||||
const sameOrigin =
|
||||
typeof window !== 'undefined' && API_BASE_URL.startsWith(window.location.origin);
|
||||
if (!sameOrigin) {
|
||||
console.error(
|
||||
`[krow] VITE_API_BASE_URL is "${API_BASE_URL}", which is a different origin ` +
|
||||
`from ${typeof window !== 'undefined' ? window.location.origin : 'this page'}. ` +
|
||||
'The session cookie will not work: the login POST is blocked at the CORS ' +
|
||||
'preflight, and a SameSite=Lax cookie would not be sent cross-site anyway. ' +
|
||||
'Set VITE_API_BASE_URL=/api/v1 in .env and restart the dev server so requests ' +
|
||||
'go through the Vite proxy.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity name → the contract's resource path (§1: kebab-case plural, mass nouns
|
||||
* singular).
|
||||
*
|
||||
* Declared rather than derived. A rule that turns `AIInterview` into
|
||||
* `ai-interviews` and `Staff` into `staff` and `UserActivity` into
|
||||
* `user-activity` is three special cases wearing a trench coat, and a wrong
|
||||
* guess here is a 404 at runtime instead of a mistake anyone can see.
|
||||
*/
|
||||
const RESOURCE_PATHS = {
|
||||
JobPosting: 'job-postings',
|
||||
JobApplication: 'job-applications',
|
||||
AIInterview: 'ai-interviews',
|
||||
Staff: 'staff',
|
||||
WorkerProfile: 'worker-profiles',
|
||||
Course: 'courses',
|
||||
Badge: 'badges',
|
||||
LearningPath: 'learning-paths',
|
||||
Certification: 'certifications',
|
||||
RoleCategory: 'role-categories',
|
||||
UserActivity: 'user-activity',
|
||||
Evidence: 'evidence',
|
||||
User: 'users',
|
||||
Assignment: 'assignments',
|
||||
ShiftRecord: 'shift-records',
|
||||
/* Authored agents and skills. These are the registry the BACKEND runs from:
|
||||
an agent saved here is one Owliver can be asked to run, which is the whole
|
||||
difference between this and the preferences blob these used to live in. */
|
||||
AgentDefinition: 'agent-definitions',
|
||||
SkillDefinition: 'skill-definitions',
|
||||
};
|
||||
|
||||
/* ── Request ────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Builds a query string with the contract's filter encoding (§6).
|
||||
*
|
||||
* An array value becomes a repeated parameter — `?status=applied&status=hired`
|
||||
* — which the server reads as `col = ANY(...)`, matching `store.js`'s
|
||||
* `want.includes(got)`. `undefined` is omitted entirely: `store.js` compared
|
||||
* `got === undefined` against real values and matched nothing, and sending the
|
||||
* string "undefined" would be a filter on a value no column holds.
|
||||
*/
|
||||
function queryString(params) {
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value === undefined) continue;
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
if (item !== undefined) search.append(key, String(item));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
search.append(key, String(value));
|
||||
}
|
||||
const encoded = search.toString();
|
||||
return encoded ? `?${encoded}` : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The error a non-2xx becomes.
|
||||
*
|
||||
* `message` is the server's message verbatim, because §5.1 makes it
|
||||
* load-bearing: `store.js` threw `"<Entity> <id> not found"` and the API
|
||||
* reproduces that string exactly. The code, HTTP status and per-field details
|
||||
* ride along as properties — new information a local store never had, and
|
||||
* additive, so nothing that only reads `.message` notices.
|
||||
*/
|
||||
function apiError(status, payload) {
|
||||
const body = payload?.error;
|
||||
const error = new Error(body?.message || `Request failed with status ${status}`);
|
||||
error.name = 'KrowApiError';
|
||||
error.status = status;
|
||||
error.code = body?.code || 'internal';
|
||||
error.details = body?.details || {};
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* One request, unwrapped.
|
||||
*
|
||||
* Returns `payload.data`, so every caller above works in bare records exactly
|
||||
* as it did against the local store. `meta` is deliberately dropped: nothing
|
||||
* reads it (§4.2), and surfacing it would mean changing what the six methods
|
||||
* return, which is the one thing Phase 2D must not do.
|
||||
*/
|
||||
async function request(method, path, { query, body } = {}) {
|
||||
const url = `${API_BASE_URL}${path}${query ? queryString(query) : ''}`;
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method,
|
||||
// The session cookie is HttpOnly: this code cannot read it, attach it by
|
||||
// hand, or store it. `credentials` is the only lever there is, and
|
||||
// without it `fetch` omits cookies on cross-origin requests entirely.
|
||||
// Same-origin — the supported arrangement — would send them anyway;
|
||||
// saying so explicitly means the one line that makes authentication work
|
||||
// is visible rather than implied.
|
||||
credentials: 'include',
|
||||
headers: body === undefined
|
||||
? { Accept: 'application/json' }
|
||||
: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
} catch (cause) {
|
||||
// A transport failure, not an API response: no status, no envelope. The
|
||||
// browser's own message for this is "Failed to fetch", which says nothing
|
||||
// about which server or why, and it is nearly always the same cause — the
|
||||
// API is not running.
|
||||
const error = new Error(
|
||||
`Cannot reach the Krow API at ${API_BASE_URL}. Is the Go API running on ` +
|
||||
`127.0.0.1:8080, and is the Vite dev server proxying /api to it? ` +
|
||||
`(${method} ${path})`
|
||||
);
|
||||
error.name = 'KrowApiError';
|
||||
error.status = 0;
|
||||
error.code = 'unreachable';
|
||||
error.details = {};
|
||||
error.cause = cause;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
if (text) {
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
payload = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) throw apiError(response.status, payload);
|
||||
|
||||
if (payload === null) {
|
||||
const error = new Error(`${method} ${path} returned no JSON body`);
|
||||
error.name = 'KrowApiError';
|
||||
error.status = response.status;
|
||||
error.code = 'internal';
|
||||
error.details = {};
|
||||
throw error;
|
||||
}
|
||||
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
/** The request helper, for the `auth` surface in `krowClient.js`. */
|
||||
export { request };
|
||||
|
||||
/**
|
||||
* True when an error is the API saying "you are not signed in".
|
||||
*
|
||||
* A 401 is an ordinary, expected answer here — it is what every request gets
|
||||
* before the first login and after a session expires — so callers need to tell
|
||||
* it apart from a real failure rather than treating both as "something broke".
|
||||
*/
|
||||
export function isUnauthenticated(error) {
|
||||
return Boolean(error) && (error.status === 401 || error.code === 'unauthorized');
|
||||
}
|
||||
|
||||
/* ── Entity API ─────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Builds the client surface for one entity.
|
||||
*
|
||||
* Signature-compatible with `store.js`'s `createEntity`, defaults included. The
|
||||
* defaults matter more than they look: `store.js` declared
|
||||
* `list(sort = '-created_date', limit = 100)`, and several call sites rely on
|
||||
* them rather than passing their own.
|
||||
*/
|
||||
export function createEntity(name) {
|
||||
const path = RESOURCE_PATHS[name];
|
||||
if (!path) throw new Error(`No API resource path is declared for entity ${name}`);
|
||||
const base = `/${path}`;
|
||||
|
||||
return {
|
||||
entityName: name,
|
||||
|
||||
/**
|
||||
* `sort` is always sent, even when empty. `?sort=` is not the same as
|
||||
* omitting it: the contract reads an explicit empty value as "no ordering",
|
||||
* which is what `applySort` did with a falsy sort, while omitting it would
|
||||
* apply the endpoint's default.
|
||||
*/
|
||||
async list(sort = '-created_date', limit = 100) {
|
||||
return request('GET', base, { query: { sort, limit } });
|
||||
},
|
||||
|
||||
async filter(query = {}, sort = '-created_date', limit = 100) {
|
||||
// Spread first so a field genuinely named `sort`, `limit` or `offset`
|
||||
// could never shadow the reserved parameters (§1 records that no column
|
||||
// collides with them today; this keeps that true if one ever does).
|
||||
return request('GET', base, { query: { ...query, sort, limit } });
|
||||
},
|
||||
|
||||
async get(id) {
|
||||
return request('GET', `${base}/${encodeURIComponent(id)}`);
|
||||
},
|
||||
|
||||
async create(data) {
|
||||
return request('POST', base, { body: data });
|
||||
},
|
||||
|
||||
async update(id, data) {
|
||||
return request('PATCH', `${base}/${encodeURIComponent(id)}`, { body: data });
|
||||
},
|
||||
|
||||
async delete(id) {
|
||||
return request('DELETE', `${base}/${encodeURIComponent(id)}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Sequential creates, exactly as `store.js` did it.
|
||||
*
|
||||
* Not a batch endpoint and not `Promise.all`: the contract has no bulk
|
||||
* write (§12.1), and doing them one at a time keeps the failure behaviour
|
||||
* identical — the first rejection stops the run and the records before it
|
||||
* are already written.
|
||||
*/
|
||||
async bulkCreate(records = []) {
|
||||
const created = [];
|
||||
for (const record of records) created.push(await this.create(record));
|
||||
return created;
|
||||
},
|
||||
};
|
||||
}
|
||||
418
src/api/krowClient.js
Normal file
418
src/api/krowClient.js
Normal file
@@ -0,0 +1,418 @@
|
||||
/**
|
||||
* 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 Go API in `httpClient.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.
|
||||
*
|
||||
* Phase 2D moved the transport from a localStorage-backed store to HTTP:
|
||||
*
|
||||
* React → krowClient.js → HTTP → Go API → PostgreSQL
|
||||
*
|
||||
* The entity surface is unchanged. The seeded dataset lives in PostgreSQL,
|
||||
* loaded by the backend's `make seed`, and nothing in the running app carries a
|
||||
* copy of it: `store.js` — the localStorage database this replaced — is gone,
|
||||
* and `seed.js` is now a test fixture that no production module imports.
|
||||
*
|
||||
* The one thing still needed before the first response arrives is the *shape*
|
||||
* of the user's default preferences, because the accessor that reads them is
|
||||
* synchronous. That is `api/demoUser.js`: a default shape, not a record.
|
||||
*/
|
||||
|
||||
import { createEntity, request, isUnauthenticated, API_BASE_URL } from './httpClient';
|
||||
import { invokeLLM, uploadFile } from './aiEngine';
|
||||
import { DEMO_USER } from './demoUser';
|
||||
|
||||
const ENTITY_NAMES = [
|
||||
'JobPosting', 'JobApplication', 'AIInterview', 'Staff', 'WorkerProfile',
|
||||
'Course', 'Badge', 'LearningPath', 'Certification', 'RoleCategory',
|
||||
'UserActivity', 'Evidence', 'User',
|
||||
/* Who is on which position, and for how long. The record that turns "hired"
|
||||
into workforce allocation: without it a position knows its demand and its
|
||||
applicants but not who is actually covering it. */
|
||||
'Assignment',
|
||||
/* Shifts worked, missed and overrun. The operational record behind
|
||||
attendance and overtime analysis — see `api/attendanceSeed.js`. */
|
||||
'ShiftRecord',
|
||||
/* The agent and skill registry the runtime resolves from.
|
||||
Authored definitions used to be written into the account's preferences,
|
||||
which the backend stores faithfully and the runtime never reads — so an
|
||||
agent created in the editor showed as "published" and answered every
|
||||
request with 404. These are the endpoints that make an authored agent a
|
||||
real one. */
|
||||
'AgentDefinition',
|
||||
'SkillDefinition',
|
||||
];
|
||||
|
||||
const entities = Object.fromEntries(
|
||||
ENTITY_NAMES.map((name) => [name, createEntity(name)])
|
||||
);
|
||||
|
||||
/* ── Auth ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The last user the API returned.
|
||||
*
|
||||
* This is a **cache of `GET /me`**, not a store. The record itself lives in
|
||||
* PostgreSQL; this exists for one reason, and it is not offline support.
|
||||
*
|
||||
* `auth.preferences()` is synchronous, and it has to stay synchronous:
|
||||
* `AssistantPanelContext` decides whether Owliver starts open in a `useState`
|
||||
* initialiser, during the first render, and `krowHooks.js:42` merges the same
|
||||
* accessor under the async user so the first paint already has real values. One
|
||||
* tick later is a visible flash of the wrong workspace — the panel opening on
|
||||
* an account that turned it off, then closing.
|
||||
*
|
||||
* So the last known user is mirrored to localStorage and read back at module
|
||||
* load, and `GET /me` refreshes it. On a return visit the synchronous read is
|
||||
* already correct; on a first-ever visit it is the seeded defaults for exactly
|
||||
* as long as the request takes, which is the same thing the old store did with
|
||||
* an empty key.
|
||||
*/
|
||||
const SESSION_KEY = 'krow_demo_user';
|
||||
|
||||
function readCachedUser() {
|
||||
try {
|
||||
const raw = localStorage.getItem(SESSION_KEY);
|
||||
return raw ? { ...DEMO_USER, ...JSON.parse(raw) } : { ...DEMO_USER };
|
||||
} catch {
|
||||
return { ...DEMO_USER };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the current user for the next page load's synchronous read.
|
||||
*
|
||||
* Failures are ignored, which is a real change from the old `persistUser` and a
|
||||
* safe one. That function checked its write and reported failure because
|
||||
* localStorage was the *only* copy — a swallowed `QuotaExceededError` was how
|
||||
* account-authored skills silently disappeared. Now the only copy is in
|
||||
* PostgreSQL, and a refused mirror costs one render of default preferences, not
|
||||
* data.
|
||||
*/
|
||||
function cacheUser() {
|
||||
try {
|
||||
localStorage.setItem(SESSION_KEY, JSON.stringify(currentUser));
|
||||
} catch {
|
||||
// Private browsing or quota — the server still has the record.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the mirrored user.
|
||||
*
|
||||
* Signing out must not leave the next page load rendering the previous
|
||||
* account's name and preferences out of localStorage while it waits for a
|
||||
* `GET /me` that is going to 401.
|
||||
*/
|
||||
function forgetUser() {
|
||||
try {
|
||||
localStorage.removeItem(SESSION_KEY);
|
||||
} catch {
|
||||
// Ignore.
|
||||
}
|
||||
currentUser = { ...DEMO_USER };
|
||||
}
|
||||
|
||||
let currentUser = readCachedUser();
|
||||
|
||||
/**
|
||||
* Whether the last `GET /me` succeeded.
|
||||
*
|
||||
* A cache of the server's answer, not a decision. Nothing here grants access:
|
||||
* the API refuses an unauthenticated request whatever this says, and a user who
|
||||
* edits it in the console has changed a boolean in their own tab and nothing
|
||||
* else. It exists because `isAuthenticated()` is synchronous.
|
||||
*/
|
||||
let authenticated = false;
|
||||
|
||||
/**
|
||||
* The first `GET /me`, shared.
|
||||
*
|
||||
* Started at module load so the synchronous accessor is corrected as early as
|
||||
* possible, and shared so the eleven `me()` call sites that fire during the
|
||||
* first render make one request between them rather than eleven.
|
||||
*
|
||||
* A 401 here is the ordinary state of a signed-out visitor, not a failure: the
|
||||
* app opens on the login page and this request is how it finds that out.
|
||||
*/
|
||||
let hydration = request('GET', '/me')
|
||||
.then((user) => {
|
||||
currentUser = user;
|
||||
authenticated = true;
|
||||
cacheUser();
|
||||
return user;
|
||||
})
|
||||
.catch(() => {
|
||||
authenticated = false;
|
||||
return null;
|
||||
});
|
||||
|
||||
const auth = {
|
||||
/**
|
||||
* The signed-in user, from the session cookie.
|
||||
*
|
||||
* Throws when there is no session — a `KrowApiError` with `status: 401` — and
|
||||
* that throw is the app's authentication check. `AuthContext` catches it and
|
||||
* renders the login page. Nothing here decides who the user is; the server
|
||||
* reads its own session table and answers.
|
||||
*
|
||||
* Joins the in-flight hydration if there is one, so the first render's
|
||||
* callers share a request; refetches afterwards so a change made in another
|
||||
* tab, or a session that has since expired, is picked up.
|
||||
*/
|
||||
async me() {
|
||||
if (hydration) {
|
||||
const user = await hydration;
|
||||
hydration = null;
|
||||
if (user) {
|
||||
authenticated = true;
|
||||
return { ...user };
|
||||
}
|
||||
}
|
||||
try {
|
||||
const user = await request('GET', '/me');
|
||||
currentUser = user;
|
||||
authenticated = true;
|
||||
cacheUser();
|
||||
return { ...user };
|
||||
} catch (error) {
|
||||
if (isUnauthenticated(error)) {
|
||||
authenticated = false;
|
||||
forgetUser();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Signs in and starts a session.
|
||||
*
|
||||
* The response body is the user. The session token is NOT in it — it arrives
|
||||
* as an HttpOnly cookie the browser stores and this code cannot read, which
|
||||
* is what stops a script on the page from stealing it. There is deliberately
|
||||
* nothing here that writes a token anywhere.
|
||||
*
|
||||
* Every credential failure comes back as the same 401 with the same message,
|
||||
* by design: telling the two apart would say whether an address is
|
||||
* registered. The caller shows that message as-is.
|
||||
*/
|
||||
async login({ email, password, rememberMe = false }) {
|
||||
const user = await request('POST', '/auth/login', {
|
||||
body: { email, password, remember_me: Boolean(rememberMe) },
|
||||
});
|
||||
currentUser = user;
|
||||
authenticated = true;
|
||||
hydration = null;
|
||||
cacheUser();
|
||||
return { ...user };
|
||||
},
|
||||
|
||||
async updateMe(patch) {
|
||||
const user = await request('PATCH', '/me', { body: patch });
|
||||
currentUser = user;
|
||||
cacheUser();
|
||||
return { ...user };
|
||||
},
|
||||
|
||||
/**
|
||||
* Preferences, read synchronously.
|
||||
*
|
||||
* A plain read of the same record `me()` returns, defaulted with the shape
|
||||
* from `demoUser.js` so a key the server has never stored still resolves. See the
|
||||
* note on `currentUser` for why this must not become async.
|
||||
*/
|
||||
preferences() {
|
||||
return { ...DEMO_USER.preferences, ...(currentUser.preferences || {}) };
|
||||
},
|
||||
|
||||
/**
|
||||
* Merges into the stored preferences and persists them server-side.
|
||||
*
|
||||
* `PATCH /me/preferences` shallow-merges and returns the whole merged object,
|
||||
* which is where `customSkills` and `customAgents` — every account-authored
|
||||
* definition — now live: `user_preferences.extra`, a real column in a real
|
||||
* database rather than a browser key.
|
||||
*
|
||||
* The `{ user, persisted, error }` shape is kept because `saveFeedback.js`
|
||||
* reads it. Over HTTP a write that did not land is a non-2xx and therefore a
|
||||
* throw, so the success path is unconditionally `persisted: true` — the
|
||||
* question the shape exists to answer is now answered by whether this
|
||||
* function resolved at all.
|
||||
*/
|
||||
async updatePreferences(patch) {
|
||||
const preferences = await request('PATCH', '/me/preferences', { body: patch });
|
||||
currentUser = { ...currentUser, preferences };
|
||||
cacheUser();
|
||||
return { user: { ...currentUser }, persisted: true, error: null };
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether the last `GET /me` succeeded.
|
||||
*
|
||||
* Synchronous, and therefore only ever a cache of what the server last said.
|
||||
* It is a hint for rendering, never a gate: every protected endpoint is
|
||||
* refused by the API on its own authority regardless of this value.
|
||||
*/
|
||||
isAuthenticated() {
|
||||
return authenticated;
|
||||
},
|
||||
|
||||
/**
|
||||
* Signs out and returns to the requested page.
|
||||
*
|
||||
* The server revokes the session row and expires the cookie; this clears the
|
||||
* cached copy of the user so a signed-out tab cannot render a stale name from
|
||||
* localStorage. The redirect happens either way — a logout that could not
|
||||
* reach the API must still leave the browser signed out locally, and the
|
||||
* cookie it keeps will be refused by every request it is sent on.
|
||||
*/
|
||||
async logout(redirectTo = '/login') {
|
||||
try {
|
||||
await request('POST', '/auth/logout');
|
||||
} catch {
|
||||
// Already signed out, or the API is unreachable. Neither is a reason to
|
||||
// keep the user looking at a signed-in page.
|
||||
}
|
||||
authenticated = false;
|
||||
forgetUser();
|
||||
window.location.href = typeof redirectTo === 'string' ? redirectTo : '/login';
|
||||
},
|
||||
|
||||
redirectToLogin() {
|
||||
window.location.href = '/login';
|
||||
},
|
||||
};
|
||||
|
||||
/* ── Workflows ──────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The multi-record writes, as one request each.
|
||||
*
|
||||
* These are the only endpoints in this file that are not a CRUD projection of a
|
||||
* table, and they exist because the flows below were previously performed as a
|
||||
* sequence of independent requests with no transaction and no rollback — a hire
|
||||
* whose PATCH landed and whose POST did not left a candidate marked `hired` with
|
||||
* no employment record, and nothing in the UI could tell.
|
||||
*
|
||||
* They are shaped as verbs on the record they act on, so the entity surface
|
||||
* above is untouched: `entities.JobApplication` still means the table, and
|
||||
* hiring is a thing you do *to* an application rather than a fifteenth entity.
|
||||
*
|
||||
* `request` unwraps the envelope, so `hire` resolves to
|
||||
* `{ application, staff }` and `assign` to `{ assignments, count }` — both
|
||||
* records the server actually wrote, so no caller needs a follow-up read to
|
||||
* render the outcome.
|
||||
*/
|
||||
const workflows = {
|
||||
/**
|
||||
* Move an application to `hired` and create the staff record, atomically.
|
||||
*
|
||||
* `body` carries only what a hiring form collects — `role`, `profile_tier`,
|
||||
* `hire_date`, `status`, `phone`, `reviewer_name`. Everything else is carried
|
||||
* across from the application by the server, because it is already the truth
|
||||
* about this person and retyping it here is how the two records drift apart.
|
||||
*/
|
||||
async hire(applicationId, body = {}) {
|
||||
return request('POST', `/job-applications/${encodeURIComponent(applicationId)}/hire`, {
|
||||
body,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Place workers on a posting, atomically.
|
||||
*
|
||||
* All-or-nothing across the batch: assigning six people and having the fourth
|
||||
* fail must not leave three placed, three not, and the caller unsure which.
|
||||
* Each entry names its application by `application_id` if the caller already
|
||||
* has one, or describes one under `application` for the server to find or file
|
||||
* inside the same transaction; a worker taken straight from the talent pool
|
||||
* with neither is placed without one rather than given an invented one.
|
||||
*/
|
||||
async assign(jobPostingId, workers = []) {
|
||||
return request('POST', `/job-postings/${encodeURIComponent(jobPostingId)}/assignments`, {
|
||||
body: { workers },
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/* ── Owliver ────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* What could usefully be asked on this page.
|
||||
*
|
||||
* The one read in this file that is not a table. `GET /owliver/suggestions`
|
||||
* answers with at most three questions, and the server decides all three: it
|
||||
* filters them against the caller's role through the same policy table every
|
||||
* other endpoint consults, and — when nothing has been typed — ranks them
|
||||
* against the organization's actual state in PostgreSQL. A workspace with
|
||||
* unfinished drafts is asked about drafts; one with unscored candidates is
|
||||
* asked about screening.
|
||||
*
|
||||
* Nothing is ranked, scored, filtered or reordered on this side. That is the
|
||||
* point: a suggestion is a claim that the reader could usefully ask something,
|
||||
* and the only thing that knows whether that is true is the thing holding the
|
||||
* data and the permissions. The panel requests, renders, and runs whichever one
|
||||
* is chosen through the capability it names.
|
||||
*
|
||||
* `query` is what is in the composer. Sent only when it is non-empty: an absent
|
||||
* query asks the data what to suggest, and an empty string would be a different
|
||||
* request from the one the caller means.
|
||||
*/
|
||||
const owliver = {
|
||||
/** @param {any} request */
|
||||
async suggestions({ page, query = '' } = {}) {
|
||||
if (!page) return [];
|
||||
const typed = String(query || '').trim();
|
||||
const data = await request('GET', '/owliver/suggestions', {
|
||||
query: { page, ...(typed ? { query: typed } : {}) },
|
||||
});
|
||||
return Array.isArray(data?.suggestions) ? data.suggestions : [];
|
||||
},
|
||||
};
|
||||
|
||||
/* ── 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 krow = { entities, auth, integrations, analytics, workflows, owliver };
|
||||
|
||||
/** Where the entity data actually comes from, for diagnostics. */
|
||||
export { API_BASE_URL };
|
||||
|
||||
/**
|
||||
* Clears local session state and reloads.
|
||||
*
|
||||
* The demo dataset is no longer the browser's to restore: it lives in
|
||||
* PostgreSQL, and reseeding it is `make seed` in the `krow-backend` repository,
|
||||
* which upserts the shipped fixture in one transaction. All this can still do
|
||||
* is drop the cached user and reload, so it says so rather than reporting a
|
||||
* reset it did not perform.
|
||||
*/
|
||||
export function resetDemoData() {
|
||||
try {
|
||||
localStorage.removeItem(SESSION_KEY);
|
||||
} catch {
|
||||
// Ignore.
|
||||
}
|
||||
console.info(
|
||||
'[krow-demo] Local session cache cleared. Entity data lives in PostgreSQL — ' +
|
||||
'restore the shipped dataset with `make seed` in krow-backend.'
|
||||
);
|
||||
window.location.reload();
|
||||
}
|
||||
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';
|
||||
62
src/components/ErrorBoundary.jsx
Normal file
62
src/components/ErrorBoundary.jsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import { Surface } from '@/components/ds/Surface';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
/**
|
||||
* The last line before a white screen.
|
||||
*
|
||||
* A render error anywhere below this unmounts the whole tree, and React's
|
||||
* default for that is a blank page with the reason only in the console — which
|
||||
* on a deployed build is a bug report that says "it broke". This catches it and
|
||||
* says what happened, in the app's own surface, with a way out.
|
||||
*
|
||||
* It is deliberately not a route-level boundary. One at the root is what keeps
|
||||
* a crash from erasing the shell; anything finer would need a story about which
|
||||
* parts of a page may fail independently, and this app does not have one yet.
|
||||
*
|
||||
* Class component because that is still the only way to implement
|
||||
* `componentDidCatch` — there is no hook equivalent.
|
||||
*/
|
||||
export default class ErrorBoundary extends React.Component {
|
||||
state = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error, info) {
|
||||
/* The console is where a developer will look, and in production it is what
|
||||
a browser's error reporting picks up. */
|
||||
console.error('Unhandled render error:', error, info?.componentStack);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { error } = this.state;
|
||||
if (!error) return this.props.children;
|
||||
|
||||
return (
|
||||
<div className="grid min-h-dvh place-items-center px-4">
|
||||
<Surface variant="solid" radius="xl" padding="lg" elevation="md" className="max-w-md text-center">
|
||||
<h1 className="font-heading text-title-lg font-bold text-ink-1">Something broke</h1>
|
||||
<p className="mt-2 text-body-sm text-ink-3">
|
||||
This page stopped rendering. Nothing you were looking at was saved or
|
||||
changed — reloading should bring it back.
|
||||
</p>
|
||||
{/* The message, not the stack: a stack in the UI helps nobody who is
|
||||
not already reading the console, where it also is. */}
|
||||
{error.message && (
|
||||
<p className="mt-3 rounded-lg bg-surface-sunken px-3 py-2 font-mono text-caption text-ink-3">
|
||||
{error.message}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-5 flex justify-center gap-2">
|
||||
<Button onClick={() => window.location.reload()}>Reload the page</Button>
|
||||
<Button variant="outline" onClick={() => { window.location.href = '/dashboard'; }}>
|
||||
Go to Dashboard
|
||||
</Button>
|
||||
</div>
|
||||
</Surface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
60
src/components/ProtectedRoute.jsx
Normal file
60
src/components/ProtectedRoute.jsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Navigate, Outlet, useLocation } 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>
|
||||
);
|
||||
|
||||
/**
|
||||
* `unauthenticatedElement` defaults to the login page.
|
||||
*
|
||||
* It used to default to `undefined`, which renders nothing. That was invisible
|
||||
* while the data client reported the seeded user as permanently signed in and
|
||||
* this branch was unreachable; now that `GET /me` can genuinely answer 401, the
|
||||
* default is what a signed-out visitor actually sees, and a blank screen is not
|
||||
* an acceptable answer to "you are not signed in".
|
||||
*
|
||||
* The attempted path travels in location state so signing in returns the
|
||||
* visitor to where they were going.
|
||||
*/
|
||||
/**
|
||||
* @param {object} props
|
||||
* @param {React.ReactElement} [props.fallback] Shown while the session is being checked.
|
||||
* @param {React.ReactElement} [props.unauthenticatedElement] Where a signed-out
|
||||
* visitor goes. Optional — it defaults to the login redirect below, which is
|
||||
* what every call site wants; the prop exists so a test can substitute one.
|
||||
*/
|
||||
export default function ProtectedRoute({ fallback = <DefaultFallback />, unauthenticatedElement }) {
|
||||
const location = useLocation();
|
||||
const { isAuthenticated, isLoadingAuth, authChecked, authError, checkUserAuth } = useAuth();
|
||||
const signedOut = unauthenticatedElement ?? (
|
||||
<Navigate to="/login" replace state={{ from: location.pathname }} />
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authChecked && !isLoadingAuth) {
|
||||
checkUserAuth();
|
||||
}
|
||||
}, [authChecked, isLoadingAuth, checkUserAuth]);
|
||||
|
||||
if (isLoadingAuth || !authChecked) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (authError) {
|
||||
if (authError.type === 'user_not_registered') {
|
||||
return <UserNotRegisteredError />;
|
||||
}
|
||||
return signedOut;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return signedOut;
|
||||
}
|
||||
|
||||
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;
|
||||
30
src/components/agents/icons.js
Normal file
30
src/components/agents/icons.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
Activity, BarChart3, Briefcase, GraduationCap, Layers, Shield, Sparkles, UserCheck, Users,
|
||||
} from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Agent icon id → component.
|
||||
*
|
||||
* A hand-written table, for the same reason `SECTION_COMPONENTS` is one: a
|
||||
* definition names a key, and only a key written down here resolves to
|
||||
* anything. There is no path from a Markdown file to a component nobody chose.
|
||||
*
|
||||
* `owliver` is deliberately absent — it is not a lucide glyph but the product's
|
||||
* own avatar, and the switcher draws it directly. Returning `null` for it is
|
||||
* the signal to do that, which keeps the primary agent looking like Owliver
|
||||
* rather than like a generic icon.
|
||||
*/
|
||||
const ICONS = {
|
||||
sparkles: Sparkles,
|
||||
briefcase: Briefcase,
|
||||
users: Users,
|
||||
'user-check': UserCheck,
|
||||
layers: Layers,
|
||||
'graduation-cap': GraduationCap,
|
||||
'bar-chart': BarChart3,
|
||||
activity: Activity,
|
||||
shield: Shield,
|
||||
};
|
||||
|
||||
/** The component for an icon id, or null when the avatar should be drawn. */
|
||||
export const agentIconFor = (id) => ICONS[String(id || '').trim()] ?? null;
|
||||
83
src/components/ai-assistant/AgentBadge.jsx
Normal file
83
src/components/ai-assistant/AgentBadge.jsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { agentIconFor } from '@/components/agents/icons';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
import { useActiveAgent } from './AgentContext';
|
||||
|
||||
/**
|
||||
* Who is answering, in Owliver's own header.
|
||||
*
|
||||
* This was an agent *switcher*: the header opened a popover with a search box,
|
||||
* every registered agent, Browse all and Create agent. That made the panel a
|
||||
* second place to decide which agent answers, competing with the one place that
|
||||
* actually configures agents — the Agent Editor — and letting a reader put
|
||||
* Owliver into a state nothing on the page explained.
|
||||
*
|
||||
* It is now a label. The agent is whatever `AgentContext` resolves for the page
|
||||
* being read, which is the same resolution the runtime already uses to answer,
|
||||
* so the header cannot disagree with the reply underneath it. Switching belongs
|
||||
* to Agent Registry → Agents → Configure.
|
||||
*
|
||||
* Deliberately **not** a button, and deliberately no chevron: a disclosure
|
||||
* affordance that discloses nothing is worse than none. Nothing here is
|
||||
* clickable, so nothing here promises a menu.
|
||||
*/
|
||||
|
||||
/** The avatar for the primary agent; a lucide glyph for the rest. */
|
||||
function AgentGlyph({ agent, className = 'h-7 w-7' }) {
|
||||
const Icon = agentIconFor(agent?.icon);
|
||||
|
||||
if (!Icon) return <OwliverAvatar className={className} rounded="rounded-lg" />;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center justify-center rounded-lg border border-border/80 bg-surface-subtle text-krow-blue shadow-2xs',
|
||||
className
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The mark a constrained agent carries.
|
||||
*
|
||||
* Kept: it is a fact about the answer that follows — this agent does not cover
|
||||
* this page and will say so — not a control, and losing it would make a
|
||||
* constrained reply arrive with no warning.
|
||||
*/
|
||||
function ConstrainedTag() {
|
||||
return (
|
||||
<span className="inline-flex shrink-0 items-center rounded-md border border-amber-500/25 bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-amber-700 dark:text-amber-300">
|
||||
Constrained
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {any} props */
|
||||
export function AgentBadge({ page }) {
|
||||
const { agent, covers } = useActiveAgent();
|
||||
|
||||
return (
|
||||
<>
|
||||
{agent
|
||||
? <AgentGlyph agent={agent} />
|
||||
: <OwliverAvatar className="h-7 w-7" rounded="rounded-lg" />}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="truncate font-heading text-body-sm font-semibold leading-tight text-ink-1">
|
||||
Owliver
|
||||
</h2>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<p className="truncate text-caption leading-tight text-ink-3">{page}</p>
|
||||
{agent && !covers && <ConstrainedTag />}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentBadge;
|
||||
196
src/components/ai-assistant/AgentContext.jsx
Normal file
196
src/components/ai-assistant/AgentContext.jsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import * as React from 'react';
|
||||
import { usePreferences } from '@/lib/krowHooks';
|
||||
import { allAgents } from '@/lib/agents/registry';
|
||||
import {
|
||||
agentCovers, nativeAgentForContext, resolveAgentForTurn, resolveDefaultAgent, resolveSelection,
|
||||
} from '@/lib/agents/runtime';
|
||||
|
||||
/**
|
||||
* Which agent is answering.
|
||||
*
|
||||
* Built on the same principles as `PageContext.jsx`, and deliberately kept
|
||||
* beside it rather than merged into it:
|
||||
*
|
||||
* - **The page is not an input to itself.** This context reads the page's
|
||||
* assistant context to decide a *default*, and never the other way round.
|
||||
* Nothing here can change which page the reader is on, which is what makes
|
||||
* "switching agent cannot change PageContext" true by construction rather
|
||||
* than by care.
|
||||
* - **Session-scoped, like the panel's own window state.** Choosing an agent
|
||||
* for the afternoon should not rewrite an account default, and a new tab
|
||||
* should open on the page's own agent.
|
||||
*
|
||||
* Resolution order: what this session chose *and that still applies here* → the
|
||||
* account's stated default, on the same condition → the page's own agent → the
|
||||
* general agent. A page therefore always opens on the agent written for it, a
|
||||
* page nobody wrote one for opens on the general agent rather than on nothing,
|
||||
* and a deliberate choice still survives navigation across the pages it covers.
|
||||
*
|
||||
* **A selection is made somewhere.** That is the correction this file carries:
|
||||
* it stores the context a choice was made on, not only the agent's id. Storing
|
||||
* the id alone meant a specialist chosen on Positions followed the reader onto
|
||||
* Settings and constrained a page nobody had chosen it for — the panel looked
|
||||
* broken and the reason was invisible. The rule itself lives in
|
||||
* `runtime.resolveSelection` as a pure function, so what applies where is
|
||||
* decided in one place and can be proved without a React tree.
|
||||
*/
|
||||
|
||||
const AgentContext = React.createContext(null);
|
||||
|
||||
const SESSION_KEY = 'krow_assistant:agent';
|
||||
|
||||
/**
|
||||
* The stored selection: `{ id, contextId }`.
|
||||
*
|
||||
* A bare string is still read, because that is what earlier sessions wrote and
|
||||
* a stored value from yesterday should not throw. It resolves as a choice made
|
||||
* nowhere, which is the honest reading of it — and the conservative one, since a
|
||||
* selection with no context applies only where it legitimately covers.
|
||||
*/
|
||||
function readSelection() {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(SESSION_KEY);
|
||||
if (!raw) return null;
|
||||
if (!raw.startsWith('{')) return { id: raw, contextId: null };
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed?.id ? { id: parsed.id, contextId: parsed.contextId ?? null } : null;
|
||||
} catch {
|
||||
/* Private mode, or a value that will not parse: the session simply always
|
||||
opens on the page's own agent. */
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeSelection(selection) {
|
||||
try {
|
||||
if (selection?.id) sessionStorage.setItem(SESSION_KEY, JSON.stringify(selection));
|
||||
else sessionStorage.removeItem(SESSION_KEY);
|
||||
} catch {
|
||||
/* Held in memory for this session only. */
|
||||
}
|
||||
}
|
||||
|
||||
export function AgentProvider({ contextId = null, children }) {
|
||||
const preferences = usePreferences();
|
||||
|
||||
/* Shipped definitions plus anything this account has authored, read through
|
||||
the one registry so the switcher and the management page cannot disagree
|
||||
about what exists. */
|
||||
const agents = React.useMemo(
|
||||
() => allAgents(preferences.customAgents || [], { customSkills: preferences.customSkills || [] }),
|
||||
[preferences.customAgents, preferences.customSkills]
|
||||
);
|
||||
|
||||
const [selection, setSelection] = React.useState(() => readSelection());
|
||||
|
||||
const select = React.useCallback((id) => {
|
||||
/* Stamped with where it was chosen. A specialist picked on a page it does
|
||||
not cover is a deliberate act and is honoured *here*; the stamp is what
|
||||
stops it from becoming a decision about every other page too. */
|
||||
const next = id ? { id, contextId } : null;
|
||||
setSelection(next);
|
||||
writeSelection(next);
|
||||
}, [contextId]);
|
||||
|
||||
/** Back to whichever agent this page resolves on its own. */
|
||||
const clearSelection = React.useCallback(() => {
|
||||
setSelection(null);
|
||||
writeSelection(null);
|
||||
}, []);
|
||||
|
||||
/* What the stored choice means on the page the reader is on now. */
|
||||
const applied = React.useMemo(
|
||||
() => resolveSelection(agents, selection, contextId),
|
||||
[agents, selection, contextId]
|
||||
);
|
||||
|
||||
/**
|
||||
* Storage housekeeping, in one place.
|
||||
*
|
||||
* Retiring a spent selection and re-stamping a live one are both writes about
|
||||
* *navigation*, not about a choice, so they belong in an effect rather than in
|
||||
* `select`. Both converge: the effect only writes when the stored value would
|
||||
* actually change.
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
if (applied.retire) {
|
||||
setSelection(null);
|
||||
writeSelection(null);
|
||||
return;
|
||||
}
|
||||
if (applied.id && selection?.contextId !== contextId) {
|
||||
const next = { id: applied.id, contextId };
|
||||
setSelection(next);
|
||||
writeSelection(next);
|
||||
}
|
||||
}, [applied, selection, contextId]);
|
||||
|
||||
const value = React.useMemo(() => {
|
||||
/**
|
||||
* What this turn asks for.
|
||||
*
|
||||
* The session's choice first, then the account default — and the account
|
||||
* default is put through the same rule, as a choice made nowhere. A stated
|
||||
* default that does not cover this page is not a decision about this page,
|
||||
* so it must not constrain it; the page resolves its own agent instead.
|
||||
*/
|
||||
const stated = applied.id
|
||||
|| resolveSelection(agents, preferences.defaultAgentId || null, contextId).id
|
||||
|| null;
|
||||
|
||||
const turn = resolveAgentForTurn(agents, stated, contextId);
|
||||
/* The page's own agent, offered as the way out of a constrained state.
|
||||
Null on a page nobody wrote one for — `fallback` is what answers there. */
|
||||
const native = nativeAgentForContext(agents, contextId);
|
||||
/* What this page opens on with nothing chosen: its own agent, or the
|
||||
general one. Never null, so no page is ever left without an agent. */
|
||||
const fallback = resolveDefaultAgent(agents, contextId);
|
||||
|
||||
return {
|
||||
agents,
|
||||
/* The agent that will answer. Never silently swapped: a reader who chose
|
||||
one *for this page* gets that one, with `covers` saying whether it
|
||||
belongs here. A choice carried in from another page is not that. */
|
||||
agent: turn.agent,
|
||||
covers: turn.covers,
|
||||
native,
|
||||
/* What this page resolves to with nothing chosen — its own agent, or the
|
||||
general one. This is what a constrained answer points at, so that the
|
||||
way out of a constrained state is named on every page rather than only
|
||||
on the pages that have a specialist. */
|
||||
defaultAgent: fallback,
|
||||
/* True while this page is simply showing the agent it resolves on its
|
||||
own, whether that is its specialist or the general agent. */
|
||||
isNative: Boolean(fallback && turn.agent && fallback.id === turn.agent.id && !applied.id),
|
||||
chosenId: applied.id,
|
||||
select,
|
||||
clearSelection,
|
||||
/* Whether a given agent belongs on this page, for the list. */
|
||||
coversPage: (candidate) => agentCovers(candidate, contextId),
|
||||
};
|
||||
}, [agents, applied, preferences.defaultAgentId, contextId, select, clearSelection]);
|
||||
|
||||
return <AgentContext.Provider value={value}>{children}</AgentContext.Provider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The active agent.
|
||||
*
|
||||
* Returns an inert value outside a provider, so a panel rendered in isolation —
|
||||
* a test, a storybook — behaves as it did before agents existed rather than
|
||||
* crashing.
|
||||
*/
|
||||
export function useActiveAgent() {
|
||||
return React.useContext(AgentContext) ?? {
|
||||
agents: [],
|
||||
agent: null,
|
||||
covers: true,
|
||||
native: null,
|
||||
defaultAgent: null,
|
||||
isNative: false,
|
||||
chosenId: null,
|
||||
select: () => {},
|
||||
clearSelection: () => {},
|
||||
coversPage: () => false,
|
||||
};
|
||||
}
|
||||
128
src/components/ai-assistant/AssistantMessage.jsx
Normal file
128
src/components/ai-assistant/AssistantMessage.jsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import * as React from 'react';
|
||||
import { ThumbsDown, ThumbsUp } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ResponseDocument } from './ResponseBlocks';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
|
||||
/**
|
||||
* The thinking state, shown between sending and the first block arriving.
|
||||
*
|
||||
* Labelled as well as animated: three moving dots mean nothing to a screen
|
||||
* reader, and the visually-hidden text is what tells one that a reply is coming.
|
||||
*/
|
||||
export function ThinkingIndicator() {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<OwliverAvatar className="h-6 w-6" rounded="rounded-full" />
|
||||
<span className="inline-flex items-center gap-1 rounded-xl rounded-tl-sm border border-border bg-surface px-2.5 py-2 shadow-xs">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="h-1.5 w-1.5 animate-typing rounded-full bg-ink-4"
|
||||
style={{ animationDelay: `${i * 0.18}s` }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
<span className="sr-only">Owliver is preparing a response</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One turn.
|
||||
*
|
||||
* The user's words sit in a filled bubble; the assistant's reply runs the full
|
||||
* width of the panel. That asymmetry is deliberate — these replies are reports
|
||||
* with tables and KPI tiles, and a chat bubble would waste a third of a 380px
|
||||
* column on padding around them.
|
||||
*/
|
||||
/**
|
||||
* How this conversation was rated.
|
||||
*
|
||||
* Offered on the latest answer only, because a rating belongs to the
|
||||
* conversation rather than to a paragraph of it — thumbs on every turn would
|
||||
* ask a reader to score an answer out of the exchange that produced it, and
|
||||
* each one would set the same value anyway.
|
||||
*
|
||||
* Hidden until the turn is hovered or focused, the way History's delete control
|
||||
* already is: a thread of reports should not carry two permanent buttons under
|
||||
* every answer.
|
||||
*/
|
||||
function FeedbackControls({ feedback, onFeedback }) {
|
||||
const rate = (rating) => onFeedback(feedback?.rating === rating ? null : rating);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-2 flex items-center gap-0.5 transition-opacity',
|
||||
/* Stays visible once rated, so a reader can see and change their own
|
||||
answer rather than having to hunt for it again. */
|
||||
feedback ? 'opacity-100' : 'opacity-0 group-hover/turn:opacity-100 group-focus-within/turn:opacity-100'
|
||||
)}
|
||||
>
|
||||
{[
|
||||
{ rating: 'up', Icon: ThumbsUp, label: 'This answer was useful' },
|
||||
{ rating: 'down', Icon: ThumbsDown, label: 'This answer was not useful' },
|
||||
].map(({ rating, Icon, label }) => (
|
||||
<button
|
||||
key={rating}
|
||||
type="button"
|
||||
onClick={() => rate(rating)}
|
||||
aria-label={label}
|
||||
aria-pressed={feedback?.rating === rating}
|
||||
className={cn(
|
||||
`rounded p-1 transition-colors focus-visible:outline-none
|
||||
focus-visible:ring-2 focus-visible:ring-krow-blue/50`,
|
||||
feedback?.rating === rating
|
||||
? 'text-krow-blue'
|
||||
: 'text-ink-4 hover:text-ink-2'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Message = React.memo(
|
||||
/** @param {any} props */
|
||||
({ role, text, blocks, streaming, stopped, onPrompt, onConfirm = null,
|
||||
feedback = null, onFeedback = null }) => {
|
||||
if (role === 'user') {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<div className="max-w-[85%] animate-slide-up rounded-xl rounded-br-sm bg-krow-blue px-3 py-2 text-body-sm text-white">
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group/turn animate-slide-up">
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<OwliverAvatar className="h-5 w-5" rounded="rounded-full" />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Owliver</span>
|
||||
</div>
|
||||
|
||||
<ResponseDocument blocks={blocks} streaming={streaming} onPrompt={onPrompt} onConfirm={onConfirm} />
|
||||
|
||||
{stopped && <p className="mt-2 text-caption italic text-ink-4">Stopped early.</p>}
|
||||
|
||||
{/* Only where a handler was passed — a turn nobody is collecting feedback
|
||||
for renders exactly as it did before this existed. */}
|
||||
{onFeedback && !streaming && (
|
||||
<FeedbackControls feedback={feedback} onFeedback={onFeedback} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
Message.displayName = 'Message';
|
||||
|
||||
/** A subtle divider between turns, so a long thread stays readable. */
|
||||
export function TurnDivider() {
|
||||
return <div className="h-px bg-border/60" role="presentation" />;
|
||||
}
|
||||
|
||||
export { cn };
|
||||
268
src/components/ai-assistant/AssistantPanel.jsx
Normal file
268
src/components/ai-assistant/AssistantPanel.jsx
Normal file
@@ -0,0 +1,268 @@
|
||||
import * as React from 'react';
|
||||
import { PanelLeftOpen } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import KrowAssistant from './KrowAssistant';
|
||||
import { useAssistantPanel } from './AssistantPanelContext';
|
||||
import { ResizeDivider } from './ResizeDivider';
|
||||
import { useIsPhone, useViewportWidth, useVisualViewport } from './viewport';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
|
||||
const EXPANDED_WIDTH = 620;
|
||||
/**
|
||||
* Gutter separating the panel from the dashboard, split between the drag handle
|
||||
* and the panel's own lead padding. Both halves are named because the track width
|
||||
* is their sum plus the panel: get that arithmetic wrong and the column either
|
||||
* overflows or leaves a strip of unexplained empty space at the shell's edge.
|
||||
*/
|
||||
const HANDLE_WIDTH = 12;
|
||||
const LEAD_PADDING = 8;
|
||||
const GUTTER = HANDLE_WIDTH + LEAD_PADDING;
|
||||
/** Expanded must never dominate: the dashboard stays the primary experience. */
|
||||
const MAX_VIEWPORT_SHARE = 0.42;
|
||||
/**
|
||||
* The collapsed state — a compact docked trigger.
|
||||
*
|
||||
* The earlier version was a full-height rail inside the layout column. It solved
|
||||
* the wrong problem: it kept Owliver *findable*, but it also kept the column, so
|
||||
* collapsing bought the dashboard 44px instead of the 400px the user was asking
|
||||
* for, and it did it with rotated text down the side of the page.
|
||||
*
|
||||
* So collapsing now removes the column entirely — the layout goes to one column
|
||||
* and `main` takes the whole shell — and the way back is this small docked pill.
|
||||
* It is deliberately the only floating control in the product, and it exists only
|
||||
* in the collapsed state, where there is nowhere else for it to live.
|
||||
*/
|
||||
function CollapsedTrigger({ page, onRestore }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRestore}
|
||||
aria-label={`Show the Owliver workspace for ${page}`}
|
||||
aria-expanded={false}
|
||||
/* `bottom` is a `max()` against the bottom safe-area inset rather than a
|
||||
flat 20px: on a phone with a home indicator a flat offset puts the pill
|
||||
under the gesture bar, where the tap belongs to the OS. `env()` is 0
|
||||
everywhere else, so desktop keeps the offset it always had. */
|
||||
className="group fixed bottom-[max(1.25rem,env(safe-area-inset-bottom))] right-[max(1.25rem,env(safe-area-inset-right))]
|
||||
z-30 inline-flex items-center gap-2 rounded-full border border-border
|
||||
bg-surface py-2 pl-2 pr-3.5 shadow-md transition-[box-shadow,border-color] duration-base
|
||||
hover:border-krow-blue/40 hover:shadow-lg
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
<OwliverAvatar className="h-7 w-7" rounded="rounded-full" />
|
||||
<span className="text-body-sm font-semibold text-ink-1">Owliver</span>
|
||||
<PanelLeftOpen className="h-3.5 w-3.5 shrink-0 text-ink-4 group-hover:text-krow-blue" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* MobileWorkspace — Owliver on a phone.
|
||||
*
|
||||
* The same `KrowAssistant`, the same context, the same runtime, the same
|
||||
* conversation. What changes is only where it is mounted: on a phone the
|
||||
* workspace is an overlay above the page rather than a column beside it.
|
||||
*
|
||||
* That is forced by arithmetic, not taste. The desktop workspace is
|
||||
* `main + 400px`; at 375px the 400px track leaves `main` negative, so the two
|
||||
* surfaces stop being a layout and start being a fight over the same pixels —
|
||||
* which is exactly what the broken state was. An overlay takes the page out of
|
||||
* that arithmetic entirely: the page stays `width: 100%` whether Owliver is
|
||||
* open or closed, and there is never a reserved column standing empty.
|
||||
*
|
||||
* Three things this is deliberately not:
|
||||
*
|
||||
* - Not a second chat. Nothing about the assistant is re-implemented; this
|
||||
* component is a positioned container and nothing else.
|
||||
* - Not a takeover. It stops below the app header, so the reader can still see
|
||||
* where they are and can still leave.
|
||||
* - Not a fixed height. It is sized to `visualViewport` where that exists and
|
||||
* to `100dvh` where it does not, so an open keyboard shortens the sheet
|
||||
* instead of pushing the composer off the bottom of it.
|
||||
*/
|
||||
function MobileWorkspace({ context, onClose }) {
|
||||
const viewport = useVisualViewport();
|
||||
|
||||
/* The page behind an overlay must not scroll: on a touch screen a drag that
|
||||
starts on the scrim and lands on the page is otherwise indistinguishable
|
||||
from scrolling the conversation, and the reader loses their place on both
|
||||
surfaces at once. Restored exactly as found — another overlay may already
|
||||
own it. */
|
||||
React.useEffect(() => {
|
||||
const { body } = document;
|
||||
const previous = body.style.overflow;
|
||||
body.style.overflow = 'hidden';
|
||||
return () => { body.style.overflow = previous; };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-x-0 top-0 z-50 h-[100dvh] md:hidden"
|
||||
style={viewport ? { height: viewport.height, top: viewport.offsetTop } : undefined}
|
||||
>
|
||||
{/* Tapping the page dismisses, which is what a sheet over a page should
|
||||
do. A button rather than a bare div so it is reachable without a
|
||||
pointer. */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close the Owliver workspace"
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 bg-ink-1/30 backdrop-blur-[2px] motion-safe:animate-fade-in"
|
||||
/>
|
||||
|
||||
{/* `top-14` is the header's own height: the sheet starts under the app
|
||||
bar rather than over it, so navigation is never covered. `min-h-0` is
|
||||
what lets the assistant's internal scroller own the overflow instead
|
||||
of the sheet growing past the viewport. */}
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 top-14 flex min-h-0 flex-col px-3
|
||||
pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Owliver workspace"
|
||||
>
|
||||
<KrowAssistant
|
||||
key={context.id}
|
||||
context={context}
|
||||
/* Expanded is a desktop-only width state; there is no wider to go
|
||||
here, so the control is not offered rather than offered and inert. */
|
||||
expanded={false}
|
||||
onClose={onClose}
|
||||
onExpand={null}
|
||||
onRestore={null}
|
||||
className="h-full min-h-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* AssistantPanel — the Owliver workspace the Admin layout renders.
|
||||
*
|
||||
* One panel, two presentations, chosen by how much room there is beside the
|
||||
* page rather than by what kind of device is asking:
|
||||
*
|
||||
* ≥ 768px a column in the layout. Owliver is part of the page on every
|
||||
* supported route, so the dashboard reflows beside it instead of
|
||||
* being covered, and collapsing restores the original layout
|
||||
* exactly. This is the protected desktop geometry and everything
|
||||
* below describes it.
|
||||
* < 768px an overlay (`MobileWorkspace`), because a 400px track does not fit
|
||||
* beside anything on a 375px phone. The page is `width: 100%` in
|
||||
* both states and never participates in a two-column width
|
||||
* calculation it cannot satisfy.
|
||||
*
|
||||
* Both presentations mount the same `KrowAssistant` with the same context and
|
||||
* read the same open/collapsed state, so there is one assistant in the product
|
||||
* and one set of actions that change it.
|
||||
*
|
||||
* Five structural details matter, and every one of them was a bug at some point:
|
||||
*
|
||||
* 1. Nothing above the sticky element clips. An ancestor with `overflow: hidden`
|
||||
* becomes the nearest scroll container and silently disables
|
||||
* `position: sticky` beneath it.
|
||||
* 2. The aside stretches to the shell's full height (`self-stretch`), so its
|
||||
* sticky context runs the whole page rather than ending partway down and
|
||||
* leaving empty space beside a long dashboard.
|
||||
* 3. `min-w-0` on the aside. A flex item's default `min-width: auto` refuses to
|
||||
* go narrower than its content, so the track ignored its own width and stalled
|
||||
* at the panel's old size on the way back from expanded.
|
||||
* 4. Only the track has a width. The panel fills what the track leaves after the
|
||||
* handle and the lead padding, so the two can never disagree — which is
|
||||
* exactly what (3) was: two widths transitioning, one winning.
|
||||
* 5. Collapsing removes the column outright and leaves a compact docked trigger.
|
||||
* An earlier version kept a full-height rail in place, which bought the
|
||||
* dashboard 44px when the user had asked for 400 — and spelled "Owliver" down
|
||||
* the side of the page to do it.
|
||||
*
|
||||
* Expanding widens the same panel in place — an analysis workspace inside the
|
||||
* application layout, never a fullscreen takeover.
|
||||
*/
|
||||
export function AssistantPanel({ stickyClassName, panelHeightClassName }) {
|
||||
const {
|
||||
context, isOpen, isExpanded, width, minWidth, maxWidth,
|
||||
setWidth, resetWidth, open, close, expand, restore,
|
||||
} = useAssistantPanel();
|
||||
const viewportWidth = useViewportWidth();
|
||||
const isPhone = useIsPhone();
|
||||
|
||||
// No assistant on this route: no column, no rail, no trace in the layout.
|
||||
if (!context) return null;
|
||||
|
||||
/* Phones: Owliver is never a column, in either state.
|
||||
Closed, the layout is one column and `main` has the whole viewport — there
|
||||
is no reserved 400px gutter to leave a blank strip down the right. Open,
|
||||
the workspace is an overlay, so the page keeps that full width underneath
|
||||
rather than being asked to share it with a track wider than the phone.
|
||||
Both states are rendered from the same panel state the desktop column uses,
|
||||
so opening, collapsing and reopening are the same three actions here. */
|
||||
if (isPhone) {
|
||||
return isOpen
|
||||
? <MobileWorkspace context={context} onClose={close} />
|
||||
: <CollapsedTrigger page={context.page} onRestore={open} />;
|
||||
}
|
||||
|
||||
/* Expanded overrides the dragged width; otherwise the user's own width wins.
|
||||
Both are clamped against the viewport so the dashboard is never squeezed. */
|
||||
const viewportCap = Math.round(viewportWidth * MAX_VIEWPORT_SHARE);
|
||||
const panelWidth = isExpanded
|
||||
? Math.max(width, Math.min(EXPANDED_WIDTH, viewportCap))
|
||||
: Math.min(width, Math.max(minWidth, viewportCap));
|
||||
|
||||
/* Collapsed: no column at all. The layout falls back to a single column, `main`
|
||||
takes the whole shell, and the only thing left is the docked trigger — so
|
||||
there is never a reserved empty gutter where the panel used to be. */
|
||||
if (!isOpen) {
|
||||
return <CollapsedTrigger page={context.page} onRestore={open} />;
|
||||
}
|
||||
|
||||
const trackWidth = panelWidth + GUTTER;
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label="Owliver workspace"
|
||||
style={{ width: trackWidth }}
|
||||
/* No `overflow-hidden` here — see (1). `self-stretch` — see (2).
|
||||
`min-w-0` — see (3).
|
||||
|
||||
A CSS transition rather than an animated width: the width is already a
|
||||
pure function of state, so there is nothing for an animation library to
|
||||
own, and the declarative version cannot get stranded mid-collapse the way
|
||||
an interrupted JS animation can. Reduced motion is handled globally in
|
||||
index.css. */
|
||||
className="relative min-w-0 shrink-0 self-stretch transition-[width] duration-slow ease-out"
|
||||
>
|
||||
<div className={cn('sticky flex', stickyClassName)}>
|
||||
{/* Drag handle. Sits in the gutter, so resizing never overlaps either
|
||||
surface. Double-click restores the default width. */}
|
||||
<ResizeDivider
|
||||
width={panelWidth}
|
||||
min={minWidth}
|
||||
max={Math.min(maxWidth, viewportCap)}
|
||||
onResize={setWidth}
|
||||
onDoubleClick={resetWidth}
|
||||
className={panelHeightClassName}
|
||||
/>
|
||||
|
||||
{/* The panel takes whatever the track leaves after the handle and the
|
||||
lead padding, which is `panelWidth` by construction — see (4). */}
|
||||
<div className="min-w-0 flex-1 pl-2">
|
||||
{/* Keyed on the context so switching pages gives a clean panel and
|
||||
aborts any response still streaming for the last one. */}
|
||||
<KrowAssistant
|
||||
key={context.id}
|
||||
context={context}
|
||||
expanded={isExpanded}
|
||||
onClose={close}
|
||||
onExpand={expand}
|
||||
onRestore={restore}
|
||||
className={panelHeightClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export default AssistantPanel;
|
||||
360
src/components/ai-assistant/AssistantPanelContext.jsx
Normal file
360
src/components/ai-assistant/AssistantPanelContext.jsx
Normal file
@@ -0,0 +1,360 @@
|
||||
import * as React from 'react';
|
||||
import { krow } from '@/api/krowClient';
|
||||
import { resolveAssistantContext } from './placement';
|
||||
import { PHONE_QUERY } from './viewport';
|
||||
import { PageContextProvider } from './PageContext';
|
||||
import { AgentProvider } from './AgentContext';
|
||||
|
||||
/**
|
||||
* Window state for the Owliver panel: open/collapsed and default/expanded.
|
||||
*
|
||||
* Lives in context rather than in the layout so any control — the panel's own
|
||||
* header, the collapsed rail — can change the workspace without the layout
|
||||
* having to know where it sits, and without threading props through every page.
|
||||
*
|
||||
* **Owliver is open by default.** On a supported Admin page the workspace *is*
|
||||
* the dashboard plus Owliver: it is an intelligence layer over the page, not a
|
||||
* feature you go and fetch. Collapsing is therefore a deliberate act by someone
|
||||
* who wants the full width for a while, which is why the collapsed state is a
|
||||
* visible rail rather than a dismissal (see AssistantPanel).
|
||||
*/
|
||||
|
||||
const AssistantPanelContext = React.createContext(null);
|
||||
|
||||
const OPEN_KEY = 'krow_assistant_open';
|
||||
const EXPANDED_KEY = 'krow_assistant_expanded';
|
||||
const WIDTH_KEY = 'krow_assistant_width';
|
||||
|
||||
/** Drag limits. Below 340px the reports stop being readable; above 520px the
|
||||
* panel starts competing with the dashboard for primacy. */
|
||||
export const MIN_WIDTH = 340;
|
||||
export const MAX_WIDTH = 520;
|
||||
export const DEFAULT_WIDTH = 380;
|
||||
|
||||
const clampWidth = (value) => Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, Math.round(value)));
|
||||
|
||||
/**
|
||||
* Window state is kept for the session so moving between supported pages does
|
||||
* not undo a panel the user deliberately collapsed or expanded, while a new
|
||||
* session still starts from the default workspace.
|
||||
*
|
||||
* `fallback` is what an untouched session gets. Absence has to mean "default"
|
||||
* rather than "off", or the open-by-default panel would only appear after
|
||||
* someone had already opened it once.
|
||||
*/
|
||||
function readFlag(key, fallback = false) {
|
||||
try {
|
||||
const stored = sessionStorage.getItem(key);
|
||||
return stored === null ? fallback : stored === '1';
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function writeFlag(key, value) {
|
||||
try {
|
||||
sessionStorage.setItem(key, value ? '1' : '0');
|
||||
} catch {
|
||||
// Private mode — state simply resets next session.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this session is starting on a phone.
|
||||
*
|
||||
* Read once, to pick the *default* — never to decide how the panel renders.
|
||||
* That decision belongs to `AssistantPanel`, which watches the same query and
|
||||
* re-renders when it changes; a resize past the breakpoint must move the panel
|
||||
* between presentations without silently reopening or closing it.
|
||||
*
|
||||
* The same media query the layout switches on, imported rather than repeated.
|
||||
*/
|
||||
function startsOnPhone() {
|
||||
try {
|
||||
return window.matchMedia(PHONE_QUERY).matches;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function AssistantPanelProvider({ role, pathname, children }) {
|
||||
/* Two different things, deliberately kept apart: the account preference is
|
||||
whether Owliver *should* open by default, and the session flag is whether
|
||||
this tab currently has it collapsed. The preference is the fallback, so a
|
||||
panel collapsed for the afternoon does not rewrite the account setting, and
|
||||
an account with Owliver off does not get the panel pushed back at it on
|
||||
every new tab. */
|
||||
/* The default differs by presentation, and only the default.
|
||||
On the desktop workspace Owliver *is* part of the page, so it opens with
|
||||
it. On a phone the workspace is an overlay (see AssistantPanel), and an
|
||||
overlay that opens on load is a sheet between the reader and the page they
|
||||
asked for — so a phone starts on the docked pill, one tap from the same
|
||||
conversation. Everything after that first render is the shared state:
|
||||
opening on a phone is the same `open()` the desktop header calls, and the
|
||||
session remembers it either way. */
|
||||
const [open, setOpen] = React.useState(
|
||||
() => readFlag(
|
||||
OPEN_KEY,
|
||||
!startsOnPhone() && krow.auth.preferences().owliverDefault !== false
|
||||
)
|
||||
);
|
||||
const [expanded, setExpanded] = React.useState(() => readFlag(EXPANDED_KEY));
|
||||
const [width, setWidth] = React.useState(() => {
|
||||
try {
|
||||
const stored = Number(sessionStorage.getItem(WIDTH_KEY));
|
||||
return stored ? clampWidth(stored) : DEFAULT_WIDTH;
|
||||
} catch {
|
||||
return DEFAULT_WIDTH;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* A question the page handed to the panel, waiting to be asked.
|
||||
*
|
||||
* The one thing a page could not do before: publish what it is looking at
|
||||
* (PageContext) but never *start* a turn about it. So a control whose work
|
||||
* lives in the conversation — Continue, on a draft card — had no way to reach
|
||||
* the conversation, and the only thing left to do with it was navigate to a
|
||||
* form. This is that missing direction, kept as narrow as the read channel:
|
||||
* serializable state, no callbacks into the panel, and the panel decides when
|
||||
* to take it.
|
||||
*
|
||||
* State rather than an event, because the panel is unmounted while collapsed —
|
||||
* `ask` opens it, and the request is still here when it mounts.
|
||||
*/
|
||||
const [request, setRequest] = React.useState(null);
|
||||
const requestSeq = React.useRef(0);
|
||||
|
||||
/**
|
||||
* Something the workspace has *told* Owliver, waiting to be said.
|
||||
*
|
||||
* `ask` above is the page speaking as the reader — it puts a question in the
|
||||
* composer's place and a turn runs. This is the other half, and it is a
|
||||
* different act: attaching a skill to an agent is not a question, and staging
|
||||
* it as one would put words in the reader's mouth and then answer them.
|
||||
*
|
||||
* So a notice appends a single assistant turn stating what changed and what
|
||||
* can now be asked. It is the same conversation, the same panel and the same
|
||||
* thread — deliberately not a second chat, a toast queue or a system-message
|
||||
* lane, because the point of Owliver knowing about a skill is that the next
|
||||
* thing the reader types can use it.
|
||||
*
|
||||
* Held as state for the same reason a request is: the panel is unmounted
|
||||
* while collapsed, and the notice must still be here when it mounts.
|
||||
*/
|
||||
const [notice, setNotice] = React.useState(null);
|
||||
const noticeSeq = React.useRef(0);
|
||||
|
||||
/**
|
||||
* A capability test in flight: `{ id, trace, state, error }`.
|
||||
*
|
||||
* `trace` is what the agent editor was testing — the skill, the agent and the
|
||||
* surface — so the panel can say so and the drawer can show the outcome. It
|
||||
* is presentation only: nothing in the runtime reads it, and it is never
|
||||
* persisted with the thread.
|
||||
*/
|
||||
const [test, setTest] = React.useState(null);
|
||||
|
||||
const context = React.useMemo(
|
||||
() => resolveAssistantContext(role, pathname),
|
||||
[role, pathname]
|
||||
);
|
||||
|
||||
const supported = Boolean(context);
|
||||
// A stored "open" must not force the panel onto a page with no assistant.
|
||||
const isOpen = supported && open;
|
||||
const isExpanded = isOpen && expanded;
|
||||
|
||||
const setOpenState = React.useCallback((next) => {
|
||||
setOpen(next);
|
||||
writeFlag(OPEN_KEY, next);
|
||||
}, []);
|
||||
|
||||
const setExpandedState = React.useCallback((next) => {
|
||||
setExpanded(next);
|
||||
writeFlag(EXPANDED_KEY, next);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Escape steps back one level rather than collapsing outright: expanded →
|
||||
* default → collapsed. Narrowing a workspace and putting it away are different
|
||||
* intentions, and one key press should not do both.
|
||||
*
|
||||
* Ignored while the caret is in the composer, where Escape belongs to whatever
|
||||
* the user is typing rather than to the layout.
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) return undefined;
|
||||
const onKey = (e) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
const tag = document.activeElement?.tagName;
|
||||
if (tag === 'TEXTAREA' || tag === 'INPUT') return;
|
||||
if (isExpanded) setExpandedState(false);
|
||||
else setOpenState(false);
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [isOpen, isExpanded, setExpandedState, setOpenState]);
|
||||
|
||||
/**
|
||||
* Asks Owliver something on the page's behalf, opening the panel to receive it.
|
||||
*
|
||||
* `positionId` names the record the control was built from, so the answer is
|
||||
* about that position rather than about whichever one the wording happens to
|
||||
* match. Each call carries its own id, so asking the same question twice is
|
||||
* two turns rather than one ignored repeat.
|
||||
*/
|
||||
const ask = React.useCallback(({
|
||||
question, positionId = null, scope = null, capability = null, trace = null,
|
||||
}) => {
|
||||
const text = String(question || '').trim();
|
||||
if (!text) return;
|
||||
requestSeq.current += 1;
|
||||
const id = requestSeq.current;
|
||||
/* `scope` runs the turn against another page's skills — the agent editor
|
||||
testing a capability. See `useConversation.send`. Ordinary callers omit
|
||||
it and the turn is about the page the reader is on. */
|
||||
setRequest({ id, question: text, positionId, scope, capability, trace });
|
||||
/**
|
||||
* A capability test is a turn somebody is *waiting on the outcome of*, and
|
||||
* the panel is the only thing that knows when a turn has finished. So a
|
||||
* traced ask opens a small status the caller can watch — queued while the
|
||||
* panel is still mounting or busy, running once the turn starts, then done
|
||||
* or error. Untraced asks set nothing and behave exactly as before.
|
||||
*/
|
||||
if (trace) setTest({ id, trace, state: 'queued', error: null });
|
||||
setOpenState(true);
|
||||
}, [setOpenState]);
|
||||
|
||||
/** The panel reporting back on a traced turn. */
|
||||
const reportTest = React.useCallback((id, state, error = null) => {
|
||||
setTest((current) => (current?.id === id ? { ...current, state, error } : current));
|
||||
}, []);
|
||||
|
||||
/** Drops the test banner — on a new ordinary question, or when it is dismissed. */
|
||||
const clearTest = React.useCallback(() => setTest(null), []);
|
||||
|
||||
/** Taken by the panel once it has actually asked it. */
|
||||
const consumeRequest = React.useCallback((id) => {
|
||||
setRequest((current) => (current?.id === id ? null : current));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* States a fact in the conversation, without asking anything.
|
||||
*
|
||||
* `followUp` are the chips offered under it — a skill's own suggestions, so
|
||||
* the next step after attaching one is a question that skill was written to
|
||||
* answer rather than a blank composer.
|
||||
*
|
||||
* Never opens the panel on its own. A reader who collapsed Owliver to get the
|
||||
* full width does not want it reopened by a checkbox, and the notice is still
|
||||
* waiting in the thread when they open it again.
|
||||
*/
|
||||
const announce = React.useCallback(({ text, followUp = null }) => {
|
||||
const body = String(text || '').trim();
|
||||
if (!body) return;
|
||||
noticeSeq.current += 1;
|
||||
setNotice({
|
||||
id: noticeSeq.current,
|
||||
text: body,
|
||||
followUp: Array.isArray(followUp) && followUp.length ? followUp : null,
|
||||
});
|
||||
}, []);
|
||||
|
||||
/** Taken by the panel once it has actually said it. */
|
||||
const consumeNotice = React.useCallback((id) => {
|
||||
setNotice((current) => (current?.id === id ? null : current));
|
||||
}, []);
|
||||
|
||||
/* Width is written on every drag frame, so persistence is debounced rather
|
||||
than hitting sessionStorage sixty times a second. */
|
||||
const setWidthState = React.useCallback((next) => {
|
||||
const clamped = clampWidth(next);
|
||||
setWidth(clamped);
|
||||
clearTimeout(setWidthState.timer);
|
||||
setWidthState.timer = setTimeout(() => {
|
||||
try {
|
||||
sessionStorage.setItem(WIDTH_KEY, String(clamped));
|
||||
} catch {
|
||||
// Non-persistent session is fine.
|
||||
}
|
||||
}, 200);
|
||||
}, []);
|
||||
|
||||
const value = React.useMemo(() => ({
|
||||
context,
|
||||
supported,
|
||||
isOpen,
|
||||
isExpanded,
|
||||
width,
|
||||
minWidth: MIN_WIDTH,
|
||||
maxWidth: MAX_WIDTH,
|
||||
setWidth: setWidthState,
|
||||
resetWidth: () => setWidthState(DEFAULT_WIDTH),
|
||||
open: () => setOpenState(true),
|
||||
close: () => setOpenState(false),
|
||||
toggle: () => setOpenState(!isOpen),
|
||||
expand: () => setExpandedState(true),
|
||||
restore: () => setExpandedState(false),
|
||||
ask,
|
||||
request,
|
||||
consumeRequest,
|
||||
announce,
|
||||
notice,
|
||||
consumeNotice,
|
||||
test,
|
||||
reportTest,
|
||||
clearTest,
|
||||
}), [context, supported, isOpen, isExpanded, width, setWidthState, setOpenState, setExpandedState,
|
||||
ask, request, consumeRequest, announce, notice, consumeNotice, test, reportTest, clearTest]);
|
||||
|
||||
/* The page's own selection travels beside the window state, mounted here so
|
||||
both the page and the panel are inside it — a skill's data source resolves
|
||||
against the record the reader has open, whichever of the two is asking. */
|
||||
/* The page's own selection travels beside the window state, and the active
|
||||
agent beside both. `AgentProvider` is given the resolved context id and can
|
||||
only read it — nothing inside it can change which page the reader is on,
|
||||
which is what makes "switching agent never moves PageContext" structural
|
||||
rather than a rule to remember. */
|
||||
return (
|
||||
<AssistantPanelContext.Provider value={value}>
|
||||
<PageContextProvider>
|
||||
<AgentProvider contextId={context?.id ?? null}>{children}</AgentProvider>
|
||||
</PageContextProvider>
|
||||
</AssistantPanelContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Panel state for triggers and the layout.
|
||||
*
|
||||
* Returns an 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,
|
||||
isExpanded: false,
|
||||
width: DEFAULT_WIDTH,
|
||||
minWidth: MIN_WIDTH,
|
||||
maxWidth: MAX_WIDTH,
|
||||
setWidth: () => {},
|
||||
resetWidth: () => {},
|
||||
open: () => {},
|
||||
close: () => {},
|
||||
toggle: () => {},
|
||||
expand: () => {},
|
||||
restore: () => {},
|
||||
ask: () => {},
|
||||
request: null,
|
||||
consumeRequest: () => {},
|
||||
announce: () => {},
|
||||
notice: null,
|
||||
consumeNotice: () => {},
|
||||
test: null,
|
||||
reportTest: () => {},
|
||||
clearTest: () => {},
|
||||
};
|
||||
}
|
||||
73
src/components/ai-assistant/AssistantTrigger.jsx
Normal file
73
src/components/ai-assistant/AssistantTrigger.jsx
Normal file
@@ -0,0 +1,73 @@
|
||||
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.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function AssistantTrigger({ className = '', size = 'default' }) {
|
||||
const { supported, isOpen, toggle, context } = useAssistantPanel();
|
||||
|
||||
if (!supported) return null;
|
||||
|
||||
const label = isOpen ? 'Close Owliver' : `Ask Owliver about ${context.page}`;
|
||||
const heights = { sm: 'h-8 px-2.5', default: 'h-9 px-3' };
|
||||
const logoHeights = { sm: 'h-3.5', default: 'h-4' };
|
||||
|
||||
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;
|
||||
1021
src/components/ai-assistant/KrowAssistant.jsx
Normal file
1021
src/components/ai-assistant/KrowAssistant.jsx
Normal file
File diff suppressed because it is too large
Load Diff
154
src/components/ai-assistant/PageContext.jsx
Normal file
154
src/components/ai-assistant/PageContext.jsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import * as React from 'react';
|
||||
|
||||
/**
|
||||
* What the page is currently looking at, published to the panel beside it.
|
||||
*
|
||||
* A skill's data source declares what it needs — a position, a candidate, the
|
||||
* position being specified — and the page is the only thing that knows which
|
||||
* one is open. Without this, Owliver could read the collections but not the
|
||||
* *selection*: it would answer about the workspace when it was asked about the
|
||||
* role in the drawer.
|
||||
*
|
||||
* The channel is deliberately narrow. A page publishes records it already has;
|
||||
* it does not publish components, callbacks or view state, and nothing here
|
||||
* reaches back into the page. That keeps the coupling one-way — a page that
|
||||
* publishes nothing simply has no selection, and every skill whose source needs
|
||||
* one says so instead of guessing.
|
||||
*
|
||||
* usePublishPageContext({ position }); // in the page
|
||||
* const { position } = usePageContext(); // in the panel
|
||||
*
|
||||
* Kept out of `AssistantPanelContext` on purpose: that context is the panel's
|
||||
* window state, changing when someone drags a divider. This changes when the
|
||||
* reader opens a record, and merging the two would re-render one for the other.
|
||||
*/
|
||||
|
||||
const PageContext = React.createContext(null);
|
||||
|
||||
export function PageContextProvider({ children }) {
|
||||
const [entity, setEntity] = React.useState(null);
|
||||
|
||||
/**
|
||||
* The writes a page is willing to accept, keyed by data source.
|
||||
*
|
||||
* The read channel above is serialized state; this cannot be — a handler is a
|
||||
* function, closing over the page's own setter. So it lives in a ref, and only
|
||||
* the *set of source ids* is state. That distinction is the whole design:
|
||||
* consumers re-render when a page starts or stops accepting a write, not every
|
||||
* time the page re-renders and hands over a new closure.
|
||||
*
|
||||
* Keyed by source id rather than by an invented action name, because the
|
||||
* source is already the validated binding between a definition and its data.
|
||||
* A skill therefore cannot write anywhere it could not read, and a page cannot
|
||||
* accept a write for a reading it does not own.
|
||||
*/
|
||||
const handlersRef = React.useRef({});
|
||||
const [writable, setWritable] = React.useState([]);
|
||||
|
||||
const publishActions = React.useCallback((handlers) => {
|
||||
handlersRef.current = handlers || {};
|
||||
const next = Object.keys(handlersRef.current).sort();
|
||||
/* Only a change in *what* is writable is worth a render. */
|
||||
setWritable((prev) => (prev.join('|') === next.join('|') ? prev : next));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Runs a page's handler for a source. Returns false when the page is not
|
||||
* accepting that write, so a caller can stay read-only rather than pretending
|
||||
* the change landed.
|
||||
*/
|
||||
const runAction = React.useCallback((source, payload) => {
|
||||
const handler = handlersRef.current[source];
|
||||
if (typeof handler !== 'function') return false;
|
||||
handler(payload);
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({ entity, setEntity, publishActions, runAction, writable }),
|
||||
[entity, publishActions, runAction, writable]
|
||||
);
|
||||
|
||||
return <PageContext.Provider value={value}>{children}</PageContext.Provider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes this page's current selection for as long as it is mounted.
|
||||
*
|
||||
* Keyed on the content rather than the object, because a page rebuilds its
|
||||
* records on every render and an identity-keyed effect would publish in a loop.
|
||||
* Unmounting clears the selection, so leaving a page cannot leave Owliver
|
||||
* answering about a record nobody is looking at.
|
||||
*/
|
||||
export function usePublishPageContext(entity) {
|
||||
const context = React.useContext(PageContext);
|
||||
const setEntity = context?.setEntity;
|
||||
|
||||
/* `undefined` values would vanish from the serialized key, so a selection
|
||||
being cleared has to read as an explicit null. */
|
||||
const key = React.useMemo(() => {
|
||||
try {
|
||||
return entity ? JSON.stringify(entity) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [entity]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!setEntity) return undefined;
|
||||
setEntity(key ? JSON.parse(key) : null);
|
||||
return () => setEntity(null);
|
||||
}, [key, setEntity]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The records the page has published. Always an object, so a consumer can read
|
||||
* `position` without checking whether anything published at all.
|
||||
*/
|
||||
export function usePageContext() {
|
||||
return React.useContext(PageContext)?.entity || {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Offers this page's writes for as long as it is mounted.
|
||||
*
|
||||
* Pass a memoized map of `{ [sourceId]: handler }`. Handlers should be stable —
|
||||
* a functional `setState` closes over nothing, which is what lets the map be
|
||||
* memoized once rather than rebuilt as the form is typed into.
|
||||
*
|
||||
* const applyWeights = useCallback(
|
||||
* (next) => setForm((f) => ({ ...f, vetting_criteria: next })), []);
|
||||
* usePublishPageActions(useMemo(
|
||||
* () => ({ 'position.vetting': applyWeights }), [applyWeights]));
|
||||
*
|
||||
* Unpublished on unmount, so leaving a page cannot leave a control on screen
|
||||
* that writes into a form nobody is looking at.
|
||||
*/
|
||||
export function usePublishPageActions(handlers) {
|
||||
const publishActions = React.useContext(PageContext)?.publishActions;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!publishActions) return undefined;
|
||||
publishActions(handlers);
|
||||
return () => publishActions(null);
|
||||
}, [publishActions, handlers]);
|
||||
}
|
||||
|
||||
/**
|
||||
* How a section writes back, for one source — or `null` when nothing on this
|
||||
* page is accepting that write.
|
||||
*
|
||||
* Returning null rather than a no-op is deliberate: a renderer checks it to
|
||||
* decide whether to draw controls at all, so a section declared `editable` on a
|
||||
* page that does not own the data stays an honest read-out.
|
||||
*/
|
||||
export function usePageAction(source) {
|
||||
const context = React.useContext(PageContext);
|
||||
const accepted = Boolean(source) && (context?.writable || []).includes(source);
|
||||
const runAction = context?.runAction;
|
||||
|
||||
return React.useMemo(
|
||||
() => (accepted && runAction ? (payload) => runAction(source, payload) : null),
|
||||
[accepted, runAction, source]
|
||||
);
|
||||
}
|
||||
83
src/components/ai-assistant/PromptChips.jsx
Normal file
83
src/components/ai-assistant/PromptChips.jsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* PromptChips — suggested prompts.
|
||||
*
|
||||
* Clicking a chip runs it immediately. A chip is already a complete question, so
|
||||
* making the user press Enter afterwards adds a step that buys nothing.
|
||||
*
|
||||
* Chips always wrap onto as many lines as they need. The earlier horizontal-scroll
|
||||
* variant is gone: a scroller can always leave a chip half-visible at the edge,
|
||||
* and a half-visible button is a bug no amount of fade masking fixes. Wrapping
|
||||
* makes clipping structurally impossible instead of merely unlikely.
|
||||
*
|
||||
* `max` caps how many are offered in a conversation, where vertical space belongs
|
||||
* to the thread rather than to suggestions.
|
||||
*
|
||||
* A chip is shrink-to-fit up to the panel's width, so a long suggestion spends the
|
||||
* whole row before it is allowed to take a second line. When it does take one, the
|
||||
* radius stays at the single-line pill's 14px rather than following the height:
|
||||
* `rounded-full` on a two-line chip resolves to half of 46px, and that corner is
|
||||
* what turns the same component into something that reads as a card.
|
||||
*
|
||||
* Arrow keys move between chips, so the whole set is one tab stop.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function PromptChips({ prompts = [], onSelect, max = 0, align = 'center', className = '' }) {
|
||||
const chipRefs = React.useRef([]);
|
||||
const visible = max ? prompts.slice(0, max) : prompts;
|
||||
|
||||
const onKeyDown = (event, index) => {
|
||||
const delta = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0;
|
||||
if (!delta) return;
|
||||
const next = chipRefs.current[index + delta];
|
||||
if (!next) return;
|
||||
event.preventDefault();
|
||||
next.focus();
|
||||
};
|
||||
|
||||
if (!visible.length) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Suggested prompts"
|
||||
className={cn(
|
||||
'flex flex-wrap gap-1.5',
|
||||
align === 'center' ? 'justify-center' : 'justify-start',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{visible.map((prompt, i) => (
|
||||
<button
|
||||
/* Position, not label. Callers de-duplicate, but a follow-up list is
|
||||
built per answer and two chips can legitimately read the same —
|
||||
the key has to stay unique either way. */
|
||||
key={`${i}:${prompt.label}`}
|
||||
ref={(el) => { chipRefs.current[i] = el; }}
|
||||
type="button"
|
||||
onClick={() => onSelect(prompt)}
|
||||
onKeyDown={(e) => onKeyDown(e, i)}
|
||||
title={prompt.prompt}
|
||||
className={cn(
|
||||
/* 14px is the radius `rounded-full` already produces on a one-line
|
||||
chip, pinned so a wrapped chip keeps the same corner. */
|
||||
'flex max-w-full items-center rounded-[14px] border border-border bg-surface px-2.5 py-1',
|
||||
'text-caption font-medium text-ink-2 shadow-xs text-left',
|
||||
'transition-all duration-base ease-out',
|
||||
'hover:-translate-y-px hover:border-krow-blue/40 hover:bg-krow-blue-tint hover:text-krow-blue hover:shadow-sm',
|
||||
'active:translate-y-0 active:shadow-xs',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/60 focus-visible:ring-offset-1'
|
||||
)}
|
||||
>
|
||||
{/* `pretty` rather than `balance`: the row should be spent before a
|
||||
second line starts, and balancing would leave the pill wide with
|
||||
two short lines inside it. Clamped so no suggestion can grow the
|
||||
chip into a paragraph — the full text stays in `title`. */}
|
||||
<span className="line-clamp-3 text-pretty">{prompt.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
140
src/components/ai-assistant/PromptInput.jsx
Normal file
140
src/components/ai-assistant/PromptInput.jsx
Normal file
@@ -0,0 +1,140 @@
|
||||
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` is a list, but only the first is shown. It used to rotate every
|
||||
* few seconds; that was defensible when the panel opened on demand and wrong once
|
||||
* Owliver became permanent furniture, because a permanently visible field that
|
||||
* rewrites itself forever is movement in the corner of the eye with nothing to
|
||||
* gain — the suggestion chips already teach the range of what can be asked, and
|
||||
* they do it without a timer or a rerender every four seconds.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function PromptInput({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onStop,
|
||||
busy = false,
|
||||
placeholders = ['Ask anything…'],
|
||||
autoFocus = false,
|
||||
align = 'left',
|
||||
className = '',
|
||||
}) {
|
||||
const textareaRef = React.useRef(null);
|
||||
const [focused, setFocused] = React.useState(false);
|
||||
|
||||
/* 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]);
|
||||
|
||||
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 isCenter = align === 'center';
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); submit(); }}
|
||||
className={cn(
|
||||
'relative flex items-center gap-2 rounded-2xl border bg-surface transition-all duration-base ease-out',
|
||||
'px-3 py-2',
|
||||
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
|
||||
)}
|
||||
>
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
disabled={busy}
|
||||
aria-label="Ask Owliver"
|
||||
aria-busy={busy || undefined}
|
||||
className={cn(
|
||||
'block w-full resize-none bg-transparent leading-relaxed text-ink-1',
|
||||
'no-scrollbar outline-none disabled:cursor-wait disabled:text-ink-3',
|
||||
'py-0.5 text-body-sm',
|
||||
isCenter ? 'text-center' : 'text-left'
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Rendered as an element rather than the native `placeholder` so it can
|
||||
be truncated and swapped for the busy state. Hidden from assistive
|
||||
tech — the textarea's aria-label is the accessible name. */}
|
||||
{!value && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 truncate text-ink-4',
|
||||
'py-0.5 text-body-sm',
|
||||
isCenter ? 'text-center' : 'text-left'
|
||||
)}
|
||||
>
|
||||
{busy ? 'Thinking…' : placeholders[0]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{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',
|
||||
'h-8 w-8'
|
||||
)}
|
||||
>
|
||||
<Square className="h-3 w-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',
|
||||
'h-8 w-8'
|
||||
)}
|
||||
>
|
||||
<ArrowUp className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
110
src/components/ai-assistant/ResizeDivider.jsx
Normal file
110
src/components/ai-assistant/ResizeDivider.jsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import * as React from 'react';
|
||||
import { GripVertical } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* ResizeDivider — the drag handle between the dashboard and the Owliver panel.
|
||||
*
|
||||
* The hit area is deliberately wider than the visible line: a 1px target is
|
||||
* unusable, so the divider occupies 12px and shows a 2px rule inside it. The
|
||||
* grip dots only appear on hover, which keeps a permanent chrome element from
|
||||
* competing with the content on either side.
|
||||
*
|
||||
* Keyboard support is not a nicety here — a pointer-only resize is unreachable
|
||||
* for anyone who cannot drag. Arrow keys nudge by 16px, Home/End jump to the
|
||||
* limits, and the whole thing is exposed as a `separator` with live values.
|
||||
*/
|
||||
export function ResizeDivider({ width, min, max, onResize, onDoubleClick, className }) {
|
||||
const [dragging, setDragging] = React.useState(false);
|
||||
|
||||
/* Pointer events on the window rather than the element: once a drag starts the
|
||||
pointer routinely leaves the 12px handle, and a listener bound to the handle
|
||||
would drop the drag the moment it did. */
|
||||
React.useEffect(() => {
|
||||
if (!dragging) return undefined;
|
||||
|
||||
const onMove = (e) => {
|
||||
// The panel is right-aligned, so distance from the right edge is the width.
|
||||
onResize(Math.round(window.innerWidth - e.clientX));
|
||||
};
|
||||
const stop = () => setDragging(false);
|
||||
|
||||
window.addEventListener('pointermove', onMove);
|
||||
window.addEventListener('pointerup', stop);
|
||||
window.addEventListener('pointercancel', stop);
|
||||
|
||||
// Suppress text selection and swap the cursor for the whole document, so the
|
||||
// cursor does not flicker back whenever the pointer crosses other elements.
|
||||
const previousCursor = document.body.style.cursor;
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', onMove);
|
||||
window.removeEventListener('pointerup', stop);
|
||||
window.removeEventListener('pointercancel', stop);
|
||||
document.body.style.cursor = previousCursor;
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
}, [dragging, onResize]);
|
||||
|
||||
const onKeyDown = (e) => {
|
||||
const step = e.shiftKey ? 48 : 16;
|
||||
const actions = {
|
||||
ArrowLeft: () => onResize(width + step), // widening the panel moves the divider left
|
||||
ArrowRight: () => onResize(width - step),
|
||||
Home: () => onResize(max),
|
||||
End: () => onResize(min),
|
||||
};
|
||||
const action = actions[e.key];
|
||||
if (!action) return;
|
||||
e.preventDefault();
|
||||
action();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize Owliver panel"
|
||||
aria-valuenow={width}
|
||||
aria-valuemin={min}
|
||||
aria-valuemax={max}
|
||||
tabIndex={0}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onKeyDown={onKeyDown}
|
||||
className={cn(
|
||||
'group relative flex w-3 shrink-0 cursor-col-resize touch-none items-center justify-center',
|
||||
'focus-visible:outline-none',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* The visible rule. Brand-tinted while dragging or focused so the
|
||||
interaction has feedback without the handle being loud at rest. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'h-full w-0.5 rounded-full transition-colors duration-fast',
|
||||
dragging
|
||||
? 'bg-krow-blue'
|
||||
: 'bg-border group-hover:bg-krow-blue/40 group-focus-visible:bg-krow-blue'
|
||||
)}
|
||||
/>
|
||||
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'absolute grid h-6 w-3.5 place-items-center rounded-full border border-border bg-surface shadow-xs',
|
||||
'transition-opacity duration-base',
|
||||
dragging ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100'
|
||||
)}
|
||||
>
|
||||
<GripVertical className="h-3 w-3 text-ink-4" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
642
src/components/ai-assistant/ResponseBlocks.jsx
Normal file
642
src/components/ai-assistant/ResponseBlocks.jsx
Normal file
@@ -0,0 +1,642 @@
|
||||
import * as React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
ArrowDownRight, ArrowRight, ArrowUpRight, Check, ChevronRight, TriangleAlert,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ProgressBar, toneForScore } from '@/components/ds/Progress';
|
||||
import { SECTION_COMPONENTS } from '@/components/skills/SkillSections';
|
||||
import { useSkillDataContext } from '@/components/skills/SkillSurface';
|
||||
import { resolveSkillData } from '@/lib/skills/dataResolver';
|
||||
import { usePageAction } from './PageContext';
|
||||
|
||||
/**
|
||||
* Renderers for response blocks.
|
||||
*
|
||||
* Every block is drawn with the same tokens as the dashboard — the ink scale,
|
||||
* the shared radius, the score-band colours — so a generated report looks like
|
||||
* it belongs to Krow rather than to a chat library.
|
||||
*
|
||||
* Blocks are memoized individually: a streaming response re-renders on every
|
||||
* snapshot, and settled blocks must not re-render with it.
|
||||
*/
|
||||
|
||||
const INLINE = /(\*\*[^*]+\*\*|_[^_]+_)/g;
|
||||
|
||||
/** Inline `**bold**` and `_italic_`. Kept deliberately small — structure is
|
||||
* carried by blocks, not by markup inside a paragraph. */
|
||||
function Inline({ value }) {
|
||||
const parts = React.useMemo(() => String(value).split(INLINE).filter(Boolean), [value]);
|
||||
|
||||
return parts.map((part, i) => {
|
||||
if (part.startsWith('**') && part.endsWith('**')) {
|
||||
return <strong key={i} className="font-semibold text-ink-1">{part.slice(2, -2)}</strong>;
|
||||
}
|
||||
if (part.startsWith('_') && part.endsWith('_')) {
|
||||
return <em key={i} className="text-ink-3">{part.slice(1, -1)}</em>;
|
||||
}
|
||||
return part;
|
||||
});
|
||||
}
|
||||
|
||||
const TONE_TEXT = {
|
||||
info: 'text-krow-blue',
|
||||
success: 'text-success',
|
||||
warning: 'text-warning',
|
||||
risk: 'text-destructive',
|
||||
neutral: 'text-ink-3',
|
||||
};
|
||||
|
||||
const TONE_SURFACE = {
|
||||
info: 'bg-krow-blue-tint border-krow-blue/15',
|
||||
success: 'bg-success-muted border-success/15',
|
||||
warning: 'bg-warning-muted border-warning/20',
|
||||
risk: 'bg-destructive-muted border-destructive/15',
|
||||
neutral: 'bg-surface-subtle border-border',
|
||||
};
|
||||
|
||||
const TONE_BADGE = {
|
||||
info: 'info', success: 'success', warning: 'warning', risk: 'destructive', neutral: 'neutral',
|
||||
};
|
||||
|
||||
/* ── Individual blocks ──────────────────────────────────────────────────── */
|
||||
|
||||
const TextBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
<p className="text-body-sm leading-relaxed text-ink-2">
|
||||
<Inline value={block.text} />
|
||||
</p>
|
||||
));
|
||||
TextBlock.displayName = 'TextBlock';
|
||||
|
||||
const HeadingBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
<div className="space-y-0.5">
|
||||
<h4 className="font-heading text-body-sm font-semibold text-ink-1">{block.text}</h4>
|
||||
{block.sub && <p className="text-caption text-ink-3">{block.sub}</p>}
|
||||
</div>
|
||||
));
|
||||
HeadingBlock.displayName = 'HeadingBlock';
|
||||
|
||||
/** Headline figures. Two columns keeps the numbers large in a 380px panel. */
|
||||
const KpisBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
<div className="grid grid-cols-2 gap-2 [[data-wide]_&]:grid-cols-3">
|
||||
{block.items.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className="rounded-xl border border-border bg-surface px-3 py-2.5 shadow-xs"
|
||||
>
|
||||
<p className="truncate text-[10px] uppercase tracking-wide text-ink-4">{item.label}</p>
|
||||
<div className="mt-0.5 flex items-baseline gap-1.5">
|
||||
<span className={cn(
|
||||
'font-heading text-title font-bold tabular-nums',
|
||||
item.tone ? TONE_TEXT[item.tone] : 'text-ink-1'
|
||||
)}>
|
||||
{item.value}
|
||||
</span>
|
||||
{typeof item.delta === 'number' && item.delta !== 0 && (
|
||||
<span className={cn(
|
||||
'inline-flex items-center text-[10px] font-semibold',
|
||||
item.delta > 0 ? 'text-success' : 'text-destructive'
|
||||
)}>
|
||||
{item.delta > 0
|
||||
? <ArrowUpRight className="h-2.5 w-2.5" aria-hidden="true" />
|
||||
: <ArrowDownRight className="h-2.5 w-2.5" aria-hidden="true" />}
|
||||
{Math.abs(item.delta)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.sub && <p className="mt-0.5 truncate text-[10px] text-ink-4">{item.sub}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
));
|
||||
KpisBlock.displayName = 'KpisBlock';
|
||||
|
||||
/** Pass/warn checks. The icon carries the state so colour is not the only cue. */
|
||||
const StatusBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
<ul className="space-y-1.5">
|
||||
{block.items.map((item) => (
|
||||
<li key={item.label} className="flex items-start gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'mt-0.5 grid h-4 w-4 shrink-0 place-items-center rounded-full',
|
||||
item.ok ? 'bg-success-muted text-success' : 'bg-warning-muted text-warning'
|
||||
)}
|
||||
>
|
||||
{item.ok
|
||||
? <Check className="h-2.5 w-2.5" aria-hidden="true" />
|
||||
: <TriangleAlert className="h-2.5 w-2.5" aria-hidden="true" />}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-body-sm font-medium text-ink-1">{item.label}</span>
|
||||
{item.value && (
|
||||
<span className="shrink-0 text-caption font-semibold tabular-nums text-ink-2">
|
||||
{item.value}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.note && <p className="mt-0.5 text-caption leading-relaxed text-ink-3">{item.note}</p>}
|
||||
</div>
|
||||
<span className="sr-only">{item.ok ? 'Passing' : 'Needs attention'}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
));
|
||||
StatusBlock.displayName = 'StatusBlock';
|
||||
|
||||
const MetersBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
<div className="space-y-2.5">
|
||||
{block.items.map((item) => (
|
||||
<div key={item.label} className="space-y-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="truncate text-caption text-ink-2">{item.label}</span>
|
||||
<span className="flex shrink-0 items-baseline gap-1.5">
|
||||
{item.weight != null && <span className="text-[10px] text-ink-4">{item.weight}%</span>}
|
||||
<span className="text-caption font-semibold tabular-nums text-ink-1">{item.value}</span>
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar value={item.value} max={item.max ?? 100} tone={item.tone ?? 'score'} size="sm" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
));
|
||||
MetersBlock.displayName = 'MetersBlock';
|
||||
|
||||
/**
|
||||
* A table. Scrolls horizontally inside its own container so a wide comparison
|
||||
* never widens the panel.
|
||||
*/
|
||||
const TableBlock = React.memo(/** @param {any} props */ ({ block }) => {
|
||||
const cell = (value) => {
|
||||
if (value == null || value === '') return <span className="text-ink-4">—</span>;
|
||||
if (typeof value === 'object') {
|
||||
if (value.badge) {
|
||||
return <Badge variant={TONE_BADGE[value.tone] || 'neutral'} size="sm">{value.badge}</Badge>;
|
||||
}
|
||||
return (
|
||||
<span className={cn('tabular-nums', value.tone ? TONE_TEXT[value.tone] : undefined)}>
|
||||
{value.value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span className="tabular-nums">{value}</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border border-border bg-surface">
|
||||
<table className="w-full border-collapse text-caption">
|
||||
{block.caption && <caption className="sr-only">{block.caption}</caption>}
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-subtle">
|
||||
{block.columns.map((column) => (
|
||||
<th
|
||||
key={column.key}
|
||||
scope="col"
|
||||
className={cn(
|
||||
'whitespace-nowrap px-2.5 py-2 text-[10px] font-semibold uppercase tracking-wide text-ink-4',
|
||||
column.align === 'right' ? 'text-right' : 'text-left'
|
||||
)}
|
||||
>
|
||||
{column.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{block.rows.map((row, i) => (
|
||||
<tr key={i} className="border-b border-border last:border-0">
|
||||
{block.columns.map((column, ci) => (
|
||||
<td
|
||||
key={column.key}
|
||||
className={cn(
|
||||
'px-2.5 py-2 align-middle',
|
||||
column.align === 'right' ? 'text-right' : 'text-left',
|
||||
ci === 0 ? 'font-medium text-ink-1' : 'text-ink-2'
|
||||
)}
|
||||
>
|
||||
{cell(row[column.key])}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
TableBlock.displayName = 'TableBlock';
|
||||
|
||||
/** Stage progression, widths relative to the largest stage. */
|
||||
const FunnelBlock = React.memo(/** @param {any} props */ ({ block }) => {
|
||||
const max = Math.max(...block.steps.map((s) => s.count), 1);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{block.steps.map((step) => (
|
||||
<div key={step.label}>
|
||||
<div className="mb-1 flex items-baseline justify-between gap-2">
|
||||
<span className="text-caption text-ink-2">{step.label}</span>
|
||||
<span className="flex shrink-0 items-baseline gap-1.5">
|
||||
{step.rate != null && <span className="text-[10px] text-ink-4">{step.rate}%</span>}
|
||||
<span className="text-caption font-semibold tabular-nums text-ink-1">{step.count}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-surface-sunken">
|
||||
<div
|
||||
className="h-full rounded-full bg-krow-blue transition-all duration-slow ease-out"
|
||||
style={{ width: `${Math.max((step.count / max) * 100, step.count > 0 ? 4 : 0)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
FunnelBlock.displayName = 'FunnelBlock';
|
||||
|
||||
const ListBlock = React.memo(/** @param {any} props */ ({ block }) => {
|
||||
const Tag = block.ordered ? 'ol' : 'ul';
|
||||
return (
|
||||
<Tag className="space-y-1.5">
|
||||
{block.items.map((item, i) => (
|
||||
<li key={i} className="flex gap-2 text-body-sm leading-relaxed text-ink-2">
|
||||
{block.ordered ? (
|
||||
<span className="mt-0.5 grid h-4 w-4 shrink-0 place-items-center rounded-full bg-krow-blue-tint text-[10px] font-bold text-krow-blue">
|
||||
{i + 1}
|
||||
</span>
|
||||
) : (
|
||||
<span className="mt-1.5 h-1 w-1 shrink-0 rounded-full bg-krow-blue" aria-hidden="true" />
|
||||
)}
|
||||
<span className="min-w-0"><Inline value={item} /></span>
|
||||
</li>
|
||||
))}
|
||||
</Tag>
|
||||
);
|
||||
});
|
||||
ListBlock.displayName = 'ListBlock';
|
||||
|
||||
/**
|
||||
* Findings, and — where the item carries one — a way to act on it.
|
||||
*
|
||||
* Two kinds of action, and the distinction is the point:
|
||||
*
|
||||
* `prompt` asks Owliver the next question, in place. The panel stays open,
|
||||
* the thread keeps its history, and the reader never loses the
|
||||
* position they were working on. This is the default for a
|
||||
* recommended person: inspecting a candidate is part of the
|
||||
* conversation, not a trip to another page.
|
||||
* `to` leaves for a route the app already has, with the whole card as the
|
||||
* target. Reserved for items whose only meaning is "open this".
|
||||
* `action` a named link inside the card — `{ label, to }`. For an item that
|
||||
* has *both* a conversation and a record: the card keeps asking
|
||||
* Owliver, and the link is the one explicit way to leave. Reading a
|
||||
* card can then never navigate by accident, which matters most where
|
||||
* the body is an explanation worth reading.
|
||||
*
|
||||
* `hint` is for an item that can do neither — it says why, instead of looking
|
||||
* clickable and doing nothing. An item may carry a hint *and* an action: the
|
||||
* hint explains the record, the action opens it.
|
||||
*/
|
||||
const InsightsBlock = React.memo(/** @param {any} props */ ({ block, onPrompt }) => (
|
||||
<div className="space-y-2">
|
||||
{block.items.map((item, i) => {
|
||||
const surface = cn(
|
||||
'block w-full text-left rounded-xl border px-3 py-2.5',
|
||||
TONE_SURFACE[item.tone] || TONE_SURFACE.neutral
|
||||
);
|
||||
const interactive = cn(
|
||||
surface,
|
||||
'transition-colors hover:border-krow-blue/50',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50'
|
||||
);
|
||||
const asksOwliver = Boolean(item.prompt && onPrompt);
|
||||
const actionable = asksOwliver || Boolean(item.to);
|
||||
|
||||
const body = (
|
||||
<>
|
||||
<p className="flex items-center gap-1 text-body-sm font-semibold text-ink-1">
|
||||
{item.title}
|
||||
{actionable && <ChevronRight className="h-3.5 w-3.5 shrink-0 opacity-60" aria-hidden="true" />}
|
||||
</p>
|
||||
{item.body && (
|
||||
<p className="mt-0.5 text-caption leading-relaxed text-ink-2"><Inline value={item.body} /></p>
|
||||
)}
|
||||
{item.hint && (
|
||||
<p className="mt-1 text-[11px] italic leading-relaxed text-ink-4">{item.hint}</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
/* A named link cannot live inside a button, so when an item has both, the
|
||||
card is a plain container: the readable area is its own button and the
|
||||
action sits beside it. One click target each, never nested. */
|
||||
if (item.action?.to) {
|
||||
return (
|
||||
<div key={i} className={surface}>
|
||||
{asksOwliver ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPrompt(item.prompt)}
|
||||
className="block w-full rounded-lg text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
{body}
|
||||
</button>
|
||||
) : body}
|
||||
|
||||
<div className="mt-2 flex justify-end">
|
||||
<Link
|
||||
to={item.action.to}
|
||||
className="inline-flex items-center gap-0.5 rounded text-caption font-semibold text-krow-blue transition-colors hover:underline
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
{item.action.label}
|
||||
<ArrowRight className="h-3 w-3" aria-hidden="true" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (asksOwliver) {
|
||||
return (
|
||||
<button key={i} type="button" onClick={() => onPrompt(item.prompt)} className={interactive}>
|
||||
{body}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (item.to) {
|
||||
return <Link key={i} to={item.to} className={interactive}>{body}</Link>;
|
||||
}
|
||||
return <div key={i} className={surface}>{body}</div>;
|
||||
})}
|
||||
</div>
|
||||
));
|
||||
InsightsBlock.displayName = 'InsightsBlock';
|
||||
|
||||
/** Recommended steps — numbered, because order is the recommendation. */
|
||||
const ActionsBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
<ol className="space-y-2">
|
||||
{block.items.map((item, i) => (
|
||||
<li key={i} className="flex gap-2.5 rounded-xl border border-border bg-surface px-3 py-2.5 shadow-xs">
|
||||
<span className="mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-full bg-krow-blue text-[10px] font-bold text-white">
|
||||
{i + 1}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-body-sm font-semibold text-ink-1">{item.title}</p>
|
||||
{item.body && (
|
||||
<p className="mt-0.5 text-caption leading-relaxed text-ink-2"><Inline value={item.body} /></p>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
));
|
||||
ActionsBlock.displayName = 'ActionsBlock';
|
||||
|
||||
const BadgesBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{block.items.map((item) => (
|
||||
<Badge key={item.label} variant={TONE_BADGE[item.tone] || 'soft'} size="lg">
|
||||
{item.label}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
));
|
||||
BadgesBlock.displayName = 'BadgesBlock';
|
||||
|
||||
const TimelineBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
<ol className="relative space-y-3">
|
||||
{block.items.map((item, i) => (
|
||||
<li key={i} className="flex gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<span
|
||||
className={cn(
|
||||
'mt-1 h-2 w-2 shrink-0 rounded-full',
|
||||
item.tone === 'success' ? 'bg-success'
|
||||
: item.tone === 'warning' ? 'bg-warning'
|
||||
: item.current ? 'bg-krow-blue' : 'bg-ink-4'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{i < block.items.length - 1 && <span className="mt-1 w-px flex-1 bg-border" aria-hidden="true" />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pb-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-body-sm font-medium text-ink-1">{item.title}</span>
|
||||
{item.timestamp && (
|
||||
<span className="shrink-0 text-[10px] text-ink-4">{item.timestamp}</span>
|
||||
)}
|
||||
</div>
|
||||
{item.description && (
|
||||
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">{item.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
));
|
||||
TimelineBlock.displayName = 'TimelineBlock';
|
||||
|
||||
const NoteBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
<p className="border-l-2 border-border pl-2.5 text-caption italic leading-relaxed text-ink-3">
|
||||
<Inline value={block.text} />
|
||||
</p>
|
||||
));
|
||||
NoteBlock.displayName = 'NoteBlock';
|
||||
|
||||
/**
|
||||
* A section a skill declared, drawn by the component the page uses for it.
|
||||
*
|
||||
* There is no chat-specific renderer for a skill's shapes, and there must not
|
||||
* be: `SECTION_COMPONENTS` is the single table from a declared type to a
|
||||
* component, and this block goes through it exactly as `SkillSurface` does. A
|
||||
* definition that gains a new shape gains it in both places at once, and a
|
||||
* shape the table does not carry renders nothing rather than something
|
||||
* improvised.
|
||||
*/
|
||||
const SkillSectionBlock = React.memo(/** @param {any} props */ ({ block }) => {
|
||||
const section = block.section;
|
||||
const Component = SECTION_COMPONENTS[section?.shape || section?.type];
|
||||
|
||||
/**
|
||||
* A settable answer is re-read, never replayed.
|
||||
*
|
||||
* An ordinary answer is a record of what was true when it was given, and the
|
||||
* stored blocks are exactly right for that. A section offering *controls* is
|
||||
* different: it is a live view of a value the reader can still change, from
|
||||
* here or on the page behind the panel. Replaying the stored copy would show
|
||||
* a weight of 25% next to a form that now says 30%, and the control would
|
||||
* write the stale figure back.
|
||||
*
|
||||
* So an editable section resolves from the current context on every render,
|
||||
* through the same resolver the page's own card uses. There is one value and
|
||||
* one place it lives; this is a second window onto it, not a second copy.
|
||||
*/
|
||||
const live = Boolean(section?.editable);
|
||||
const context = useSkillDataContext(null);
|
||||
const data = React.useMemo(
|
||||
() => (live && section ? resolveSkillData(section, context) : block.data),
|
||||
[live, section, context, block.data]
|
||||
);
|
||||
const apply = usePageAction(live ? section?.source : null);
|
||||
|
||||
if (!Component) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-surface-subtle/60 p-3">
|
||||
<Component data={data} section={section} onApply={apply} compact />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
SkillSectionBlock.displayName = 'SkillSectionBlock';
|
||||
|
||||
/**
|
||||
* A write the agent has proposed. The only block a person can act on.
|
||||
*
|
||||
* Three things it must do, and they are all about not being clicked past:
|
||||
*
|
||||
* - **Say what will happen, in the server's words.** The title, summary and
|
||||
* details are rendered as they arrived. A browser paraphrasing them would
|
||||
* be describing a different act than the one the token authorises.
|
||||
* - **Put warnings above the button.** A clash or an over-headcount is
|
||||
* exactly what somebody is about to approve without noticing, and a warning
|
||||
* underneath the decision is a warning read afterwards.
|
||||
* - **Not pretend to be finished.** Once approved, the block stays visible
|
||||
* and says so. Replacing it with a tick would lose what was agreed to.
|
||||
*/
|
||||
const ConfirmationBlock = React.memo(/** @param {any} props */ ({ block, onConfirm }) => {
|
||||
const [state, setState] = React.useState('pending');
|
||||
|
||||
const approve = React.useCallback(() => {
|
||||
/* Guarded rather than debounced. The token is single-use server-side, so a
|
||||
double click costs a confusing refusal rather than a duplicate write —
|
||||
but a button that visibly does nothing the second time is kinder than
|
||||
one that reports an error somebody caused by being quick. */
|
||||
if (state !== 'pending') return;
|
||||
setState('approved');
|
||||
onConfirm?.(block);
|
||||
}, [state, block, onConfirm]);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-krow-blue/30 bg-krow-blue/5 p-3.5">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<span className="mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-full bg-krow-blue text-white">
|
||||
<TriangleAlert className="h-3 w-3" aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-body-sm font-semibold text-ink-1">{block.title}</p>
|
||||
{block.summary && (
|
||||
<p className="mt-1 text-caption leading-relaxed text-ink-2">{block.summary}</p>
|
||||
)}
|
||||
|
||||
{block.details?.length > 0 && (
|
||||
<dl className="mt-2.5 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
|
||||
{block.details.map((d, i) => (
|
||||
<React.Fragment key={i}>
|
||||
<dt className="text-caption text-ink-3">{d.label}</dt>
|
||||
<dd className="text-caption font-medium text-ink-1">{d.value}</dd>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
|
||||
{/* Above the button, deliberately. */}
|
||||
{block.warnings?.length > 0 && (
|
||||
<ul className="mt-2.5 space-y-1">
|
||||
{block.warnings.map((w, i) => (
|
||||
<li key={i} className="flex gap-1.5 text-caption text-amber-700 dark:text-amber-400">
|
||||
<TriangleAlert className="mt-0.5 h-3 w-3 shrink-0" aria-hidden="true" />
|
||||
<span>{w}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
{state === 'pending' ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={approve}
|
||||
className="rounded-lg bg-krow-blue px-3 py-1.5 text-caption font-semibold text-white
|
||||
transition hover:opacity-90 focus-visible:outline focus-visible:outline-2
|
||||
focus-visible:outline-offset-2 focus-visible:outline-krow-blue"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setState('dismissed')}
|
||||
className="rounded-lg px-3 py-1.5 text-caption font-medium text-ink-3
|
||||
transition hover:text-ink-1 focus-visible:outline focus-visible:outline-2
|
||||
focus-visible:outline-offset-2 focus-visible:outline-krow-blue"
|
||||
>
|
||||
Not now
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<p className="flex items-center gap-1.5 text-caption font-medium text-ink-3">
|
||||
<Check className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{state === 'approved' ? 'Approved — carrying on.' : 'Left for now. Nothing was changed.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
ConfirmationBlock.displayName = 'ConfirmationBlock';
|
||||
|
||||
const RENDERERS = {
|
||||
text: TextBlock,
|
||||
confirmation: ConfirmationBlock,
|
||||
skillSection: SkillSectionBlock,
|
||||
heading: HeadingBlock,
|
||||
kpis: KpisBlock,
|
||||
status: StatusBlock,
|
||||
meters: MetersBlock,
|
||||
table: TableBlock,
|
||||
funnel: FunnelBlock,
|
||||
list: ListBlock,
|
||||
insights: InsightsBlock,
|
||||
actions: ActionsBlock,
|
||||
badges: BadgesBlock,
|
||||
timeline: TimelineBlock,
|
||||
note: NoteBlock,
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a response document.
|
||||
*
|
||||
* Spacing is decided here rather than by the blocks, so any combination of
|
||||
* blocks has consistent rhythm — a heading hugs what follows it, everything
|
||||
* else breathes.
|
||||
*/
|
||||
export const ResponseDocument = React.memo(/** @param {any} props */ ({ blocks = [], streaming = false, onPrompt, onConfirm }) => (
|
||||
<div className="space-y-3">
|
||||
{blocks.map((block, i) => {
|
||||
const Renderer = RENDERERS[block.type];
|
||||
if (!Renderer) return null;
|
||||
const hugsNext = block.type === 'heading' && i < blocks.length - 1;
|
||||
const isLast = i === blocks.length - 1;
|
||||
|
||||
return (
|
||||
<div key={i} className={cn(hugsNext && '-mb-1.5', 'animate-fade-in')}>
|
||||
<Renderer block={block} onPrompt={onPrompt} onConfirm={onConfirm} />
|
||||
{/* The cursor trails the final block only while text is still arriving. */}
|
||||
{streaming && isLast && (block.type === 'text' || block.type === 'heading') && (
|
||||
<span
|
||||
className="ml-0.5 inline-block h-3.5 w-1.5 -mb-0.5 animate-caret bg-krow-blue align-middle"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
));
|
||||
ResponseDocument.displayName = 'ResponseDocument';
|
||||
|
||||
export { toneForScore };
|
||||
142
src/components/ai-assistant/blocks.js
Normal file
142
src/components/ai-assistant/blocks.js
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Response blocks.
|
||||
*
|
||||
* Answers are documents, not strings. A capability decides how its information
|
||||
* is best communicated — a comparison is a table, a health check is a status
|
||||
* list, a funnel is a funnel — and the renderer draws it with design-system
|
||||
* components. That is what turns a reply into a small interactive report rather
|
||||
* than a wall of chat text.
|
||||
*
|
||||
* Blocks are plain data, so they serialize: a future remote service can return
|
||||
* exactly this shape and nothing in the UI changes.
|
||||
*/
|
||||
|
||||
/** Assembles a document, flattening nested arrays and dropping empty slots. */
|
||||
export const doc = (...blocks) => ({
|
||||
blocks: blocks.flat(Infinity).filter(Boolean),
|
||||
});
|
||||
|
||||
/** A short paragraph. Supports `**bold**` and `_italic_` inline. */
|
||||
export const text = (value) => value && { type: 'text', text: value };
|
||||
|
||||
/** A section label inside a response. */
|
||||
export const heading = (value, sub) => value && { type: 'heading', text: value, sub };
|
||||
|
||||
/** Headline figures. `items: [{ label, value, delta?, sub?, tone? }]` */
|
||||
export const kpis = (items) => items?.length && { type: 'kpis', items };
|
||||
|
||||
/** Pass/warn checks. `items: [{ label, ok, value?, note? }]` */
|
||||
export const status = (items) => items?.length && { type: 'status', items };
|
||||
|
||||
/** Labelled bars. `items: [{ label, value, weight?, max?, tone? }]` */
|
||||
export const meters = (items) => items?.length && { type: 'meters', items };
|
||||
|
||||
/**
|
||||
* A comparison or record table.
|
||||
* `columns: [{ key, label, align?, width? }]`, `rows: [{ ...cells }]`
|
||||
* A cell may be a string, a number, or `{ value, tone, badge }`.
|
||||
*/
|
||||
export const table = (columns, rows, options = {}) =>
|
||||
rows?.length && { type: 'table', columns, rows, ...options };
|
||||
|
||||
/** Stage progression. `steps: [{ label, count, rate?, lost? }]` */
|
||||
export const funnel = (steps) => steps?.length && { type: 'funnel', steps };
|
||||
|
||||
/** Bulleted or numbered points. */
|
||||
export const list = (items, options = {}) =>
|
||||
items?.filter(Boolean).length && { type: 'list', items: items.filter(Boolean), ...options };
|
||||
|
||||
/** Findings. `items: [{ tone, title, body }]` — tone: info|success|warning|risk */
|
||||
/**
|
||||
* Findings. `items: [{ title, body, tone?, to?, hint? }]`
|
||||
*
|
||||
* `to` makes the card a link into the app — a recommended person opening their
|
||||
* own record, rather than a name the reader has to go and look up. It is a route
|
||||
* the app already has; nothing here invents an address, and an item without one
|
||||
* renders exactly as it always did.
|
||||
*/
|
||||
export const insights = (items) =>
|
||||
items?.filter(Boolean).length && { type: 'insights', items: items.filter(Boolean) };
|
||||
|
||||
/** Recommended next steps. `items: [{ title, body }]` */
|
||||
export const actions = (items) =>
|
||||
items?.filter(Boolean).length && { type: 'actions', items: items.filter(Boolean) };
|
||||
|
||||
/** Inline tags. `items: [{ label, tone }]` or plain strings. */
|
||||
export const badges = (items) =>
|
||||
items?.filter(Boolean).length && {
|
||||
type: 'badges',
|
||||
items: items.filter(Boolean).map((i) => (typeof i === 'string' ? { label: i } : i)),
|
||||
};
|
||||
|
||||
/** Chronology. `items: [{ title, description?, timestamp?, tone?, current? }]` */
|
||||
export const timeline = (items) => items?.length && { type: 'timeline', items };
|
||||
|
||||
/**
|
||||
* A section a skill definition declared, drawn in the panel.
|
||||
*
|
||||
* The one block whose shape is not decided here: `section` is the normalized
|
||||
* record the registry produced from a `.md` file, and `data` is what the shared
|
||||
* resolver read for it. The renderer hands both to the same component the page
|
||||
* mounts, so a flow asked for in conversation *is* the page's flow rather than
|
||||
* a chat-shaped imitation of it.
|
||||
*
|
||||
* Both halves are plain data, so a reply containing one still serializes into
|
||||
* the stored thread like every other block.
|
||||
*/
|
||||
export const skillSection = (section, data) =>
|
||||
section && data && { type: 'skillSection', section, data };
|
||||
|
||||
/** A caveat. Always the last word on a claim, never the headline. */
|
||||
export const note = (value) => value && { type: 'note', text: value };
|
||||
|
||||
/**
|
||||
* A write the agent has proposed and NOT performed.
|
||||
*
|
||||
* The only block in this file that carries a decision rather than information,
|
||||
* and the only one whose renderer has a button. Everything else here describes
|
||||
* something that already happened; this describes something that will happen if
|
||||
* a person says so.
|
||||
*
|
||||
* The payload comes from the server verbatim — title, summary, details,
|
||||
* warnings, token — and is rendered rather than reformatted. The wording was
|
||||
* composed beside the code that will do the writing, so a browser paraphrasing
|
||||
* it would be describing a different act than the one the token authorises.
|
||||
*
|
||||
* `token` is what the approval sends back. It authorises exactly one call, with
|
||||
* exactly those arguments, and it expires.
|
||||
*/
|
||||
export const confirmation = (payload) =>
|
||||
payload?.token && { type: 'confirmation', ...payload };
|
||||
|
||||
/** A single sentence answer — used by short free-text replies. */
|
||||
export const answer = (value) => doc(text(value));
|
||||
|
||||
/* ── Streaming ──────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Splits a document into the progressive snapshots the provider streams.
|
||||
*
|
||||
* Text and heading blocks reveal word by word; structured blocks appear whole,
|
||||
* because a half-drawn table reads as broken rather than as arriving. The result
|
||||
* is a response that composes itself in a readable order.
|
||||
*/
|
||||
export function toSnapshots({ blocks }) {
|
||||
const snapshots = [];
|
||||
const settled = [];
|
||||
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'text' || block.type === 'heading') {
|
||||
const tokens = String(block.text).split(/(\s+)/);
|
||||
// Three words per snapshot: fast enough to feel live, coarse enough to
|
||||
// avoid a reflow per character.
|
||||
for (let i = 6; i < tokens.length; i += 6) {
|
||||
snapshots.push([...settled, { ...block, text: tokens.slice(0, i).join('') }]);
|
||||
}
|
||||
}
|
||||
settled.push(block);
|
||||
snapshots.push([...settled]);
|
||||
}
|
||||
|
||||
return snapshots.length ? snapshots : [[]];
|
||||
}
|
||||
246
src/components/ai-assistant/contexts.js
Normal file
246
src/components/ai-assistant/contexts.js
Normal file
@@ -0,0 +1,246 @@
|
||||
|
||||
/**
|
||||
* The words each page's answers are actually about.
|
||||
*
|
||||
* Inlined here when the capability modules were deleted. They used to sit
|
||||
* beside the functions that computed answers from browser data; those functions
|
||||
* are gone — the agent answers now — but this vocabulary survives them, because
|
||||
* it decides something different: whether a question belongs to THIS page or to
|
||||
* another one. A question matching none of a page's topics is a question the
|
||||
* reader should be taken elsewhere to ask, and that is still true with an agent
|
||||
* behind the panel.
|
||||
*/
|
||||
|
||||
const AGENT_CONFIGURE_TOPICS = [
|
||||
'agent', 'agents', 'subagent', 'subagents',
|
||||
'skill', 'skills', 'knowledge',
|
||||
'reasoning', 'starter', 'starters', 'web search',
|
||||
'instruction', 'instructions', 'configure', 'configuration', 'setting', 'settings',
|
||||
'publish', 'published', 'draft', 'archive', 'archived', 'revert', 'shipped',
|
||||
'workspace',
|
||||
];
|
||||
|
||||
|
||||
|
||||
const SETTINGS_TOPICS = [
|
||||
'setting', 'settings', 'account', 'password', 'credential', 'security', 'two-factor',
|
||||
'two factor', '2fa', 'permission', 'access', 'organization', 'organisation',
|
||||
'client', 'user', 'users', 'team', 'audit', 'notification', 'digest', 'email',
|
||||
'density', 'compact', 'preference', 'automation', 'toggle', 'configure', 'configuration',
|
||||
'workspace', 'owliver', 'assistant',
|
||||
];
|
||||
|
||||
const WORKSPACE_TOPICS = [
|
||||
'workspace', 'agent', 'agents', 'skill', 'skills', 'capability', 'capabilities',
|
||||
'training', 'path', 'paths', 'library', 'registry', 'owliver', 'extend', 'configure',
|
||||
'configuration', 'govern', 'development',
|
||||
];
|
||||
|
||||
const WORKSPACE_AGENTS_TOPICS = [
|
||||
'agent', 'agents', 'subagent', 'subagents', 'skill', 'skills', 'page', 'pages',
|
||||
'publish', 'published', 'draft', 'archive', 'archived', 'revert', 'shipped',
|
||||
'configure', 'configuration', 'reasoning', 'workspace', 'owliver',
|
||||
'constrained', 'cover', 'covers', 'fallback', 'default',
|
||||
];
|
||||
|
||||
const WORKSPACE_SKILLS_TOPICS = [
|
||||
'skill', 'skills', 'library', 'registry', 'owliver', 'board', 'section', 'surface',
|
||||
'placement', 'markdown', 'definition', 'upload', 'author', 'attach',
|
||||
'active', 'draft', 'archive', 'archived', 'category', 'capability', 'capabilities',
|
||||
'workspace', 'agent', 'agents', 'page', 'pages',
|
||||
];
|
||||
|
||||
const SKILL_CONFIGURE_TOPICS = [
|
||||
'skill', 'skills', 'definition', 'markdown', 'frontmatter', 'field', 'fields',
|
||||
'page', 'pages', 'surface', 'placement', 'section', 'source', 'sources', 'trigger',
|
||||
'triggers', 'action', 'actions', 'capability', 'capabilities', 'category', 'status',
|
||||
'active', 'draft', 'archive', 'archived', 'validate', 'validation', 'save', 'publish',
|
||||
'editor', 'configure', 'configuration', 'owliver', 'board', 'workspace',
|
||||
];
|
||||
|
||||
const SKILL_DEVELOPMENT_TOPICS = [
|
||||
'training', 'path', 'paths', 'progression', 'level', 'levels', 'rung', 'ladder',
|
||||
'development', 'capability', 'capabilities', 'define', 'definition',
|
||||
'workspace', 'configure', 'configuration', 'owliver',
|
||||
];
|
||||
|
||||
/**
|
||||
* Page contexts for the Owliver dashboard panel.
|
||||
*
|
||||
* Deliberately thin. What the user sees on the landing screen — the title,
|
||||
* greeting, description, placeholders and suggested prompts — is computed from
|
||||
* live data in `dynamic.js`, not written here. A context only declares what this
|
||||
* page's assistant can *do*: its capabilities and its free-text responder.
|
||||
*
|
||||
* Krow AI serves Employer and Admin. The talent portal has Owliver, a separate
|
||||
* product with its own UI and workflow — nothing here targets it.
|
||||
*
|
||||
* `topics` is what this page's answers are actually about. A question matching
|
||||
* none of them is not quietly answered with the page's default report — see
|
||||
* routing.js. Contexts without `topics` keep answering everything, so declaring
|
||||
* them is opt-in per page.
|
||||
*/
|
||||
export const ASSISTANT_CONTEXTS = {
|
||||
'employer.overview': {
|
||||
id: 'employer.overview',
|
||||
page: 'Overview',
|
||||
},
|
||||
'employer.candidates': {
|
||||
id: 'employer.candidates',
|
||||
page: 'Candidates',
|
||||
},
|
||||
'employer.analytics': {
|
||||
id: 'employer.analytics',
|
||||
page: 'Analytics',
|
||||
},
|
||||
'admin.controlCenter': {
|
||||
id: 'admin.controlCenter',
|
||||
page: 'Control Center',
|
||||
topics: ['health', 'platform', 'attention', 'urgent', 'bottleneck', 'funnel', 'pipeline', 'conversion', 'recommend', 'should', 'summary', 'summarize', 'workforce', 'operation', 'velocity', 'speed', 'hiring', 'how many', 'unusual', 'anomal', 'risk', 'overview', 'status'],
|
||||
},
|
||||
'admin.positions': {
|
||||
id: 'admin.positions',
|
||||
page: 'Positions',
|
||||
topics: ['position', 'role', 'posting', 'vacancy', 'fill', 'attention', 'priorit', 'bottleneck', 'funnel', 'waiting', 'review', 'screen', 'applicant', 'pipeline', 'strength', 'activity', 'hiring', 'how many', 'which'],
|
||||
},
|
||||
/* Specifying a role is a different question from managing the ones that
|
||||
exist, so Create Position is its own context rather than Positions with a
|
||||
form on it. Its topics are the form's own fields. */
|
||||
'admin.createPosition': {
|
||||
id: 'admin.createPosition',
|
||||
page: 'Create Position',
|
||||
topics: ['weight', 'vetting', 'criteria', 'scoring', 'score', 'certification', 'credential',
|
||||
'requirement', 'skill', 'pay', 'rate', 'salary', 'benchmark', 'compare', 'experience',
|
||||
'typical', 'position', 'role', 'job description', 'form', 'field', 'step', 'how do i',
|
||||
'what do i', 'explain', 'summar', 'flow'],
|
||||
},
|
||||
/* The Candidates list and Candidates Analysis are separate contexts because they
|
||||
ask different questions of the same records: the list is triage — who needs a
|
||||
decision — and Analysis reads the pool as a whole. */
|
||||
'admin.candidatesList': {
|
||||
id: 'admin.candidatesList',
|
||||
page: 'Candidates',
|
||||
topics: ['candidate', 'applicant', 'attention', 'waiting', 'interview', 'ready', 'score', 'unscored', 'risk', 'flag', 'strongest', 'best', 'top', 'compare', 'shortlist', 'pipeline', 'summar', 'how many', 'who'],
|
||||
},
|
||||
'admin.candidates': {
|
||||
id: 'admin.candidates',
|
||||
page: 'Candidates Analysis',
|
||||
topics: ['candidate', 'applicant', 'gap', 'missing', 'coverage', 'unscored', 'risk', 'flag', 'compare', 'top', 'best', 'strongest', 'shortlist', 'recommend', 'hire', 'who', 'pool', 'quality'],
|
||||
},
|
||||
/* Analytics reads the same records as the Control Center, but as performance
|
||||
over time rather than as a state to act on — hence its own capabilities. */
|
||||
'admin.analytics': {
|
||||
id: 'admin.analytics',
|
||||
page: 'Analytics',
|
||||
topics: ['trend', 'over time', 'month', 'week', 'department', 'category', 'team', 'perform', 'bottleneck', 'funnel', 'conversion', 'position', 'role', 'score', 'attention', 'rate', 'average', 'breakdown', 'report', 'how many', 'compare'],
|
||||
},
|
||||
/* Forge, Talent Pool and Hired History read the same platform records as the
|
||||
contexts above, but ask different questions of them: proof and progression,
|
||||
supply and readiness, and outcomes after the hire. */
|
||||
/* Forge is an authoring console, so its topics are the author's: what the
|
||||
library holds, what is live, what Owliver checks, and how the workforce is
|
||||
doing with it. */
|
||||
'admin.forge': {
|
||||
id: 'admin.forge',
|
||||
page: 'KROW Forge',
|
||||
topics: ['forge', 'skill', 'challenge', 'course', 'training', 'library', 'badge',
|
||||
'certification', 'publish', 'published', 'draft', 'archive', 'status', 'category',
|
||||
'owliver', 'evaluat', 'criteri', 'rubric', 'proof', 'evidence', 'verify', 'verification',
|
||||
'verified', 'workforce', 'gap', 'missing', 'what do we have'],
|
||||
},
|
||||
'admin.talentPool': {
|
||||
id: 'admin.talentPool',
|
||||
page: 'Talent Pool',
|
||||
topics: ['talent', 'pool', 'worker', 'profile', 'priorit', 'availab', 'verif', 'score', 'unscored', 'segment', 'supply', 'summar', 'how many', 'who', 'shortlist'],
|
||||
},
|
||||
'admin.hiredHistory': {
|
||||
id: 'admin.hiredHistory',
|
||||
page: 'Hired History',
|
||||
topics: ['hire', 'hired', 'outcome', 'department', 'quality', 'recent', 'strongest', 'pattern', 'time to hire', 'retention', 'how many', 'who'],
|
||||
},
|
||||
/* The account page. A form rather than a workflow, but the questions people
|
||||
ask about it — what may I do, how is this secured, what did I change — are
|
||||
answerable from what the page already shows. */
|
||||
'admin.profile': {
|
||||
id: 'admin.profile',
|
||||
page: 'Profile',
|
||||
topics: ['permission', 'scope', 'access', 'allowed', 'can i', 'account', 'role',
|
||||
'password', 'two-factor', 'two factor', '2fa', 'security', 'session', 'sign out',
|
||||
'preference', 'setting', 'digest', 'density', 'workspace', 'owliver', 'profile',
|
||||
'name', 'email', 'activity', 'recent', 'do here', 'how do i', 'edit', 'change my'],
|
||||
},
|
||||
/**
|
||||
* Agent configuration — a workspace page, not an operational one.
|
||||
*
|
||||
* It holds no workforce records, and no skill declares it, so
|
||||
* `skillsForContext` returns nothing here. That is deliberate rather than an
|
||||
* omission: configuring the Analytics Agent must not put the reader on
|
||||
* Analytics, and the surest way to guarantee that is for this page to have no
|
||||
* operational reading available at all.
|
||||
*
|
||||
* `topics` keeps it honest in the other direction — a workforce question asked
|
||||
* here is declined and pointed at the page that holds the records, rather than
|
||||
* answered from whatever this screen happens to know.
|
||||
*/
|
||||
'admin.agentConfigure': {
|
||||
id: 'admin.agentConfigure',
|
||||
page: 'Agent Configure',
|
||||
topics: AGENT_CONFIGURE_TOPICS,
|
||||
},
|
||||
/**
|
||||
* Settings, and the workspace pages behind it.
|
||||
*
|
||||
* **A page with no agent of its own is not a page without Owliver.** These
|
||||
* six carry the same panel as the eight operational pages, resolved by the
|
||||
* same placement table, answered by the same responder mechanism — and they
|
||||
* open on the general Krow Workforce Agent, because no specialist was written
|
||||
* for a configuration screen and none needs to be.
|
||||
*
|
||||
* What they do not carry is workforce records. No skill declares these
|
||||
* surfaces, so `skillsForContext` returns an empty list on every one of them,
|
||||
* and `topics` keeps the decline honest in the other direction: a question
|
||||
* about positions or attendance asked here is pointed at the page that holds
|
||||
* those records rather than answered from a configuration screen.
|
||||
*
|
||||
* The two halves are the whole design. Remove the first and Owliver is dead
|
||||
* on half the product; remove the second and it invents figures on a page
|
||||
* that has none.
|
||||
*/
|
||||
'admin.settings': {
|
||||
id: 'admin.settings',
|
||||
page: 'Settings',
|
||||
topics: SETTINGS_TOPICS,
|
||||
},
|
||||
'admin.workspace': {
|
||||
id: 'admin.workspace',
|
||||
page: 'Workspace',
|
||||
topics: WORKSPACE_TOPICS,
|
||||
},
|
||||
'admin.workspaceAgents': {
|
||||
id: 'admin.workspaceAgents',
|
||||
page: 'Agents',
|
||||
topics: WORKSPACE_AGENTS_TOPICS,
|
||||
},
|
||||
'admin.workspaceSkills': {
|
||||
id: 'admin.workspaceSkills',
|
||||
page: 'Skills',
|
||||
topics: WORKSPACE_SKILLS_TOPICS,
|
||||
},
|
||||
'admin.skillConfigure': {
|
||||
id: 'admin.skillConfigure',
|
||||
page: 'Skill Configure',
|
||||
topics: SKILL_CONFIGURE_TOPICS,
|
||||
},
|
||||
'admin.skillDevelopment': {
|
||||
id: 'admin.skillDevelopment',
|
||||
page: 'Skill Development',
|
||||
topics: SKILL_DEVELOPMENT_TOPICS,
|
||||
},
|
||||
'admin.activity': {
|
||||
id: 'admin.activity',
|
||||
page: 'Activity',
|
||||
topics: ['activity', 'audit', 'event', 'log', 'security', 'breach', 'compliance', 'trace', 'unusual', 'anomal', 'suspicious', 'user', 'who', 'account', 'busiest'],
|
||||
},
|
||||
};
|
||||
|
||||
export const getContext = (id) => ASSISTANT_CONTEXTS[id] ?? null;
|
||||
831
src/components/ai-assistant/dynamic.js
Normal file
831
src/components/ai-assistant/dynamic.js
Normal file
@@ -0,0 +1,831 @@
|
||||
import { plural, verb } from './insights';
|
||||
import { prioritise, workforceStatusFor } from '@/lib/workforce';
|
||||
|
||||
/**
|
||||
* Dynamic greetings and suggested prompts.
|
||||
*
|
||||
* Nothing here is a fixed string chosen in advance. The greeting states what is
|
||||
* actually true on the page right now, and the suggestions name real records —
|
||||
* the specific candidates, the specific starved role. That is the difference
|
||||
* between an assistant that looks aware of context and one that is.
|
||||
*
|
||||
* Everything derives from the same fact sheet the dashboard renders from, so a
|
||||
* greeting can never contradict the numbers beside it.
|
||||
*/
|
||||
|
||||
/* ── Greetings ──────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Time of day from the viewer's own clock.
|
||||
*
|
||||
* `getHours()` is local, so this follows the user's timezone rather than the
|
||||
* server's. The late band matters: at 00:30 "morning" is wrong, so 21:00–04:59
|
||||
* reads as evening.
|
||||
*
|
||||
* 05:00–11:59 morning
|
||||
* 12:00–16:59 afternoon
|
||||
* 17:00–20:59 evening
|
||||
* 21:00–04:59 evening
|
||||
*/
|
||||
function timeOfDay(date) {
|
||||
const hour = date.getHours();
|
||||
if (hour >= 5 && hour < 12) return 'morning';
|
||||
if (hour >= 12 && hour < 17) return 'afternoon';
|
||||
return 'evening';
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the single most useful thing to say about the page.
|
||||
*
|
||||
* Ordered by urgency rather than by what sounds cheeriest: something stuck beats
|
||||
* something healthy, because the greeting is the one line guaranteed to be read.
|
||||
*/
|
||||
const GREETINGS = {
|
||||
'employer.overview': (f) => {
|
||||
if (!f.total) return 'No applicants yet — publish a position and I will start tracking it.';
|
||||
if (f.stalled.length) {
|
||||
return `${plural(f.stalled.length, 'strong candidate')} ${verb(f.stalled.length, 'is', 'are')} screened and waiting on you.`;
|
||||
}
|
||||
if (f.unscreened.length) {
|
||||
return `${plural(f.unscreened.length, 'applicant')} ${verb(f.unscreened.length, 'is', 'are')} waiting to be screened.`;
|
||||
}
|
||||
if (f.starvedPositions.length) {
|
||||
return `${plural(f.starvedPositions.length, 'open role')} ${verb(f.starvedPositions.length, 'has', 'have')} no applicants yet.`;
|
||||
}
|
||||
return 'Your hiring pipeline looks healthy today.';
|
||||
},
|
||||
|
||||
'employer.candidates': (f) => {
|
||||
if (!f.total) return 'No candidates in the pipeline yet.';
|
||||
if (f.unscreened.length) {
|
||||
return `You have ${plural(f.unscreened.length, 'candidate')} waiting for review.`;
|
||||
}
|
||||
if (f.stalled.length) {
|
||||
return `${plural(f.stalled.length, 'candidate')} at 80+ ${verb(f.stalled.length, 'is', 'are')} ready for a decision.`;
|
||||
}
|
||||
return `All ${f.total} candidates are scored and actioned.`;
|
||||
},
|
||||
|
||||
'employer.analytics': (f) => {
|
||||
if (!f.scored.length) return 'Nothing scored yet, so the charts are still empty.';
|
||||
if (f.bottleneck && f.bottleneck.lost > 0) {
|
||||
return `Your funnel loses most people between ${f.bottleneck.from} and ${f.bottleneck.to}.`;
|
||||
}
|
||||
return `${plural(f.scored.length, 'candidate')} scored, averaging ${f.avgScore}. Worth a closer look.`;
|
||||
},
|
||||
|
||||
'admin.controlCenter': (f) => {
|
||||
if (f.unscoredProfiles.length) {
|
||||
return `${plural(f.unscoredProfiles.length, 'talent profile')} ${verb(f.unscoredProfiles.length, 'has', 'have')} no score and cannot be matched.`;
|
||||
}
|
||||
if (f.activity24h.length) {
|
||||
return `${plural(f.activity24h.length, 'event')} logged in the last 24 hours.`;
|
||||
}
|
||||
return `Platform is steady across ${plural(f.postings.length, 'position')} and ${plural(f.total, 'application')}.`;
|
||||
},
|
||||
|
||||
/* The form is the page, so the greeting is about what is being specified
|
||||
rather than about the board — read from what this workspace has posted. */
|
||||
'admin.createPosition': (f) => {
|
||||
if (!f.postings.length) {
|
||||
return 'This is the first position in the workspace — the weights you set here become the house pattern.';
|
||||
}
|
||||
/* Every posted role is a benchmark for pay and experience; only the ones
|
||||
carrying weights can be compared on those, so the two are counted
|
||||
separately rather than one standing in for the other. */
|
||||
const weighted = f.postings.filter((p) => p.vetting_criteria).length;
|
||||
return `Specifying a new role. ${plural(f.postings.length, 'position')} on file to benchmark against${weighted ? `, ${weighted} with vetting weights set` : ''}.`;
|
||||
},
|
||||
|
||||
'admin.positions': (f) => {
|
||||
if (!f.openPositions.length) return 'No open positions yet — publish a role and I will start tracking it.';
|
||||
if (f.starvedPositions.length) {
|
||||
return `${plural(f.starvedPositions.length, 'open position')} ${verb(f.starvedPositions.length, 'has', 'have')} no applicants and ${verb(f.starvedPositions.length, 'needs', 'need')} attention.`;
|
||||
}
|
||||
if (f.singleCandidateRoles.length) {
|
||||
return `${plural(f.singleCandidateRoles.length, 'role')} ${verb(f.singleCandidateRoles.length, 'rests', 'rest')} on a single viable candidate.`;
|
||||
}
|
||||
if (f.unscreened.length) {
|
||||
return `${plural(f.unscreened.length, 'applicant')} across your roles ${verb(f.unscreened.length, 'is', 'are')} waiting to be screened.`;
|
||||
}
|
||||
return `All ${f.openPositions.length} open roles have candidates and a moving pipeline.`;
|
||||
},
|
||||
|
||||
'admin.candidatesList': (f) => {
|
||||
if (!f.total) return 'No candidates in the pipeline yet.';
|
||||
if (f.stalled.length) {
|
||||
return `${plural(f.stalled.length, 'strong candidate')} ${verb(f.stalled.length, 'is', 'are')} screened and waiting on a decision.`;
|
||||
}
|
||||
if (f.unscreened.length) {
|
||||
return `${plural(f.unscreened.length, 'candidate')} ${verb(f.unscreened.length, 'has', 'have')} no score and cannot be ranked.`;
|
||||
}
|
||||
if (f.missingCredentials.length) {
|
||||
return `${plural(f.missingCredentials.length, 'scored candidate')} ${verb(f.missingCredentials.length, 'has', 'have')} no credentials on file.`;
|
||||
}
|
||||
return `All ${f.total} candidates are scored and actioned.`;
|
||||
},
|
||||
|
||||
'admin.candidates': (f) => {
|
||||
if (!f.total) return 'Nothing in the pipeline yet, so there is no pool to analyse.';
|
||||
if (f.needsDeeperReview.length) {
|
||||
return `${plural(f.needsDeeperReview.length, 'candidate')} currently ${verb(f.needsDeeperReview.length, 'needs', 'need')} deeper review.`;
|
||||
}
|
||||
if (f.singleCandidateRoles.length) {
|
||||
return `${plural(f.singleCandidateRoles.length, 'role')} ${verb(f.singleCandidateRoles.length, 'rests', 'rest')} on a single viable candidate.`;
|
||||
}
|
||||
return `All ${f.total} candidates are scored, credentialed and comparable.`;
|
||||
},
|
||||
|
||||
'admin.analytics': (f) => {
|
||||
if (!f.total) return 'Nothing has been applied for yet, so the charts have nothing to report.';
|
||||
if (f.bottleneck && f.bottleneck.lost > 0) {
|
||||
return `Conversion is weakest between ${f.bottleneck.from} and ${f.bottleneck.to}, at ${f.bottleneck.rate}%.`;
|
||||
}
|
||||
if (f.standardizedPct < 80) {
|
||||
return `Screening covers ${f.standardizedPct}% of applications, so the rest of these figures rest on a partial pool.`;
|
||||
}
|
||||
return `${plural(f.hired.length, 'hire')} from ${plural(f.total, 'applicant')} — ${f.hireRate}% conversion.`;
|
||||
},
|
||||
|
||||
/* The line the panel opens with: whatever most needs the author's attention,
|
||||
which on an authoring console is an unfinished skill before it is a total. */
|
||||
'admin.forge': (f) => {
|
||||
const g = f.forge;
|
||||
if (!g.library.length) return 'The library is empty — no skill is defined yet.';
|
||||
|
||||
const unscored = g.library.filter((c) => !c.challenge?.rubric?.length).length;
|
||||
if (unscored) {
|
||||
return `${plural(unscored, 'skill')} ${verb(unscored, 'has', 'have')} no Owliver criteria, so ${verb(unscored, 'it', 'they')} cannot verify anything yet.`;
|
||||
}
|
||||
if (g.drafts.length) {
|
||||
return `${plural(g.drafts.length, 'skill')} ${verb(g.drafts.length, 'is', 'are')} in draft and not visible to the workforce.`;
|
||||
}
|
||||
return `${plural(g.published.length, 'skill')} published, ${plural(g.workforceVerified, 'verification')} earned.`;
|
||||
},
|
||||
|
||||
'admin.talentPool': (f) => {
|
||||
if (!f.profiles.length) return 'No talent profiles registered yet.';
|
||||
if (f.unscoredProfiles.length) {
|
||||
return `${plural(f.unscoredProfiles.length, 'profile')} ${verb(f.unscoredProfiles.length, 'has', 'have')} no career score and cannot be ranked.`;
|
||||
}
|
||||
if (f.talent.unverified.length) {
|
||||
return `${plural(f.talent.unverified.length, 'profile')} ${verb(f.talent.unverified.length, 'carries', 'carry')} no verified skill.`;
|
||||
}
|
||||
return `${plural(f.profiles.length, 'worker')} in the pool, ${f.elite.length} Elite.`;
|
||||
},
|
||||
|
||||
'admin.hiredHistory': (f) => {
|
||||
const h = f.hiring;
|
||||
if (!h.hires.length) return 'Nobody has been hired yet, so there is no history to read.';
|
||||
if (h.unrated.length) {
|
||||
return `${plural(h.unrated.length, 'hire')} ${verb(h.unrated.length, 'has', 'have')} no client rating on file.`;
|
||||
}
|
||||
return `${plural(h.hires.length, 'hire')} placed, averaging ${h.avgQuality} at hire.`;
|
||||
},
|
||||
|
||||
/* Counted from the activity log, which is a real record. This used to open by
|
||||
stating how many permission scopes the account held "at full access" — read
|
||||
off a hardcoded list in `lib/admin/permissions.js` that the server never
|
||||
sees and that describes an administrator, not this user. An assistant's
|
||||
first line asserting an access level nothing checked is worse than one that
|
||||
says less. */
|
||||
'admin.profile': (f) => {
|
||||
const mine = f.user
|
||||
? f.activity.filter((e) => e.user_email === f.user.email)
|
||||
: [];
|
||||
if (!mine.length) return 'Nothing logged against this account yet.';
|
||||
return `${plural(mine.length, 'event')} logged against this account.`;
|
||||
},
|
||||
|
||||
'admin.activity': (f) => {
|
||||
if (!f.activity.length) return 'No activity logged yet.';
|
||||
const patterns = f.activitySignals.flags.length;
|
||||
if (patterns) {
|
||||
return `${plural(patterns, 'unusual activity pattern')} ${verb(patterns, 'needs', 'need')} attention.`;
|
||||
}
|
||||
if (f.activity24h.length === 0) {
|
||||
return `Quiet week — ${plural(f.activity7d.length, 'event')} logged, nothing out of pattern.`;
|
||||
}
|
||||
return `${plural(f.activity24h.length, 'event')} today, ${f.activity7d.length} this week, nothing out of pattern.`;
|
||||
},
|
||||
|
||||
'admin.agentConfigure': () =>
|
||||
'Configure what this agent covers, its attached skills, reference knowledge, and reasoning behavior.',
|
||||
|
||||
/* Settings and the workspace pages. Static rather than computed, because a
|
||||
configuration screen has no records to read and a line that pretended
|
||||
otherwise would be the fabrication the whole design refuses. */
|
||||
'admin.settings': () =>
|
||||
'Your account, the organization, who may do what, and the behaviours this workspace runs on.',
|
||||
'admin.workspace': () =>
|
||||
'The agents that answer, the skills they carry, and the paths the workforce progresses along.',
|
||||
'admin.workspaceAgents': () =>
|
||||
'Every agent this workspace has, what each one covers, and which are in service.',
|
||||
'admin.workspaceSkills': () =>
|
||||
'The shared library — what Owliver can answer, and what a Krow page can draw.',
|
||||
'admin.skillConfigure': () =>
|
||||
'Declare what this skill is, which pages it applies on, and what it can do.',
|
||||
'admin.skillDevelopment': () =>
|
||||
'Define the capabilities the workforce holds, and the levels they progress through.',
|
||||
};
|
||||
|
||||
/**
|
||||
* Title lines.
|
||||
*
|
||||
* Every Admin context greets by the clock. That is not decoration: the greeting
|
||||
* is the one line guaranteed to be read, so it has to prove the panel knows when
|
||||
* and where it is before it says anything about the data. A fixed "Good morning"
|
||||
* on an 11pm shift does the opposite.
|
||||
*
|
||||
* First names read better than a formal greeting in a small panel, so the name is
|
||||
* used where one is known.
|
||||
*/
|
||||
const TITLES = {
|
||||
'employer.overview': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'employer.candidates': () => 'Candidate intelligence',
|
||||
'employer.analytics': () => 'Analytics assistant',
|
||||
'admin.controlCenter': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.positions': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.createPosition': () => 'Specifying a position',
|
||||
'admin.candidatesList': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.candidates': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.analytics': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.activity': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.forge': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.talentPool': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.hiredHistory': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.profile': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.agentConfigure': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.settings': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.workspace': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.workspaceAgents': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.workspaceSkills': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.skillConfigure': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.skillDevelopment': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
};
|
||||
|
||||
/** Descriptions state what this page's assistant can actually do with the data. */
|
||||
const DESCRIPTIONS = {
|
||||
'employer.overview': (f) =>
|
||||
`Reading ${f.total} applicants across ${plural(f.openPositions.length, 'open role')}.`,
|
||||
'employer.candidates': (f) => `All ${f.total} candidates and their score breakdowns.`,
|
||||
'employer.analytics': () => 'Every figure on this page, explained or forecast.',
|
||||
'admin.controlCenter': (f) =>
|
||||
`Reading ${plural(f.total, 'application')} across ${plural(f.postings.length, 'position')} — health, operations and what needs intervention.`,
|
||||
'admin.positions': (f) =>
|
||||
`Reading ${plural(f.total, 'application')} across ${plural(f.postings.length, 'position')} — hiring health, pipeline strength and what needs intervention.`,
|
||||
'admin.createPosition': (f) =>
|
||||
`Reading this draft against ${plural(f.postings.length, 'posted position')} — vetting weights, pay, experience and the credentials already in use.`,
|
||||
'admin.candidatesList': (f) =>
|
||||
`Reading ${plural(f.total, 'candidate')} across ${plural(f.postings.length, 'position')} — match quality, screening and hiring readiness.`,
|
||||
'admin.candidates': (f) =>
|
||||
`${plural(f.total, 'candidate')} and ${plural(f.profiles.length, 'talent profile')} — supply, quality and risk.`,
|
||||
'admin.analytics': (f) =>
|
||||
`Reading hiring performance across ${plural(f.openPositions.length, 'position')}, ${plural(f.total, 'candidate')} and ${plural(new Set(f.byRole.map((r) => r.category)).size, 'department')} — conversion, speed and outcomes.`,
|
||||
'admin.activity': (f) => `${plural(f.activity.length, 'logged event')}, reconciled and profiled.`,
|
||||
'admin.forge': (f) =>
|
||||
`Reading ${plural(f.forge.library.length, 'skill')} — training, proof, what Owliver evaluates and how the workforce is doing with them.`,
|
||||
'admin.talentPool': (f) =>
|
||||
`Reading ${plural(f.profiles.length, 'talent profile')} — availability, career score and verification.`,
|
||||
'admin.hiredHistory': (f) =>
|
||||
`Reading ${plural(f.hiring.hires.length, 'hire')} across ${plural(f.hiring.byDepartment.length, 'department')} — quality, speed and outcomes.`,
|
||||
'admin.profile': () =>
|
||||
'Reading this account — who you are on this workspace, and your own activity.',
|
||||
'admin.agentConfigure': () =>
|
||||
'Ask Owliver about agent settings, lifecycle, attaching skills, or custom instructions.',
|
||||
'admin.settings': () =>
|
||||
'Ask Owliver what this screen configures, what each control does, and where the records behind a workforce question live.',
|
||||
'admin.workspace': () =>
|
||||
'Ask Owliver what the workspace governs — agents, skills, and how a page decides which agent answers.',
|
||||
'admin.workspaceAgents': () =>
|
||||
'Ask Owliver how a page picks its agent, what constrains one, and what draft, published and archived mean.',
|
||||
'admin.workspaceSkills': () =>
|
||||
'Ask Owliver about the two skill lists, how a skill is written, and how one reaches an agent.',
|
||||
'admin.skillConfigure': () =>
|
||||
'Ask Owliver what this editor declares, why a definition is refused, and where the skill will apply.',
|
||||
'admin.skillDevelopment': () =>
|
||||
'Ask Owliver what a training path defines and how its levels work.',
|
||||
};
|
||||
|
||||
/**
|
||||
* Placeholders phrased as things this page's user would actually type.
|
||||
*
|
||||
* The Admin ones name Owliver and name the page's subject, because the panel sits
|
||||
* permanently beside the dashboard: someone glancing at it needs the input itself
|
||||
* to say what it is for and what this page can be asked about.
|
||||
*/
|
||||
const PLACEHOLDERS = {
|
||||
'employer.overview': (f) => [
|
||||
`Summarize ${timeOfDay(f.today)}'s hiring…`,
|
||||
f.starvedPositions.length ? 'Which positions need attention?' : 'What should I do first?',
|
||||
'Why did hiring slow this week?',
|
||||
],
|
||||
'employer.candidates': (f) => [
|
||||
f.ranked.length >= 2 ? 'Compare the top candidates…' : 'Analyse my strongest candidate…',
|
||||
'Which resumes need review?',
|
||||
'Recommend interview questions…',
|
||||
],
|
||||
'employer.analytics': () => [
|
||||
'Explain this chart…',
|
||||
'Forecast hiring…',
|
||||
'Generate an executive summary…',
|
||||
],
|
||||
'admin.controlCenter': () => [
|
||||
'Ask Owliver about platform health…',
|
||||
'Ask Owliver what needs attention…',
|
||||
'Ask Owliver about hiring bottlenecks…',
|
||||
],
|
||||
'admin.positions': () => [
|
||||
'Ask Owliver about your positions…',
|
||||
'Ask Owliver which role to fill first…',
|
||||
'Ask Owliver about hiring bottlenecks…',
|
||||
],
|
||||
'admin.createPosition': () => [
|
||||
'Ask Owliver about the vetting weights…',
|
||||
'Ask Owliver what comparable roles ask for…',
|
||||
'Ask Owliver to explain this form…',
|
||||
],
|
||||
'admin.candidatesList': () => [
|
||||
'Ask Owliver about candidates…',
|
||||
'Ask Owliver who is interview ready…',
|
||||
'Ask Owliver about candidate risks…',
|
||||
],
|
||||
'admin.candidates': () => [
|
||||
'Ask Owliver about candidates…',
|
||||
'Ask Owliver about screening gaps…',
|
||||
'Ask Owliver where the risk is…',
|
||||
],
|
||||
'admin.analytics': () => [
|
||||
'Ask Owliver about hiring performance…',
|
||||
'Ask Owliver about the hiring trend…',
|
||||
'Ask Owliver where the bottleneck is…',
|
||||
],
|
||||
'admin.activity': () => [
|
||||
'Ask Owliver about recent activity…',
|
||||
'Ask Owliver about unusual activity…',
|
||||
'Ask Owliver about security concerns…',
|
||||
],
|
||||
'admin.forge': () => [
|
||||
'Ask Owliver to create a skill training…',
|
||||
'Ask Owliver what the library covers…',
|
||||
'Ask Owliver what a skill verifies…',
|
||||
],
|
||||
'admin.talentPool': () => [
|
||||
'Ask Owliver who to prioritize…',
|
||||
'Ask Owliver about verification gaps…',
|
||||
'Ask Owliver who is available…',
|
||||
],
|
||||
'admin.hiredHistory': () => [
|
||||
'Ask Owliver about hiring outcomes…',
|
||||
'Ask Owliver which roles hired best…',
|
||||
'Ask Owliver what stands out…',
|
||||
],
|
||||
'admin.profile': () => [
|
||||
'Ask Owliver what you can change here…',
|
||||
'Ask Owliver about your recent activity…',
|
||||
'Ask Owliver where your preferences live…',
|
||||
],
|
||||
'admin.agentConfigure': () => [
|
||||
'Ask Owliver about agent configuration…',
|
||||
'Ask Owliver how skills attach…',
|
||||
'Ask Owliver about reasoning modes…',
|
||||
],
|
||||
'admin.settings': () => [
|
||||
'Ask Owliver what you can configure here…',
|
||||
'Ask Owliver what the automation toggles do…',
|
||||
'Ask Owliver about permissions and security…',
|
||||
],
|
||||
'admin.workspace': () => [
|
||||
'Ask Owliver what the workspace governs…',
|
||||
'Ask Owliver what an agent is…',
|
||||
'Ask Owliver where skills come from…',
|
||||
],
|
||||
'admin.workspaceAgents': () => [
|
||||
'Ask Owliver how a page picks its agent…',
|
||||
'Ask Owliver what constrained means…',
|
||||
'Ask Owliver about draft and published…',
|
||||
],
|
||||
'admin.workspaceSkills': () => [
|
||||
'Ask Owliver about the two skill lists…',
|
||||
'Ask Owliver how a skill is written…',
|
||||
'Ask Owliver how skills reach an agent…',
|
||||
],
|
||||
'admin.skillConfigure': () => [
|
||||
'Ask Owliver what this editor declares…',
|
||||
'Ask Owliver why a definition is refused…',
|
||||
'Ask Owliver where this skill will apply…',
|
||||
],
|
||||
'admin.skillDevelopment': () => [
|
||||
'Ask Owliver what a training path is…',
|
||||
'Ask Owliver how the levels work…',
|
||||
'Ask Owliver what this screen defines…',
|
||||
],
|
||||
};
|
||||
|
||||
/* ── Suggested prompts ──────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Builds suggestions from what is actually on the page.
|
||||
*
|
||||
* NOT what the panel renders, and no longer on the production path. The chips
|
||||
* Owliver offers now come from `GET /api/v1/owliver/suggestions`, which ranks
|
||||
* against the rows in PostgreSQL and the caller's permissions — neither of
|
||||
* which a fact sheet assembled in the browser can speak for. `buildIntro` above
|
||||
* is unaffected: a greeting states what is on the page, and the page is where
|
||||
* that is known.
|
||||
*
|
||||
* What is left here is the derivation itself, which is still read by
|
||||
* `scripts/skill-check.mjs` and pinned by the committed Owliver baseline: given
|
||||
* a fact sheet, which questions does this page's data raise. It is kept because
|
||||
* the baseline is a record of behaviour rather than of code, and rewriting it
|
||||
* to match a deletion would erase the comparison it exists to make.
|
||||
*
|
||||
* A suggestion that names two real candidates is a different product from one
|
||||
* that says "Compare candidates" — it proves the assistant already looked.
|
||||
* Each carries the capability that answers it best, so a click is precise.
|
||||
*/
|
||||
const PROMPTS = {
|
||||
'employer.overview': (f) => [
|
||||
{ label: `Summarize ${timeOfDay(f.today)}'s hiring`, prompt: `Summarize hiring as of this ${timeOfDay(f.today)}`, capability: 'hiring-summary' },
|
||||
f.starvedPositions.length
|
||||
? { label: 'Which positions need attention?', prompt: `Which positions need attention? ${f.starvedPositions[0].title} has no applicants`, capability: 'pending-actions' }
|
||||
: { label: 'What needs my attention?', prompt: 'What needs my attention right now?', capability: 'pending-actions' },
|
||||
{ label: 'Pipeline health', prompt: 'How healthy is my hiring pipeline?', capability: 'hiring-health' },
|
||||
f.singleCandidateRoles.length || f.missingCredentials.length
|
||||
? { label: 'Where is the risk?', prompt: 'Where is the risk in my hiring right now?', capability: 'hiring-risks' }
|
||||
: { label: 'Any hiring risks?', prompt: 'Are there any hiring risks I should know about?', capability: 'hiring-risks' },
|
||||
],
|
||||
|
||||
'employer.candidates': (f) => {
|
||||
const [a, b] = f.ranked;
|
||||
return [
|
||||
a && b
|
||||
? { label: `Compare ${a.applicant_name.split(' ')[0]} & ${b.applicant_name.split(' ')[0]}`, prompt: `Compare ${a.applicant_name} and ${b.applicant_name}`, capability: 'candidate-comparison' }
|
||||
: { label: 'Compare top candidates', prompt: 'Compare my top candidates', capability: 'candidate-comparison' },
|
||||
f.unscreened.length
|
||||
? { label: `${f.unscreened.length} resumes need review`, prompt: 'Which resumes need review?', capability: 'resume-analysis' }
|
||||
: { label: 'Analyse top résumé', prompt: 'Analyse my strongest candidate', capability: 'resume-analysis' },
|
||||
a
|
||||
? { label: `Questions for ${a.applicant_name.split(' ')[0]}`, prompt: `Recommend interview questions for ${a.applicant_name}`, capability: 'interview-questions' }
|
||||
: null,
|
||||
{ label: 'Skill gaps', prompt: 'What skill gaps run across my candidates?', capability: 'skill-gap' },
|
||||
{ label: 'Who should I hire?', prompt: 'Who should I hire, interview, or pass on?', capability: 'hiring-recommendation' },
|
||||
].filter(Boolean);
|
||||
},
|
||||
|
||||
'employer.analytics': (f) => [
|
||||
{ label: 'Explain this chart', prompt: 'Explain the charts on this page', capability: 'explain-charts' },
|
||||
{ label: 'Forecast hiring', prompt: 'Forecast hiring from the current pipeline', capability: 'forecast-hiring' },
|
||||
{ label: 'Generate executive summary', prompt: 'Generate an executive hiring report', capability: 'generate-reports' },
|
||||
f.bottleneck
|
||||
? {
|
||||
label: `Why the drop to ${f.bottleneck.to}?`,
|
||||
prompt: `Why do candidates drop off between ${f.bottleneck.from} and ${f.bottleneck.to}?`,
|
||||
capability: 'explain-charts',
|
||||
}
|
||||
: null,
|
||||
{ label: 'Break down by role', prompt: 'Break down performance by role category', capability: 'department-insights' },
|
||||
].filter(Boolean),
|
||||
|
||||
/* Admin. The first chip is whatever the data has made urgent, so the set
|
||||
reorders itself as the platform's state changes rather than offering the
|
||||
same five questions regardless. */
|
||||
'admin.controlCenter': (f) => [
|
||||
f.starvedPositions.length
|
||||
? { label: `${f.starvedPositions.length} roles with no applicants`, prompt: 'What needs attention right now?', capability: 'attention-required' }
|
||||
: { label: 'What needs attention?', prompt: 'What needs attention right now?', capability: 'attention-required' },
|
||||
{ label: 'Platform health', prompt: 'Run a platform health check', capability: 'platform-health' },
|
||||
f.bottleneck && f.bottleneck.lost > 0
|
||||
? { label: `Bottleneck at ${f.bottleneck.to}`, prompt: `Show hiring bottlenecks — why do candidates drop between ${f.bottleneck.from} and ${f.bottleneck.to}?`, capability: 'pipeline-health' }
|
||||
: { label: 'Show hiring bottlenecks', prompt: 'Show hiring bottlenecks in the pipeline', capability: 'pipeline-health' },
|
||||
{ label: 'Summarize platform usage', prompt: 'Summarize platform usage and workforce scale', capability: 'workforce-summary' },
|
||||
{ label: 'Hiring operations', prompt: 'How are hiring operations performing?', capability: 'hiring-operations' },
|
||||
{ label: 'Identify operational risks', prompt: 'Identify operational risks and what I should fix first', capability: 'recommendations' },
|
||||
],
|
||||
|
||||
/* Create Position asks about the draft in front of the author. Every one is
|
||||
answerable from the form and the positions already posted. */
|
||||
'admin.createPosition': () => [
|
||||
{ label: 'Explain the vetting weights', prompt: 'Summarize these vetting weights', capability: 'vetting-weights' },
|
||||
{ label: 'Compare with similar roles', prompt: 'What do comparable roles ask for?', capability: 'position-benchmarks' },
|
||||
{ label: 'Credentials in use', prompt: 'Which certifications does this workspace require?', capability: 'position-requirements' },
|
||||
{ label: 'How this form works', prompt: 'Explain how this form works', capability: 'position-spec-steps' },
|
||||
],
|
||||
|
||||
/**
|
||||
* Positions suggestions are the current workforce situation, phrased as
|
||||
* questions.
|
||||
*
|
||||
* Each one is offered only when the data behind it exists — a prompt about
|
||||
* strong matches on a board with none is an invitation to a dead end. None of
|
||||
* them names a person: the question is asked generically and the candidates
|
||||
* are resolved live, so a suggestion cannot go stale between being rendered
|
||||
* and being clicked.
|
||||
*
|
||||
* Workforce prompts carry no `capability`, which is what sends them through
|
||||
* intent routing to the engine rather than to a page analytics responder.
|
||||
*/
|
||||
'admin.positions': (f, workforce) => {
|
||||
const out = [];
|
||||
|
||||
/**
|
||||
* Two things about the position in front of the reader come first, because
|
||||
* both are about what they were *just doing* rather than about the board.
|
||||
*
|
||||
* A draft is unfinished work, so finishing it leads. Then the role the page
|
||||
* has open — the drawer, or the one just published, which the form names in
|
||||
* the URL on its way here — gets the question its state actually invites:
|
||||
* a role with no applicants yet is a matching question, not a pipeline one.
|
||||
*
|
||||
* Both name the record, so the answer resolves against that position and
|
||||
* cannot be a stale reading of a different one.
|
||||
*/
|
||||
const drafts = (f.postings || []).filter((p) => p.status === 'draft');
|
||||
if (drafts.length) {
|
||||
out.push(drafts.length === 1
|
||||
/* Names the draft, so the answer continues *that* record in the panel
|
||||
rather than listing every draft and sending the reader to a form. */
|
||||
? {
|
||||
label: `Continue the ${drafts[0].title} draft`,
|
||||
prompt: `Continue the ${drafts[0].title} draft`,
|
||||
}
|
||||
: {
|
||||
label: `Which ${plural(drafts.length, 'position')} ${verb(drafts.length, 'is', 'are')} still in draft?`,
|
||||
prompt: 'Which positions are still in draft?',
|
||||
capability: 'position-drafts',
|
||||
});
|
||||
}
|
||||
|
||||
const current = workforce?.currentPositionId
|
||||
? (workforce.positions || []).find((p) => p.id === workforce.currentPositionId)
|
||||
: null;
|
||||
if (current && current.status !== 'draft') {
|
||||
out.push({
|
||||
label: `Find matching candidates for ${current.title}`,
|
||||
/* The definition's own words, with the role named. It resolves to the
|
||||
candidate-matching skill and reads that position — so switching the
|
||||
skill off in Settings takes this answer with it, as it should. */
|
||||
prompt: `Find matching candidates for ${current.title}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (workforce?.positions?.length) {
|
||||
const { positions, context } = workforce;
|
||||
const ranked = prioritise(positions, context);
|
||||
const board = positions
|
||||
.filter((p) => p.status === 'active')
|
||||
.map((p) => ({ position: p, status: workforceStatusFor(p, context) }));
|
||||
|
||||
const withStrong = board.filter((r) => r.status.strong.length);
|
||||
const newToday = board.reduce((n, r) => n + r.status.newToday.length, 0);
|
||||
const readyCount = board.reduce(
|
||||
(n, r) => n + r.status.applicants.filter(
|
||||
(a) => ['ai_screened', 'shortlisted'].includes(a.application.status)
|
||||
).length,
|
||||
0
|
||||
);
|
||||
|
||||
if (withStrong.length) {
|
||||
const best = withStrong.sort((a, b) => b.status.strong.length - a.status.strong.length)[0];
|
||||
const n = best.status.strong.length;
|
||||
out.push({
|
||||
label: `Show the ${plural(n, 'strongest candidate')} for ${best.position.title}`,
|
||||
prompt: `Who matches ${best.position.title}?`,
|
||||
});
|
||||
}
|
||||
if (ranked.length) {
|
||||
out.push({ label: 'Which position needs people first?', prompt: 'Which position needs people first?' });
|
||||
}
|
||||
if (newToday) {
|
||||
out.push({
|
||||
label: `Which ${plural(newToday, 'candidate')} applied today?`,
|
||||
prompt: 'Who applied today?',
|
||||
});
|
||||
}
|
||||
if (readyCount) {
|
||||
out.push({
|
||||
label: `Which ${plural(readyCount, 'candidate')} ${verb(readyCount, 'is', 'are')} ready for interview?`,
|
||||
prompt: 'Who is ready for interview?',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* The page's own analytics questions keep their place behind the workforce
|
||||
ones — they are still the right answer to "how is hiring going". */
|
||||
out.push(
|
||||
f.starvedPositions.length
|
||||
? {
|
||||
label: `Which ${plural(f.starvedPositions.length, 'role')} ${verb(f.starvedPositions.length, 'needs', 'need')} attention?`,
|
||||
prompt: 'What needs attention across my positions?',
|
||||
capability: 'positions-attention',
|
||||
}
|
||||
: { label: 'What needs attention?', prompt: 'What needs attention across my positions?', capability: 'positions-attention' },
|
||||
{ label: 'Which position has the strongest pipeline?', prompt: 'Which position has the strongest pipeline?', capability: 'position-strength' },
|
||||
f.unscreened.length
|
||||
? {
|
||||
label: `Show the ${plural(f.unscreened.length, 'application')} waiting for review`,
|
||||
prompt: 'Show candidates waiting for review',
|
||||
capability: 'candidates-waiting',
|
||||
}
|
||||
: { label: 'Show candidates waiting for review', prompt: 'Show candidates waiting for review', capability: 'candidates-waiting' },
|
||||
/* Named for the whole board. The Hiring Activity skill answers the same
|
||||
question *for one position*, and the two used to arrive worded
|
||||
identically — see the intent de-duplication in `KrowAssistant`, which
|
||||
now also compares what a chip resolves to rather than only its words. */
|
||||
{ label: 'Summarize hiring activity across all positions', prompt: 'Summarize hiring activity across my positions', capability: 'hiring-operations' }
|
||||
);
|
||||
|
||||
return out;
|
||||
},
|
||||
|
||||
'admin.candidatesList': (f) => [
|
||||
f.stalled.length
|
||||
? { label: `${f.stalled.length} waiting on a decision`, prompt: 'Who needs attention?', capability: 'candidates-attention' }
|
||||
: { label: 'Who needs attention?', prompt: 'Who needs attention?', capability: 'candidates-attention' },
|
||||
{ label: 'Who are the strongest candidates?', prompt: 'Who are the strongest candidates?', capability: 'top-candidates' },
|
||||
{ label: 'Which candidates are ready for interview?', prompt: 'Which candidates are ready for interview?', capability: 'interview-ready' },
|
||||
f.unscreened.length
|
||||
? { label: `${f.unscreened.length} have no score`, prompt: 'Which candidates have no score?', capability: 'screening-gaps' }
|
||||
: { label: 'Which candidates have no score?', prompt: 'Which candidates have no score?', capability: 'screening-gaps' },
|
||||
{ label: 'Summarize the candidate pipeline', prompt: 'Summarize the candidate pipeline', capability: 'pipeline-summary' },
|
||||
{ label: 'Identify candidate risks', prompt: 'Identify candidate risks', capability: 'candidate-risk' },
|
||||
],
|
||||
|
||||
'admin.candidates': (f) => [
|
||||
f.ranked.length >= 2
|
||||
? { label: `Compare ${f.ranked[0].applicant_name.split(' ')[0]} & ${f.ranked[1].applicant_name.split(' ')[0]}`, prompt: 'Compare the top candidates in the pool', capability: 'top-candidates' }
|
||||
: { label: 'Compare top candidates', prompt: 'Compare the top candidates in the pool', capability: 'top-candidates' },
|
||||
f.missingCredentials.length
|
||||
? { label: `${f.missingCredentials.length} missing credentials`, prompt: 'Show candidate risks across the pool', capability: 'candidate-risk' }
|
||||
: { label: 'Show candidate risks', prompt: 'Show candidate risks across the pool', capability: 'candidate-risk' },
|
||||
f.unscreened.length
|
||||
? { label: `${f.unscreened.length} unscored applicants`, prompt: 'Find screening gaps in the candidate pool', capability: 'screening-gaps' }
|
||||
: { label: 'Find screening gaps', prompt: 'Find screening gaps in the candidate pool', capability: 'screening-gaps' },
|
||||
{ label: 'Recommend next actions', prompt: 'Recommend next actions for these candidates', capability: 'hiring-recommendations' },
|
||||
{ label: 'Recruitment insights', prompt: 'Show recruitment insights across the platform', capability: 'recruitment-insights' },
|
||||
],
|
||||
|
||||
'admin.analytics': (f) => [
|
||||
{ label: 'What is the hiring trend?', prompt: 'What is the hiring trend?', capability: 'hiring-trend' },
|
||||
{ label: 'Which department is performing best?', prompt: 'Which department is performing best?', capability: 'department-performance' },
|
||||
f.bottleneck && f.bottleneck.lost > 0
|
||||
? { label: `Biggest drop at ${f.bottleneck.to}`, prompt: `Where is the biggest hiring bottleneck — why do candidates drop between ${f.bottleneck.from} and ${f.bottleneck.to}?`, capability: 'pipeline-health' }
|
||||
: { label: 'Where is the biggest bottleneck?', prompt: 'Where is the biggest hiring bottleneck?', capability: 'pipeline-health' },
|
||||
{ label: 'Which positions are converting best?', prompt: 'Which positions are converting best?', capability: 'position-conversion' },
|
||||
{ label: 'Summarize hiring performance', prompt: 'Summarize hiring performance', capability: 'hiring-operations' },
|
||||
f.unscreened.length
|
||||
? { label: `${f.unscreened.length} unscored applications`, prompt: 'What needs attention?', capability: 'attention-required' }
|
||||
: { label: 'What needs attention?', prompt: 'What needs attention?', capability: 'attention-required' },
|
||||
],
|
||||
|
||||
/* Forge, Talent Pool and Hired History. Same rule as the contexts above: the
|
||||
first chip is whatever the data has made urgent, so the set reorders itself
|
||||
rather than offering the same questions regardless of state. */
|
||||
'admin.forge': (f) => {
|
||||
const g = f.forge;
|
||||
const unscored = g.library.filter((c) => !c.challenge?.rubric?.length).length;
|
||||
return [
|
||||
/* The chip that creates something goes first: on an authoring console the
|
||||
most likely next action is authoring. It carries no capability, so it
|
||||
routes through the skill registry rather than the page responder. */
|
||||
{ label: 'Create a skill training', prompt: 'Create a skill training' },
|
||||
g.drafts.length
|
||||
? { label: `${g.drafts.length} in draft`, prompt: 'Which skills are published?', capability: 'forge-published' }
|
||||
: { label: `${g.published.length} published`, prompt: 'Which skills are published?', capability: 'forge-published' },
|
||||
unscored
|
||||
? { label: `${unscored} without Owliver criteria`, prompt: 'What gaps are left in the library?', capability: 'forge-gaps' }
|
||||
: { label: 'What does Owliver evaluate?', prompt: 'What does Owliver evaluate?', capability: 'forge-evaluation' },
|
||||
{ label: 'What skills do we currently have?', prompt: 'What skills do we currently have?', capability: 'forge-library' },
|
||||
g.workforceVerified
|
||||
? { label: `${g.workforceVerified} skills verified`, prompt: 'How is the workforce doing with these skills?', capability: 'forge-workforce' }
|
||||
: { label: 'How is the workforce using these?', prompt: 'How is the workforce doing with these skills?', capability: 'forge-workforce' },
|
||||
];
|
||||
},
|
||||
|
||||
'admin.talentPool': (f) => {
|
||||
const t = f.talent;
|
||||
return [
|
||||
t.top[0]
|
||||
? { label: `Why does ${t.top[0].full_name?.split(' ')[0] || 'the top profile'} rank first?`, prompt: 'Which talent should I prioritize?', capability: 'talent-priorities' }
|
||||
: { label: 'Which talent should I prioritize?', prompt: 'Which talent should I prioritize?', capability: 'talent-priorities' },
|
||||
f.unscoredProfiles.length
|
||||
? { label: `${f.unscoredProfiles.length} profiles unscored`, prompt: 'Which profiles are unscored?', capability: 'talent-summary' }
|
||||
: { label: 'Summarize the talent pool', prompt: 'Summarize the talent pool', capability: 'talent-summary' },
|
||||
t.unverified.length
|
||||
? { label: `${t.unverified.length} need verification`, prompt: 'Which talent needs verification?', capability: 'talent-verification' }
|
||||
: { label: 'Which talent needs verification?', prompt: 'Which talent needs verification?', capability: 'talent-verification' },
|
||||
{ label: 'Who is currently available?', prompt: 'Who is currently available?', capability: 'talent-availability' },
|
||||
{ label: 'Who has the strongest score?', prompt: 'Who has the strongest score?', capability: 'talent-priorities' },
|
||||
];
|
||||
},
|
||||
|
||||
'admin.hiredHistory': (f) => {
|
||||
const h = f.hiring;
|
||||
return [
|
||||
h.unrated.length
|
||||
? { label: `${h.unrated.length} hires unrated`, prompt: 'What needs attention in hiring outcomes?', capability: 'hiring-patterns' }
|
||||
: { label: 'What hiring patterns stand out?', prompt: 'What hiring patterns stand out?', capability: 'hiring-patterns' },
|
||||
h.byDepartment[0]
|
||||
? { label: `${h.byDepartment[0].name} leads on hires`, prompt: 'Which department has the most hires?', capability: 'hiring-outcomes' }
|
||||
: { label: 'Which department has the most hires?', prompt: 'Which department has the most hires?', capability: 'hiring-outcomes' },
|
||||
{ label: 'Which positions hired the strongest talent?', prompt: 'Which positions hired the strongest talent?', capability: 'hiring-strongest' },
|
||||
{ label: 'Summarize recent hires', prompt: 'Summarize recent hires', capability: 'hiring-recent' },
|
||||
{ label: 'What hiring outcomes should I review?', prompt: 'What hiring outcomes should I review?', capability: 'hiring-outcomes' },
|
||||
];
|
||||
},
|
||||
|
||||
/* No permission or security prompts: this console has no endpoint behind
|
||||
either, so both could only be answered from an invented list. */
|
||||
'admin.profile': (f) => {
|
||||
const mine = f.user ? f.activity.filter((e) => e.user_email === f.user.email) : [];
|
||||
return [
|
||||
{ label: 'What can I change here?', prompt: 'What can I change on this page?', capability: 'profile-actions' },
|
||||
{ label: 'What are my preferences set to?', prompt: 'What are my preferences set to?', capability: 'profile-preferences' },
|
||||
mine.length
|
||||
? { label: `My last ${mine.length} actions`, prompt: 'What is my recent activity?', capability: 'profile-activity' }
|
||||
: { label: 'Where does my activity show?', prompt: 'What is my recent activity?', capability: 'profile-activity' },
|
||||
];
|
||||
},
|
||||
|
||||
'admin.activity': (f) => [
|
||||
f.activitySignals.flags.length
|
||||
? { label: `${f.activitySignals.flags.length} unusual patterns`, prompt: 'Show unusual activity on the platform', capability: 'unusual-activity' }
|
||||
: { label: 'Show unusual activity', prompt: 'Show unusual activity on the platform', capability: 'unusual-activity' },
|
||||
f.activity24h.length
|
||||
? { label: `Summarize today's ${f.activity24h.length} events`, prompt: "Summarize today's activity", capability: 'audit-summary' }
|
||||
: { label: "Summarize today's activity", prompt: "Summarize today's activity", capability: 'audit-summary' },
|
||||
{ label: 'What changed recently?', prompt: 'What changed recently on the platform?', capability: 'audit-summary' },
|
||||
{ label: 'Identify security concerns', prompt: 'Identify security concerns in the audit trail', capability: 'security-insights' },
|
||||
{ label: 'Who is most active?', prompt: 'Which accounts are most active?', capability: 'user-activity' },
|
||||
],
|
||||
|
||||
'admin.agentConfigure': () => [
|
||||
{ label: 'Where do skills come from?', prompt: 'Where do skills come from on this screen?' },
|
||||
{ label: 'Explain reasoning modes', prompt: 'What are the reasoning modes and how do they work?' },
|
||||
{ label: 'How to write instructions?', prompt: 'How should I write effective instructions for this agent?' },
|
||||
{ label: 'How does publishing work?', prompt: 'How does agent publishing and versioning work?' },
|
||||
],
|
||||
|
||||
/**
|
||||
* Settings and the workspace pages.
|
||||
*
|
||||
* Every chip below resolves to something this page can actually answer, and
|
||||
* that is the whole rule: a suggestion is a promise, so a configuration screen
|
||||
* must not offer "Which positions are at risk?" merely to have chips. None of
|
||||
* these names a record; each one names the screen. The operational chips above
|
||||
* are computed from data because those pages have data — these are not, for
|
||||
* exactly the same reason.
|
||||
*/
|
||||
'admin.settings': () => [
|
||||
{ label: 'What can I configure here?', prompt: 'What can I configure on Settings?' },
|
||||
{ label: 'What do the automation toggles do?', prompt: 'What do the automation toggles do?' },
|
||||
{ label: 'Who can access what?', prompt: 'Who can access what, and where is it recorded?' },
|
||||
],
|
||||
'admin.workspace': () => [
|
||||
{ label: 'What does the workspace govern?', prompt: 'What does the workspace govern?' },
|
||||
{ label: 'What is an agent?', prompt: 'What is an agent, and what does it decide?' },
|
||||
{ label: 'Where do skills come from?', prompt: 'Where do skills come from?' },
|
||||
],
|
||||
'admin.workspaceAgents': () => [
|
||||
{ label: 'How does a page pick its agent?', prompt: 'How does a page pick its agent?' },
|
||||
{ label: 'What does draft mean?', prompt: 'What do draft, published and archived mean?' },
|
||||
{ label: 'What is in this list?', prompt: 'What does this list of agents hold?' },
|
||||
],
|
||||
'admin.workspaceSkills': () => [
|
||||
{ label: 'How do skills reach an agent?', prompt: 'How do skills reach an agent?' },
|
||||
{ label: 'How is a skill written?', prompt: 'How is a skill written?' },
|
||||
{ label: 'What are the two lists?', prompt: 'What is the difference between the two skill lists?' },
|
||||
],
|
||||
'admin.skillConfigure': () => [
|
||||
{ label: 'Why is a definition refused?', prompt: 'Why would a definition be refused?' },
|
||||
{ label: 'Where will this apply?', prompt: 'Where will this skill apply?' },
|
||||
{ label: 'What does this editor declare?', prompt: 'What does this editor configure?' },
|
||||
],
|
||||
'admin.skillDevelopment': () => [
|
||||
{ label: 'How do the levels work?', prompt: 'How do the levels work?' },
|
||||
{ label: 'What is a training path?', prompt: 'What is a training path?' },
|
||||
],
|
||||
};
|
||||
|
||||
/* ── Public API ─────────────────────────────────────────────────────────── */
|
||||
|
||||
const firstName = (fullName) => String(fullName || '').trim().split(/\s+/)[0] || '';
|
||||
|
||||
/**
|
||||
* Everything the landing screen needs, computed from current data.
|
||||
* `userName` personalizes the greeting where a name is known.
|
||||
*/
|
||||
export function buildIntro(contextId, facts, userName) {
|
||||
const name = firstName(userName);
|
||||
return {
|
||||
title: TITLES[contextId]?.(facts, name) ?? 'Owliver',
|
||||
greeting: GREETINGS[contextId]?.(facts) ?? '',
|
||||
description: DESCRIPTIONS[contextId]?.(facts) ?? '',
|
||||
placeholders: PLACEHOLDERS[contextId]?.(facts) ?? ['Ask anything…'],
|
||||
};
|
||||
}
|
||||
|
||||
/** Suggested prompts for the current page and data. */
|
||||
export function buildPrompts(contextId, facts, workforce = null) {
|
||||
return PROMPTS[contextId]?.(facts, workforce) ?? [];
|
||||
}
|
||||
250
src/components/ai-assistant/history.js
Normal file
250
src/components/ai-assistant/history.js
Normal file
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Owliver's conversation history.
|
||||
*
|
||||
* The panel already persisted the *current* thread per page, in sessionStorage,
|
||||
* so returning to Candidates found the conversation you left there. What it had
|
||||
* no notion of was a conversation you had *finished*: starting a new one
|
||||
* overwrote the old one, and there was no way back to anything.
|
||||
*
|
||||
* This is that archive, and it is deliberately the same artefact — a list of
|
||||
* threads in the shape the panel already stores. Two differences, both
|
||||
* intentional:
|
||||
*
|
||||
* - **localStorage, not session.** A history that empties when the tab closes
|
||||
* is not a history. Threads outlive the session, which is what makes
|
||||
* "Yesterday" and "Older" mean anything.
|
||||
* - **Written continuously, not on reset.** The archive is updated as the
|
||||
* conversation grows rather than when it ends, so a thread is recoverable
|
||||
* even if the tab is closed mid-answer.
|
||||
*
|
||||
* Nothing here is a second conversation store: the live thread is still the one
|
||||
* the panel renders from. This holds copies, keyed by conversation, for the one
|
||||
* question the panel could not previously answer — "what did I ask before?"
|
||||
*/
|
||||
|
||||
const KEY = 'krow_assistant:history';
|
||||
|
||||
/** Threads older than this stop being useful and start being clutter. */
|
||||
const MAX_AGE_DAYS = 60;
|
||||
/** A hard cap, so a heavy user cannot fill the origin's storage quota. */
|
||||
const MAX_RECORDS = 100;
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* The shape of a stored record.
|
||||
*
|
||||
* Bumped when a record gains fields, so a reader can tell an old record from a
|
||||
* new one instead of guessing from which keys happen to be present.
|
||||
*/
|
||||
const SCHEMA = 2;
|
||||
|
||||
/**
|
||||
* An older record, brought up to date on the way out.
|
||||
*
|
||||
* A conversation held before agents existed is still a conversation somebody
|
||||
* had. It gets the new fields at their empty values and keeps everything it
|
||||
* already had — in particular `messages` is passed through untouched, so
|
||||
* nothing a reader wrote is rewritten by a schema change.
|
||||
*
|
||||
* Migrating on read rather than rewriting the store means a browser that never
|
||||
* opens History again still loses nothing, and there is no migration pass that
|
||||
* can fail halfway.
|
||||
*/
|
||||
function migrate(record) {
|
||||
if (record.schema === SCHEMA) return record;
|
||||
return {
|
||||
...record,
|
||||
schema: SCHEMA,
|
||||
/* Absent, not unknown: these conversations genuinely had no agent, no
|
||||
recorded skills and no feedback. */
|
||||
agentId: record.agentId ?? null,
|
||||
pageContext: record.pageContext ?? null,
|
||||
skillsUsed: Array.isArray(record.skillsUsed) ? record.skillsUsed : [],
|
||||
toolsUsed: Array.isArray(record.toolsUsed) ? record.toolsUsed : [],
|
||||
knowledgeUsed: Array.isArray(record.knowledgeUsed) ? record.knowledgeUsed : [],
|
||||
feedback: record.feedback ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Every archived conversation, newest first. Never throws. */
|
||||
export function readHistory() {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
const list = raw ? JSON.parse(raw) : [];
|
||||
if (!Array.isArray(list)) return [];
|
||||
|
||||
const cutoff = Date.now() - MAX_AGE_DAYS * DAY;
|
||||
return list
|
||||
.filter((r) => r && r.id && Array.isArray(r.messages) && r.messages.length)
|
||||
.filter((r) => new Date(r.updatedAt || 0).getTime() >= cutoff)
|
||||
.map(migrate)
|
||||
.sort((a, b) => new Date(b.updatedAt || 0).getTime() - new Date(a.updatedAt || 0).getTime());
|
||||
} catch {
|
||||
/* Corrupt or unavailable storage is an empty history, not an error the
|
||||
reader has to deal with. */
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** The first thing the person asked, which is what the thread was about. */
|
||||
function titleFor(messages) {
|
||||
const first = messages.find((m) => m.role === 'user' && m.text);
|
||||
const text = String(first?.text || '').trim();
|
||||
if (!text) return 'Conversation';
|
||||
return text.length > 60 ? `${text.slice(0, 57)}…` : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records this conversation, replacing its own earlier entry.
|
||||
*
|
||||
* Matched on `id`, so a thread being added to updates in place rather than
|
||||
* appearing once per turn.
|
||||
*/
|
||||
export function saveConversation({
|
||||
id, contextId, page, messages,
|
||||
/**
|
||||
* Who answered, where, and what it actually used.
|
||||
*
|
||||
* Recorded from what ran rather than from what was available: `skillsUsed`
|
||||
* is the skill that answered a turn, not every skill the page offered. A
|
||||
* record of what *could* have happened would make the insight figures
|
||||
* describe the registry instead of the conversation.
|
||||
*/
|
||||
agentId = null, pageContext = null,
|
||||
skillsUsed = [], toolsUsed = [], knowledgeUsed = [],
|
||||
}) {
|
||||
if (!id || !Array.isArray(messages) || !messages.length) return readHistory();
|
||||
|
||||
/* Feedback belongs to the conversation, not to the turn that triggered a
|
||||
save, so an existing rating survives the thread growing. */
|
||||
const existing = readHistory().find((r) => r.id === id) || null;
|
||||
|
||||
const record = {
|
||||
schema: SCHEMA,
|
||||
id,
|
||||
contextId,
|
||||
page: page || '',
|
||||
agentId,
|
||||
/* The *reduced* envelope — where the question was asked, never a copy of
|
||||
what was on screen. Storing selections and computed figures would write
|
||||
the dataset into localStorage a turn at a time. */
|
||||
pageContext,
|
||||
skillsUsed: [...new Set(skillsUsed.filter(Boolean))],
|
||||
toolsUsed: [...new Set(toolsUsed.filter(Boolean))],
|
||||
knowledgeUsed: [...new Set(knowledgeUsed.filter(Boolean))],
|
||||
feedback: existing?.feedback ?? null,
|
||||
title: titleFor(messages),
|
||||
turns: messages.filter((m) => m.role === 'user').length,
|
||||
updatedAt: new Date().toISOString(),
|
||||
messages,
|
||||
};
|
||||
|
||||
const next = [record, ...readHistory().filter((r) => r.id !== id)].slice(0, MAX_RECORDS);
|
||||
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* Quota or private mode: the live thread is unaffected, only the archive
|
||||
is. Dropping the oldest half is a better outcome than losing every
|
||||
future write. */
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(next.slice(0, Math.floor(MAX_RECORDS / 4))));
|
||||
} catch {
|
||||
/* Storage is unusable. History is in-memory for this session only. */
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records how a conversation was rated.
|
||||
*
|
||||
* Updates in place and never appends: rating a thread twice is a correction,
|
||||
* not two opinions. Returns the refreshed list so a caller re-renders from one
|
||||
* read rather than two.
|
||||
*/
|
||||
export function recordFeedback(id, feedback) {
|
||||
const list = readHistory();
|
||||
const at = list.findIndex((r) => r.id === id);
|
||||
if (at === -1) return list;
|
||||
|
||||
const next = [...list];
|
||||
next[at] = {
|
||||
...next[at],
|
||||
feedback: feedback
|
||||
? {
|
||||
rating: feedback.rating === 'up' ? 'up' : 'down',
|
||||
note: String(feedback.note || '').trim() || null,
|
||||
at: new Date().toISOString(),
|
||||
}
|
||||
/* Clearing is a real action — someone un-rating a thread should leave no
|
||||
rating behind rather than a neutral one. */
|
||||
: null,
|
||||
};
|
||||
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* The rating stays in memory for this session. */
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Forgets one conversation. */
|
||||
export function removeConversation(id) {
|
||||
const next = readHistory().filter((r) => r.id !== id);
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* Ignore — the caller re-reads either way. */
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Forgets everything. */
|
||||
export function clearHistory() {
|
||||
try {
|
||||
localStorage.removeItem(KEY);
|
||||
} catch {
|
||||
/* Ignore. */
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Conversations under a heading, in the order a person thinks about time.
|
||||
*
|
||||
* Empty groups are dropped rather than rendered as a heading with nothing
|
||||
* under it.
|
||||
*/
|
||||
export function groupByRecency(records, now = new Date()) {
|
||||
const startOfToday = new Date(now);
|
||||
startOfToday.setHours(0, 0, 0, 0);
|
||||
const startOfYesterday = new Date(startOfToday.getTime() - DAY);
|
||||
const startOfWeek = new Date(startOfToday.getTime() - 7 * DAY);
|
||||
|
||||
const groups = [
|
||||
{ id: 'today', label: 'Today', items: [] },
|
||||
{ id: 'yesterday', label: 'Yesterday', items: [] },
|
||||
{ id: 'week', label: 'Earlier this week', items: [] },
|
||||
{ id: 'older', label: 'Older', items: [] },
|
||||
];
|
||||
|
||||
for (const record of records) {
|
||||
const at = new Date(record.updatedAt || 0).getTime();
|
||||
if (at >= startOfToday.getTime()) groups[0].items.push(record);
|
||||
else if (at >= startOfYesterday.getTime()) groups[1].items.push(record);
|
||||
else if (at >= startOfWeek.getTime()) groups[2].items.push(record);
|
||||
else groups[3].items.push(record);
|
||||
}
|
||||
|
||||
return groups.filter((g) => g.items.length);
|
||||
}
|
||||
|
||||
/** A new conversation id. Time-ordered, so ids sort the way threads do. */
|
||||
export function newConversationId() {
|
||||
return `c_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
32
src/components/ai-assistant/index.jsx
Normal file
32
src/components/ai-assistant/index.jsx
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Owliver — the dashboard's contextual productivity panel.
|
||||
*
|
||||
* Named Owliver in the UI, but architecturally independent of the standalone
|
||||
* Owliver voice product in components/krow/Owliver*. Nothing is shared.
|
||||
*
|
||||
* Architecture:
|
||||
*
|
||||
* AssistantPanelProvider workspace state, resolved page context
|
||||
* AssistantPanel the workspace column the Admin layout renders
|
||||
* AssistantTrigger legacy opt-in button. Not used by Admin: Owliver is
|
||||
* part of the page there, so there is no trigger to
|
||||
* press. It self-hides on unsupported routes, which
|
||||
* is why the pre-redesign pages still holding one
|
||||
* render nothing.
|
||||
* KrowAssistant the one assistant UI, reused on every page
|
||||
* provider.js the transport seam — local today, HTTP later
|
||||
*
|
||||
* 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 {
|
||||
usePageAction, usePageContext, usePublishPageActions, usePublishPageContext,
|
||||
} from './PageContext';
|
||||
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 { createAgentProvider, createUnconfiguredProvider } from './provider';
|
||||
390
src/components/ai-assistant/insights.js
Normal file
390
src/components/ai-assistant/insights.js
Normal file
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { HIRED_STATUSES, atOrBeyond } from '@/lib/hiringRecords';
|
||||
import { isUnlocked } from '@/lib/provingGround';
|
||||
import { getScoreBand } from '@/lib/talentHome';
|
||||
import { PRIVILEGED_EVENTS, activitySignals } from '@/lib/activitySignals';
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* Events an auditor looks at first: they change who is employed or what is being
|
||||
* hired for. Defined in `lib/activitySignals.js` alongside the detection that
|
||||
* uses it, and re-exported here because the fact sheet, the Activity page's
|
||||
* severity column and the assistant's security answers all import it from this
|
||||
* module and must keep agreeing on what "privileged" means.
|
||||
*/
|
||||
export { PRIVILEGED_EVENTS };
|
||||
|
||||
/** `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,
|
||||
user = 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');
|
||||
/* The stage ladder, not a list of statuses.
|
||||
*
|
||||
* Two of the seven `application_status` values do not say what they look like
|
||||
* they say, and spelling the set out by hand got both wrong. `rejected` is
|
||||
* terminal from any stage and overwrites the stage it was reached from — but
|
||||
* somebody assessed that candidate, so the backend counts them as screened.
|
||||
* And `assigned` is a hire who has been rostered, which is why the server
|
||||
* counts headcount with `status IN ('hired','assigned')` everywhere.
|
||||
*
|
||||
* Listing the statuses inline meant `screened` silently omitted everyone
|
||||
* rejected and `hired` silently omitted everyone assigned, so the dashboard
|
||||
* under-reported both against the same records the server was counting.
|
||||
* `hiringRecords.js` already holds the ladder; this defers to it. */
|
||||
const screened = atOrBeyond(applications, 'ai_screened');
|
||||
const interviewing = byStatus('interview');
|
||||
const hired = applications.filter((a) => HIRED_STATUSES.includes(a.status));
|
||||
|
||||
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).getTime() - new Date(a.created_date).getTime()) / 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. */
|
||||
/* Screened and waiting on a decision — which includes the shortlisted, who
|
||||
are waiting on exactly that. Restricting this to `ai_screened` read as
|
||||
correct only while nothing was ever shortlisted: the first shortlisted
|
||||
candidate would have dropped silently out of the count. positionInsights
|
||||
draws the same set with ['ai_screened', 'shortlisted']. */
|
||||
const stalled = ranked.filter(
|
||||
(a) => a.ai_score >= 80 && ['ai_screened', 'shortlisted'].includes(a.status)
|
||||
);
|
||||
|
||||
const funnel = [
|
||||
{ key: 'applied', label: 'Applied', count: applications.length },
|
||||
{ key: 'ai_screened', label: 'AI Screened', count: screened.length },
|
||||
{ key: 'shortlisted', label: 'Shortlisted', count: atOrBeyond(applications, 'shortlisted').length },
|
||||
{ key: 'interview', label: 'Interviewed', count: atOrBeyond(applications, 'interview').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: atOrBeyond(apps, 'ai_screened').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;
|
||||
}, {});
|
||||
|
||||
/**
|
||||
* Out-of-pattern activity.
|
||||
*
|
||||
* Computed by `lib/activitySignals.js` rather than here, because it now has
|
||||
* a second reader: the `activity.signals` data source resolves it for a card
|
||||
* or an answer, and `dataResolver.js` cannot import from this directory
|
||||
* without a library reaching up into the component tree.
|
||||
*
|
||||
* Moved, not changed. "Two unusual patterns" has to mean the same two
|
||||
* everywhere it is said, and one function is the only way to guarantee that.
|
||||
*/
|
||||
const signals = activitySignals(activity, today);
|
||||
|
||||
/* ── KROW Forge ──────────────────────────────────────────────────────────
|
||||
Derived with the same gate (`isUnlocked`) and the same completion record the
|
||||
Forge page renders from, so the panel and the page cannot disagree about
|
||||
what is ready, what is blocked, or why. `profile` is the signed-in worker's
|
||||
record when one is already in cache; without it the counts are the library's
|
||||
rather than the person's, which is still true — just less personal. */
|
||||
const forgeCourses = courses.filter((c) => c.status === 'active');
|
||||
const forgeCompletedIds = new Set((profile?.completed_courses || []).map((c) => c.course_id));
|
||||
const forgeGate = (course) => isUnlocked(course, profile || {});
|
||||
const forgeUnfinished = forgeCourses.filter((c) => !forgeCompletedIds.has(c.id));
|
||||
const forgeReady = forgeUnfinished.filter((c) => forgeGate(c).unlocked);
|
||||
const forgeLocked = forgeUnfinished.filter((c) => !forgeGate(c).unlocked);
|
||||
const forge = {
|
||||
courses: forgeCourses,
|
||||
verified: profile?.earned_badges || [],
|
||||
completed: (profile?.completed_courses || []).filter((c) => c.status !== 'in_progress'),
|
||||
inProgress: (profile?.completed_courses || []).filter((c) => c.status === 'in_progress'),
|
||||
ready: forgeReady,
|
||||
locked: forgeLocked,
|
||||
/* The page features the first unfinished challenge whether or not it is
|
||||
unlocked, so the panel names the same one — a chip that recommends a
|
||||
different challenge than the one on screen reads as a second opinion. */
|
||||
next: forgeUnfinished[0] || null,
|
||||
xpAvailable: forgeUnfinished.reduce((sum, c) => sum + (c.xp || 0), 0),
|
||||
/* Every blocked challenge with the reasons that block it, in page order. */
|
||||
blocked: forgeLocked.map((course) => ({ course, reasons: forgeGate(course).reasons })),
|
||||
byDifficulty: ['beginner', 'intermediate', 'advanced'].map((level) => ({
|
||||
level,
|
||||
count: forgeCourses.filter((c) => c.difficulty === level).length,
|
||||
})).filter((d) => d.count),
|
||||
|
||||
/* ── The same library, read by the administrator who owns it ──────────
|
||||
Everything above answers "what can I prove"; everything below answers
|
||||
"what have we built, and how is the workforce doing with it". Both are
|
||||
derived from the same records, so the panel cannot disagree with the page
|
||||
whichever question is asked. Publication state is read with the same
|
||||
mapping the Forge page uses: `active` is Published. */
|
||||
library: courses,
|
||||
published: courses.filter((c) => c.status === 'active' || c.status === 'published'),
|
||||
drafts: courses.filter((c) => c.status === 'draft'),
|
||||
archived: courses.filter((c) => c.status === 'archived' || c.status === 'inactive'),
|
||||
withChallenge: courses.filter((c) => c.challenge?.type),
|
||||
withTraining: courses.filter((c) => c.training_outline?.length || c.quiz?.length),
|
||||
byCategory: [...courses.reduce((map, c) => {
|
||||
const key = c.category || 'Uncategorised';
|
||||
map.set(key, (map.get(key) || 0) + 1);
|
||||
return map;
|
||||
}, new Map())]
|
||||
.map(([category, count]) => ({ category, count }))
|
||||
.sort((a, b) => b.count - a.count),
|
||||
/* Verifications the workforce actually holds, counted off worker profiles
|
||||
rather than off the library — a badge is something a person has. */
|
||||
workforceVerified: profiles.reduce((sum, p) => {
|
||||
const ids = new Set(courses.map((c) => c.id));
|
||||
return sum + (p.earned_badges || []).filter((b) => ids.has(b.course_id)).length;
|
||||
}, 0),
|
||||
usage: courses.map((c) => ({
|
||||
course: c,
|
||||
completed: profiles.filter((p) => (p.completed_courses || []).some((x) => x.course_id === c.id)).length,
|
||||
verified: profiles.filter((p) => (p.earned_badges || []).some((b) => b.course_id === c.id)).length,
|
||||
})),
|
||||
};
|
||||
|
||||
/* ── Talent pool ─────────────────────────────────────────────────────────
|
||||
The same score bands the Talent Pool page labels profiles with. */
|
||||
const rankedProfiles = [...profiles].sort((a, b) => (b.krow_score || 0) - (a.krow_score || 0));
|
||||
const talent = {
|
||||
ranked: rankedProfiles,
|
||||
top: rankedProfiles.slice(0, 3),
|
||||
available: profiles.filter((p) => (p.availability || []).length > 0),
|
||||
unverified: profiles.filter(
|
||||
(p) => !(p.earned_badges || []).length
|
||||
&& !(p.capabilities || []).some((c) => c.status === 'verified')
|
||||
),
|
||||
experienced: profiles.filter((p) => (p.experience_years || 0) >= 3),
|
||||
avgScore: avg(profiles.filter((p) => p.krow_score > 0).map((p) => p.krow_score)),
|
||||
bands: Object.values(profiles.reduce((acc, p) => {
|
||||
const { label } = getScoreBand(p.krow_score || 0);
|
||||
acc[label] ||= { label, count: 0 };
|
||||
acc[label].count += 1;
|
||||
return acc;
|
||||
}, {})).sort((a, b) => b.count - a.count),
|
||||
};
|
||||
|
||||
/* ── Hired history ───────────────────────────────────────────────────────
|
||||
Staff joined to the application and posting they came from — the same join
|
||||
the Hired History page performs, so department, speed and score match. */
|
||||
const hires = staff.map((s) => {
|
||||
const app = applications.find((a) => a.id === s.application_id);
|
||||
const posting = postings.find((p) => p.id === s.job_posting_id);
|
||||
return {
|
||||
...s,
|
||||
department: posting?.role_category || '—',
|
||||
timeToHire: app
|
||||
? Math.max(1, Math.round(
|
||||
(new Date(app.updated_date).getTime() - new Date(app.created_date).getTime()) / DAY_MS
|
||||
))
|
||||
: null,
|
||||
score: s.ai_score || app?.ai_score || 0,
|
||||
};
|
||||
});
|
||||
|
||||
const groupHires = (key) => Object.values(hires.reduce((acc, h) => {
|
||||
const name = h[key] || 'Unspecified';
|
||||
acc[name] ||= { name, count: 0, scores: [], days: [], ratings: [] };
|
||||
acc[name].count += 1;
|
||||
if (h.score) acc[name].scores.push(h.score);
|
||||
if (h.timeToHire) acc[name].days.push(h.timeToHire);
|
||||
if (h.client_rating) acc[name].ratings.push(h.client_rating);
|
||||
return acc;
|
||||
}, {})).map((e) => ({
|
||||
name: e.name,
|
||||
count: e.count,
|
||||
avgScore: avg(e.scores),
|
||||
avgDays: avg(e.days),
|
||||
avgRating: e.ratings.length
|
||||
? Number((e.ratings.reduce((a, b) => a + b, 0) / e.ratings.length).toFixed(1))
|
||||
: null,
|
||||
})).sort((a, b) => b.count - a.count);
|
||||
|
||||
const hiring = {
|
||||
hires,
|
||||
byDepartment: groupHires('department'),
|
||||
byRoleTitle: groupHires('role'),
|
||||
unrated: hires.filter((h) => !h.client_rating),
|
||||
rated: hires.filter((h) => h.client_rating),
|
||||
avgQuality: avg(hires.map((h) => h.score).filter(Boolean)),
|
||||
avgSpeed: avg(hires.map((h) => h.timeToHire).filter(Boolean)),
|
||||
strongest: [...hires].sort((a, b) => (b.score || 0) - (a.score || 0))[0] || null,
|
||||
onboarding: hires.filter((h) => h.status === 'onboarding'),
|
||||
};
|
||||
|
||||
return {
|
||||
today,
|
||||
|
||||
/* The signed-in account — identity and preferences, for the pages that are
|
||||
about the operator rather than the workforce. */
|
||||
user,
|
||||
|
||||
/* 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),
|
||||
|
||||
/**
|
||||
* Candidates a human should look at more closely — either because there is
|
||||
* no score to judge them on, or because the record has a gap the score does
|
||||
* not reflect. Deliberately not "weak candidates": every one of these is
|
||||
* under-assessed rather than assessed and found wanting.
|
||||
*/
|
||||
needsDeeperReview: applications.filter((a) => {
|
||||
if (!(a.ai_score > 0)) return true;
|
||||
const flaggedInterview = interviews.some(
|
||||
(i) => i.application_id === a.id && (i.integrity_score ?? 100) < 100
|
||||
);
|
||||
return flaggedInterview
|
||||
|| !(a.certifications || []).length
|
||||
|| !a.years_experience;
|
||||
}),
|
||||
|
||||
/* Positions */
|
||||
postings,
|
||||
openPositions,
|
||||
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,
|
||||
activitySignals: signals,
|
||||
|
||||
/* Signed-in worker, and the pages built on these records */
|
||||
profile,
|
||||
courses,
|
||||
forge,
|
||||
talent,
|
||||
hiring,
|
||||
|
||||
/* 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');
|
||||
145
src/components/ai-assistant/placement.js
Normal file
145
src/components/ai-assistant/placement.js
Normal file
@@ -0,0 +1,145 @@
|
||||
import { canonicalize } from '@/lib/product';
|
||||
import { ASSISTANT_CONTEXTS } from './contexts';
|
||||
|
||||
/**
|
||||
* Where the Owliver panel appears.
|
||||
*
|
||||
* The assistant occupies a permanent column in the page layout, so enabling it
|
||||
* on a page is a layout decision, not a feature flag — which is exactly why the
|
||||
* decision lives in one table rather than being scattered across pages.
|
||||
*
|
||||
* TWO THINGS TO KNOW BEFORE EDITING THIS TABLE
|
||||
*
|
||||
* 1. The keys are **canonical** routes (`/admin/positions`), not the addresses
|
||||
* this app actually serves (`/positions`). That is not an oversight. The
|
||||
* same keys are read by `lib/skills/registry.js`, whose `pageKeyForRoute`
|
||||
* derives the page id the backend's `GET /owliver/suggestions?page=`
|
||||
* endpoint accepts — a closed vocabulary on the server (`control-center`,
|
||||
* `positions`, `create-position`, `candidates`, `hired-history`,
|
||||
* `talent-pool`, `analytics`, `activity`, `profile`, `settings`). Rewriting
|
||||
* these keys would mean rewriting the skill surface table and desynchronising
|
||||
* from a vocabulary this app does not own. `canonicalize` converts the
|
||||
* incoming pathname instead; see `lib/product.js`.
|
||||
*
|
||||
* 2. Matching is exact, not prefix-based, so `/candidates` cannot leak an
|
||||
* assistant onto `/candidates/:id`. Routes carrying a record id are listed
|
||||
* in `PLACEMENT_PATTERNS` below, deliberately and one at a time.
|
||||
*
|
||||
* Context ids are still `admin.*`. They are internal identifiers rather than
|
||||
* anything a user sees, they are what `dynamic.js` and `routing.js` key their
|
||||
* per-page behaviour on, and renaming them would be a rename across five files
|
||||
* for no behavioural gain.
|
||||
*/
|
||||
const PLACEMENT = {
|
||||
employer: {
|
||||
'/admin': 'admin.controlCenter',
|
||||
'/admin/positions': 'admin.positions',
|
||||
/* The authoring form. Owliver belongs here for the same reason it belongs
|
||||
on Positions: the questions asked while specifying a role — what do these
|
||||
weights score, what do comparable roles ask for — are answerable from the
|
||||
page and from what this workspace has already posted. */
|
||||
'/admin/positions/new': 'admin.createPosition',
|
||||
'/admin/candidates': 'admin.candidatesList',
|
||||
'/admin/talent-pool': 'admin.talentPool',
|
||||
'/admin/hired': 'admin.hiredHistory',
|
||||
'/admin/analytics': 'admin.analytics',
|
||||
'/admin/activity': 'admin.activity',
|
||||
/* Profile and Settings have no agent written for them, and that is not the
|
||||
same as having no assistant. Each carries the same panel, answering from
|
||||
the general agent. What they do not carry is operational data: no skill
|
||||
declares these surfaces, so a workforce question asked here is declined
|
||||
and pointed at the page holding the records. */
|
||||
'/admin/profile': 'admin.profile',
|
||||
'/admin/settings': 'admin.settings',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Pages that must never carry the assistant, listed explicitly so the intent is
|
||||
* documented rather than implied by omission.
|
||||
*/
|
||||
export const EXCLUDED_ROUTES = [
|
||||
/* Sign-in has no page data to reason about. Canonicalized, `/login` is
|
||||
`/admin/login` — see the note on the table above. */
|
||||
'/admin/login',
|
||||
/* The full candidate profile is one person's record, not a page-level
|
||||
question, and the page needs its whole width to show forty fields. */
|
||||
'/admin/candidates/:id',
|
||||
];
|
||||
|
||||
/**
|
||||
* The route → context table, exported so the skill registry can derive page
|
||||
* keys from it. One table, so a route added here is addressable by a skill
|
||||
* without a second list to keep in step.
|
||||
*/
|
||||
export const PLACEMENT_ROUTES = PLACEMENT.employer;
|
||||
|
||||
/**
|
||||
* Routes whose address carries a record id.
|
||||
*
|
||||
* The table above is matched exactly, which is deliberate and stays that way.
|
||||
* But a position is read at `/positions/<id>`, and no exact table can list an
|
||||
* address containing an id nobody has created yet.
|
||||
*
|
||||
* This is consulted **only after the exact lookup misses**, so every page in
|
||||
* the table above is unaffected by construction rather than by care.
|
||||
*
|
||||
* `positions/new` is excluded because it is a literal route listed exactly
|
||||
* above, and must never be read as a position whose id is "new".
|
||||
*/
|
||||
const PLACEMENT_PATTERNS = {
|
||||
employer: [
|
||||
{
|
||||
/* One path segment after `positions/`, and not a nested route beneath
|
||||
it. A position being read is exactly where "who should I hire for
|
||||
this" is a page-level question, so it answers as Positions does. */
|
||||
test: (pathname) => /^\/admin\/positions\/(?!new$)[^/]+$/.test(pathname),
|
||||
contextId: 'admin.positions',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* The dynamic routes, as literal examples.
|
||||
*
|
||||
* Exported so the registry and the checks can reason about a pattern without
|
||||
* re-implementing it. These are addresses the pattern genuinely matches.
|
||||
*/
|
||||
export const PLACEMENT_PATTERN_ROUTES = {
|
||||
'/admin/positions/:id': 'admin.positions',
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the context for a role and path, or `null` to render no assistant.
|
||||
*
|
||||
* `pathname` is an address as the browser has it; the tables are canonical.
|
||||
*/
|
||||
export function resolveAssistantContext(role, pathname) {
|
||||
const route = canonicalize(pathname);
|
||||
|
||||
if (EXCLUDED_ROUTES.includes(route)) return null;
|
||||
|
||||
/* The Owliver AI home is a conversational entry point rather than a data
|
||||
page of its own, so it borrows the Control Center's context: the same
|
||||
general workforce agent, the same `control-center` suggestions page. It is
|
||||
resolved here rather than in `PLACEMENT` on purpose — adding `/admin/home`
|
||||
to that table would give `skills/registry.js` a second route mapping to the
|
||||
control-center page key and the reduce there would pick the wrong one. This
|
||||
one line keeps the page-key machinery untouched. */
|
||||
if (route === '/admin/home') return ASSISTANT_CONTEXTS['admin.controlCenter'] ?? null;
|
||||
|
||||
/* Exact first, always. Every listed page resolves here and never reaches the
|
||||
patterns below. */
|
||||
const id = PLACEMENT[role]?.[route];
|
||||
if (id) return ASSISTANT_CONTEXTS[id] ?? null;
|
||||
|
||||
const pattern = (PLACEMENT_PATTERNS[role] || []).find((p) => p.test(route));
|
||||
return pattern ? ASSISTANT_CONTEXTS[pattern.contextId] ?? null : 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 }))
|
||||
);
|
||||
}
|
||||
449
src/components/ai-assistant/provider.js
Normal file
449
src/components/ai-assistant/provider.js
Normal file
@@ -0,0 +1,449 @@
|
||||
/**
|
||||
* Provider seam for the Owliver dashboard panel.
|
||||
*
|
||||
* The UI never generates an answer and never knows where one came from. It calls
|
||||
* `provider.stream(request)` and renders the snapshots. That single boundary is
|
||||
* 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
|
||||
* change to the component.
|
||||
*
|
||||
* ── Contract ────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* provider.id: string
|
||||
* provider.stream(request): AsyncIterable<Block[]>
|
||||
*
|
||||
* Each yielded value is the response *so far* as an array of blocks (see
|
||||
* blocks.js). Snapshots rather than deltas because a response is structured:
|
||||
* a table or a KPI row has no meaningful half-state, and the renderer stays a
|
||||
* pure function of the latest snapshot.
|
||||
*
|
||||
* 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 chip's prompt
|
||||
* facts: object, // dashboard fact sheet (see insights.js)
|
||||
* signal: AbortSignal, // aborts an in-flight response
|
||||
* }
|
||||
*/
|
||||
|
||||
import { confirmation, note } from './blocks';
|
||||
|
||||
/**
|
||||
* The agent provider: the real runtime, over the real API.
|
||||
*
|
||||
* The only provider that answers. Everything that makes that safe lives on the
|
||||
* server:
|
||||
*
|
||||
* - **The principal is the session's.** The body carries a question and
|
||||
* nothing else about who is asking. The browser cannot name a caller, so it
|
||||
* cannot ask about records it is not entitled to see.
|
||||
* - **The tools and corpora are the SPEC's.** Not the request's. An agent
|
||||
* reads what its published definition says it may read, and no field here
|
||||
* can widen that.
|
||||
* - **A write cannot happen from a question.** The backend answers a proposed
|
||||
* write with a confirmation payload and performs nothing. Approving it is a
|
||||
* second, explicit call carrying the token — see `confirmation` below.
|
||||
*
|
||||
* One snapshot, not a stream. The endpoint is not streaming yet, so this yields
|
||||
* the finished answer once. The signature is the streaming one because that is
|
||||
* what the seam is, and switching to real streaming later changes this function
|
||||
* and nothing else.
|
||||
*/
|
||||
export function createAgentProvider({ baseUrl = '/api/v1' } = {}) {
|
||||
return {
|
||||
id: 'agent',
|
||||
|
||||
async *stream({ question, agent = null, confirmation = null, agentVersion = 0, signal }) {
|
||||
/* No agent, no run. The panel resolves which agent covers the page before
|
||||
calling; reaching here without one means the routing layer changed and
|
||||
this should say so rather than guess at an agent id. */
|
||||
if (!agent?.id) {
|
||||
yield [note('No agent is available for this page.')];
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}/agents/${encodeURIComponent(agent.id)}/runs`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
/* Ask for a stream. The server answers the same run either way — the
|
||||
final event carries exactly the body the non-streaming path
|
||||
returns — so a deployment that cannot stream degrades to one late
|
||||
snapshot rather than to a broken panel. */
|
||||
Accept: 'text/event-stream, application/json',
|
||||
},
|
||||
/* The session cookie. Without it the API answers 401, which is the
|
||||
correct answer to a browser that is not signed in. */
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
input: question,
|
||||
confirmation: confirmation || undefined,
|
||||
/* Pins the conversation to the version it started with. Every answer
|
||||
comes back carrying its version; sending it on the next turn is what
|
||||
stops an edit published mid-thread from silently changing which
|
||||
agent is answering. */
|
||||
agentVersion: agentVersion || undefined,
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
yield [note(await describeFailure(response))];
|
||||
return;
|
||||
}
|
||||
|
||||
/* Not a stream after all — a proxy that buffers, or a server answering
|
||||
JSON. Read it whole. */
|
||||
if (!isEventStream(response) || !response.body) {
|
||||
yield toBlocks(await response.json());
|
||||
return;
|
||||
}
|
||||
|
||||
yield* readRunStream(response, signal);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the server actually opened a stream, rather than answering JSON.
|
||||
*
|
||||
* Defensive about `headers` because the answer to "is this a stream" must be
|
||||
* NO when anything is unexpected. A response shape this does not recognise gets
|
||||
* read whole, which works; assuming a stream and finding none would hang.
|
||||
*/
|
||||
function isEventStream(response) {
|
||||
const type = response?.headers?.get?.('Content-Type') || '';
|
||||
return type.includes('text/event-stream');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the run's event stream, yielding the answer as it grows.
|
||||
*
|
||||
* Two kinds of event and they are handled very differently:
|
||||
*
|
||||
* - `delta` is a fragment of assistant text. The accumulated text is
|
||||
* re-parsed into blocks on every one, so a half-written answer renders as
|
||||
* far as it makes sense to — a table mid-construction stays as text until
|
||||
* its rows arrive, which reads better than a broken table.
|
||||
* - `run` is the finished result: the same body the non-streaming path
|
||||
* returns, carrying confirmations, the termination and the token cost.
|
||||
* Whatever text streamed is replaced by it, because the final snapshot is
|
||||
* authoritative and the deltas were a preview of it.
|
||||
*
|
||||
* A client that ignored every delta and read only the last event would be in
|
||||
* exactly the state it would have reached without streaming. That is what keeps
|
||||
* the two paths honest rather than merely similar.
|
||||
*/
|
||||
async function* readRunStream(response, signal) {
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
let buffer = '';
|
||||
let text = '';
|
||||
let final = null;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
/* The last element may be half a line; hold 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;
|
||||
|
||||
let event;
|
||||
try {
|
||||
event = JSON.parse(payload);
|
||||
} catch {
|
||||
/* A malformed frame is dropped rather than rendered. It cannot be
|
||||
assistant text — the server encodes every event as JSON — so
|
||||
showing it would put transport noise in front of a reader. */
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof event.delta === 'string') {
|
||||
text += event.delta;
|
||||
yield markdownToBlocks(text);
|
||||
continue;
|
||||
}
|
||||
if (event.run) final = event.run;
|
||||
if (event.error) {
|
||||
yield [note(event.error.message || 'The agent could not be reached.')];
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (signal?.aborted) break;
|
||||
}
|
||||
} finally {
|
||||
/* Releasing matters on an abort: a reader still holding the body keeps the
|
||||
connection open, and a user who pressed Stop expects it to stop. */
|
||||
try { reader.cancel(); } catch { /* already closed */ }
|
||||
}
|
||||
|
||||
if (final) {
|
||||
yield toBlocks(final);
|
||||
return;
|
||||
}
|
||||
/* The stream ended without a final event — the run was aborted, or the
|
||||
connection dropped mid-answer. Whatever arrived is kept: a half-read answer
|
||||
is worth more to the reader than an empty panel. */
|
||||
if (text) yield markdownToBlocks(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a run result into blocks.
|
||||
*
|
||||
* The termination decides the shape, and every one of the six produces
|
||||
* something a person can act on. A run that ended without completing is not an
|
||||
* error to swallow: it has an answer-so-far worth keeping and a reason worth
|
||||
* reading.
|
||||
*/
|
||||
function toBlocks(run) {
|
||||
const blocks = [];
|
||||
|
||||
if (run.output) blocks.push(...markdownToBlocks(run.output));
|
||||
|
||||
/* Pending writes before the trailing note: somebody scrolling to the bottom
|
||||
should meet the decision, not a footnote about token cost. */
|
||||
for (const c of run.confirmations || []) {
|
||||
blocks.push(confirmation(c));
|
||||
}
|
||||
|
||||
/* The surface layer's wording for a run that did not complete. Rendered as a
|
||||
note rather than as prose, so it reads as the system speaking rather than
|
||||
as the agent's own words. */
|
||||
if (run.message) blocks.push(note(run.message));
|
||||
|
||||
if (!blocks.length) {
|
||||
blocks.push(note('The agent finished without saying anything.'));
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a model's markdown into the block vocabulary the panel already renders.
|
||||
*
|
||||
* A real model writes markdown — headings, numbered steps, tables. The local
|
||||
* simulator never did: it emitted short single paragraphs, so the text renderer
|
||||
* only ever handled inline bold and italic. Point the panel at a real model and
|
||||
* `## What I'd do, in order` arrives on screen with the hashes still attached,
|
||||
* and a comparison table arrives as pipes.
|
||||
*
|
||||
* The fix is NOT to render markdown inside a text block. The panel already has
|
||||
* a heading block, a list block and a table block, all styled with the same
|
||||
* tokens as the dashboard cards beside them — so the honest move is to parse
|
||||
* into those, and let a generated answer look like it belongs to Krow rather
|
||||
* than like a chat window that happens to be embedded in it.
|
||||
*
|
||||
* Deliberately a small parser and not a markdown library. Four constructs is
|
||||
* what a model actually produces in an answer; anything else falls through as a
|
||||
* paragraph, which reads correctly even when it is not styled richly. A full
|
||||
* parser would be a large dependency in exchange for handling footnotes nobody
|
||||
* writes.
|
||||
*/
|
||||
export function markdownToBlocks(markdown) {
|
||||
const lines = String(markdown).replace(/\r\n/g, '\n').split('\n');
|
||||
const blocks = [];
|
||||
let paragraph = [];
|
||||
let listItems = null;
|
||||
let ordered = false;
|
||||
|
||||
const flushParagraph = () => {
|
||||
const text = paragraph.join(' ').trim();
|
||||
paragraph = [];
|
||||
if (text) blocks.push({ type: 'text', text });
|
||||
};
|
||||
const flushList = () => {
|
||||
if (listItems?.length) blocks.push({ type: 'list', items: listItems, ordered });
|
||||
listItems = null;
|
||||
};
|
||||
const flushAll = () => { flushParagraph(); flushList(); };
|
||||
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const line = lines[i];
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (!trimmed) { flushAll(); continue; }
|
||||
|
||||
/* A heading. The level is dropped: this panel has one heading style, and
|
||||
inventing three would give a 380px column a hierarchy it cannot show. */
|
||||
const heading = /^(#{1,6})\s+(.+)$/.exec(trimmed);
|
||||
if (heading) {
|
||||
flushAll();
|
||||
blocks.push({ type: 'heading', text: stripInline(heading[2]) });
|
||||
continue;
|
||||
}
|
||||
|
||||
/* A table: a pipe row followed by a separator row. Checked together,
|
||||
because a single pipe row is far more likely to be prose. */
|
||||
if (trimmed.startsWith('|') && isSeparatorRow(lines[i + 1])) {
|
||||
flushAll();
|
||||
const { block, next } = parseTable(lines, i);
|
||||
if (block) { blocks.push(block); i = next; continue; }
|
||||
}
|
||||
|
||||
const bullet = /^[-*]\s+(.+)$/.exec(trimmed);
|
||||
const numbered = /^\d+[.)]\s+(.+)$/.exec(trimmed);
|
||||
if (bullet || numbered) {
|
||||
const wantOrdered = Boolean(numbered);
|
||||
/* A list that changes kind mid-way is two lists. */
|
||||
if (listItems && ordered !== wantOrdered) flushList();
|
||||
flushParagraph();
|
||||
ordered = wantOrdered;
|
||||
listItems = listItems || [];
|
||||
listItems.push((bullet || numbered)[1].trim());
|
||||
continue;
|
||||
}
|
||||
|
||||
flushList();
|
||||
paragraph.push(trimmed);
|
||||
}
|
||||
flushAll();
|
||||
|
||||
return blocks.length ? blocks : [{ type: 'text', text: String(markdown).trim() }];
|
||||
}
|
||||
|
||||
/** `|---|---:|` — the row that makes the one above it a header. */
|
||||
function isSeparatorRow(line) {
|
||||
return Boolean(line && /^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(line) && line.includes('-'));
|
||||
}
|
||||
|
||||
function splitRow(line) {
|
||||
return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a markdown table starting at `start`.
|
||||
*
|
||||
* Rows with the wrong number of cells are padded or trimmed rather than
|
||||
* dropped. A model occasionally miscounts a pipe, and losing a whole row of an
|
||||
* answer over a formatting slip is worse than showing an empty cell — which the
|
||||
* renderer already draws as an em dash.
|
||||
*/
|
||||
function parseTable(lines, start) {
|
||||
const header = splitRow(lines[start]);
|
||||
const columns = header.map((label, i) => ({ key: `c${i}`, label: stripInline(label) }));
|
||||
|
||||
const rows = [];
|
||||
let i = start + 2;
|
||||
for (; i < lines.length; i += 1) {
|
||||
const line = lines[i];
|
||||
if (!line.trim().startsWith('|')) break;
|
||||
const cells = splitRow(line);
|
||||
const row = {};
|
||||
columns.forEach((col, n) => { row[col.key] = stripInline(cells[n] ?? ''); });
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
if (!rows.length) return { block: null, next: start };
|
||||
return { block: { type: 'table', columns, rows }, next: i - 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes markdown a cell or heading cannot show.
|
||||
*
|
||||
* Table cells and headings are rendered as plain strings by their components,
|
||||
* so `**Maria**` would appear with the asterisks. Paragraphs and list items are
|
||||
* left alone — those go through `Inline`, which renders bold properly.
|
||||
*/
|
||||
function stripInline(value) {
|
||||
return String(value).replace(/\*\*(.+?)\*\*/g, '$1').replace(/`(.+?)`/g, '$1').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* A failed request, in one sentence a person can act on.
|
||||
*
|
||||
* The status is what distinguishes the cases that matter, and they are
|
||||
* genuinely different actions: sign in again, ask someone for access, or wait.
|
||||
* Flattening them into "something went wrong" makes the user's next move a
|
||||
* guess.
|
||||
*/
|
||||
async function describeFailure(response) {
|
||||
let detail = '';
|
||||
try {
|
||||
const body = await response.json();
|
||||
detail = body?.error?.message || '';
|
||||
} catch {
|
||||
/* A non-JSON error body is a proxy or a gateway, not this API. The status
|
||||
still says enough. */
|
||||
}
|
||||
|
||||
switch (response.status) {
|
||||
case 401:
|
||||
return 'Your session has expired. Sign in again to keep asking.';
|
||||
case 404:
|
||||
/* Deliberately the same answer for "no such agent" and "not yours" — the
|
||||
API refuses to distinguish them, and repeating the distinction here
|
||||
would undo that. */
|
||||
return 'That agent is not available on this workspace.';
|
||||
case 422:
|
||||
return detail || 'That agent cannot run right now.';
|
||||
case 429:
|
||||
return 'Too many requests just now. Try again in a moment.';
|
||||
default:
|
||||
return detail || 'The agent could not be reached. Try again in a moment.';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider the app uses.
|
||||
*
|
||||
* There used to be three: a local simulator that computed answers from data
|
||||
* already in the browser, a streaming HTTP provider for a deployment that had
|
||||
* one, and the agent. The simulator is gone, and its removal is the point of
|
||||
* this file's current shape.
|
||||
*
|
||||
* WHY IT WENT
|
||||
*
|
||||
* Two answering paths behind one avatar meant the same question got different
|
||||
* answers depending on phrasing — "assign the strongest free worker" matched a
|
||||
* template and reported nobody was available, while "put the best free worker
|
||||
* on" reached the agent, which found somebody and proposed them. A user cannot
|
||||
* be expected to know which sentence talks to which system, and a product where
|
||||
* the wording decides the answer is a demo with good manners.
|
||||
*
|
||||
* WHAT IT COST, SAID PLAINLY
|
||||
*
|
||||
* The simulator was instant, free, and could not be wrong about a figure — it
|
||||
* read the same cache the page rendered from. The agent takes ten to twenty
|
||||
* seconds and costs tokens. That is a real regression on speed, accepted in
|
||||
* exchange for answers that can be followed up, cannot be beaten by a synonym,
|
||||
* and can act on what they find.
|
||||
*
|
||||
* AN UNCONFIGURED DEPLOYMENT NOW SAYS SO
|
||||
*
|
||||
* With no VITE_AGENT_API there is nothing to fall back to. That state is
|
||||
* explicit rather than silent: every question is answered with the reason,
|
||||
* because a panel that quietly does nothing is the worst of the three
|
||||
* possibilities and the hardest to diagnose.
|
||||
*/
|
||||
export function createAssistantProvider() {
|
||||
const base = import.meta.env?.VITE_AGENT_API;
|
||||
return base ? createAgentProvider({ baseUrl: base }) : createUnconfiguredProvider();
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider for a deployment with no agent configured.
|
||||
*
|
||||
* Answers every question with the same sentence, which is the honest thing to
|
||||
* do: nothing here can answer, and pretending otherwise is what the simulator
|
||||
* was doing.
|
||||
*/
|
||||
export function createUnconfiguredProvider() {
|
||||
return {
|
||||
id: 'unconfigured',
|
||||
|
||||
async *stream() {
|
||||
yield [note(
|
||||
'Owliver is not configured on this deployment. Set VITE_AGENT_API and give the '
|
||||
+ 'backend a model credential, and this panel will answer from your workspace.'
|
||||
)];
|
||||
},
|
||||
};
|
||||
}
|
||||
1013
src/components/ai-assistant/routing.js
Normal file
1013
src/components/ai-assistant/routing.js
Normal file
File diff suppressed because it is too large
Load Diff
985
src/components/ai-assistant/useAssistant.js
Normal file
985
src/components/ai-assistant/useAssistant.js
Normal file
@@ -0,0 +1,985 @@
|
||||
import * as React from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { krow } from '@/api/krowClient';
|
||||
import {
|
||||
useApplications, useCourses, useInterviews, useJobPostings, useStaff,
|
||||
useUserActivity, useWorkerProfile, useWorkerProfiles,
|
||||
} from '@/lib/krowHooks';
|
||||
import { skillsForContext } from '@/lib/skills/registry';
|
||||
import {
|
||||
advancePositionFlow, createdFollowUp, positionCreatedReply, positionFailedReply,
|
||||
} from '@/lib/skills/positionFlow';
|
||||
import {
|
||||
descriptionFailedReply, descriptionReply, draftActions, publishFailedReply, publishedFollowUp,
|
||||
publishedReply, weightsSetReply, weightsUnchangedReply,
|
||||
} from '@/lib/skills/draftFlow';
|
||||
import {
|
||||
assignmentDone, assignmentFailed, assignmentFollowUp, headcountFailed, headcountSet,
|
||||
interviewDone, interviewFailed,
|
||||
} from '@/lib/skills/workforceFlow';
|
||||
import {
|
||||
newConversationId, readHistory, recordFeedback, removeConversation, saveConversation,
|
||||
} from './history';
|
||||
import { storableContext } from '@/lib/agents/context';
|
||||
import { buildFacts } from './insights';
|
||||
import { agentRequest } from '@/lib/agents/runtime';
|
||||
import { createAssistantProvider } from './provider';
|
||||
import { preferAgent, resolveIntent } from './routing';
|
||||
import { doc, text as textBlock, toSnapshots } from './blocks';
|
||||
|
||||
/** One provider instance for the app's lifetime. */
|
||||
const provider = createAssistantProvider();
|
||||
|
||||
/**
|
||||
* Whether a real agent is answering, as opposed to the local simulator.
|
||||
*
|
||||
* Read once, from the provider the app actually built. Not a separate flag: a
|
||||
* second switch could disagree with the first, and "the panel thought it had an
|
||||
* agent and did not" is a failure mode with no visible symptom beyond worse
|
||||
* answers.
|
||||
*/
|
||||
const agentBacked = provider.id === 'agent';
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
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();
|
||||
|
||||
// The signed-in worker's own record, read from cache only: `enabled: false`
|
||||
// means this never runs the query, which would *create* a WorkerProfile for an
|
||||
// admin who has never opened Forge. So the Forge context is personal on pages
|
||||
// that already loaded it, and falls back to library-level facts elsewhere
|
||||
// rather than manufacturing a record as a side effect of opening a panel.
|
||||
const { data: profile = null } = useWorkerProfile({ enabled: false });
|
||||
|
||||
/* The signed-in account, from the shared `['user']` cache the header and the
|
||||
Profile page already read. */
|
||||
const { data: user = null } = useQuery({
|
||||
queryKey: ['user'],
|
||||
queryFn: () => krow.auth.me().catch(() => null),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
return React.useMemo(
|
||||
() => buildFacts({ applications, postings, interviews, staff, profiles, activity, courses, profile, user }),
|
||||
[applications, postings, interviews, staff, profiles, activity, courses, profile, user]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The signed-in user's first name, for personalizing the greeting.
|
||||
*
|
||||
* Shares the `['user']` query key with the rest of the app, so this is a cache
|
||||
* read rather than another request.
|
||||
*/
|
||||
export function useCurrentUserName() {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['user'],
|
||||
queryFn: () => krow.auth.me().catch(() => null),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
return data?.full_name || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams a locally built document through the same snapshot pipeline the
|
||||
* provider uses, so a routing reply arrives exactly like any other answer
|
||||
* rather than appearing instantly and reading as a different system.
|
||||
*/
|
||||
async function streamDocument({ document, signal, onFrame, onDone }) {
|
||||
const snapshots = toSnapshots(document);
|
||||
const complete = snapshots[snapshots.length - 1] || [];
|
||||
|
||||
let latest = [];
|
||||
let interrupted = false;
|
||||
|
||||
for (const snapshot of snapshots) {
|
||||
if (signal?.aborted) { interrupted = true; break; }
|
||||
latest = snapshot;
|
||||
onFrame(snapshot);
|
||||
await new Promise((resolve) => setTimeout(resolve, 26));
|
||||
}
|
||||
|
||||
/**
|
||||
* An interrupted local document still settles complete.
|
||||
*
|
||||
* These documents are already fully computed before the first frame — the
|
||||
* reveal is presentation, not generation. So an interruption should not leave
|
||||
* a half-written sentence in the thread the way stopping a real generation
|
||||
* would: there is nothing partial about the answer, only about the animation.
|
||||
*
|
||||
* This matters most after a write. The mutation refreshes every query it
|
||||
* touched, the panel re-renders on the new data, and the reveal can be cut
|
||||
* short — leaving the confirmation of something that definitely happened
|
||||
* truncated mid-word.
|
||||
*/
|
||||
if (interrupted) onDone(complete);
|
||||
else if (latest.length) onDone(latest);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Create Position form, as an address.
|
||||
*
|
||||
* Named once because two things have to agree about it: nothing stored may
|
||||
* offer a way back into the authoring form after a position has been saved.
|
||||
*/
|
||||
const AUTHORING_ROUTE = '/admin/positions/new';
|
||||
|
||||
/**
|
||||
* The one wording every draft-continuation control resolves to.
|
||||
*
|
||||
* Named once because two things have to agree about it: the chip a suggestion
|
||||
* writes today, and the chip a thread wrote months ago. Both must reach the
|
||||
* same in-panel handler, so both are normalized to this phrasing rather than
|
||||
* one being special-cased.
|
||||
*/
|
||||
const CONTINUE_DRAFT = /^continue the (.+) draft$/i;
|
||||
|
||||
/** The wording older builds used for the drafts question. */
|
||||
const OLD_DRAFTS_PROMPT = /which positions are still in draft/i;
|
||||
|
||||
/**
|
||||
* A stored chip, brought up to the current draft behavior.
|
||||
*
|
||||
* Threads persist as the blocks and chips they rendered, so a conversation
|
||||
* written before draft continuation moved into the panel still carries the
|
||||
* controls of that era: a `capability` that runs the read-only drafts report,
|
||||
* or a prompt phrased so loosely that keyword routing sent it to the authoring
|
||||
* form. Rewriting them in code was not enough — the ones already written down
|
||||
* had to change behavior too.
|
||||
*
|
||||
* Deliberately narrow. Only chips that are *about continuing a draft* are
|
||||
* touched, and only their routing metadata: the label the reader saw is left
|
||||
* exactly as it was, so a thread reads as it always did and simply does the
|
||||
* right thing when clicked.
|
||||
*/
|
||||
function normalizeDraftChip(chip) {
|
||||
if (!chip) return chip;
|
||||
|
||||
const label = String(chip.label || '');
|
||||
const prompt = String(chip.prompt || '');
|
||||
|
||||
/* "Continue the Bartender draft", however it was stored: the label is the
|
||||
question, and no capability or route may intercept it. */
|
||||
if (CONTINUE_DRAFT.test(label) || CONTINUE_DRAFT.test(prompt)) {
|
||||
const { capability: _capability, route: _route, ...rest } = chip;
|
||||
return { ...rest, prompt: CONTINUE_DRAFT.test(label) ? label : prompt };
|
||||
}
|
||||
|
||||
/* The old drafts question ran a capability that could only describe drafts
|
||||
and told the reader to open the form. The same words now resolve to the
|
||||
draft flow, which continues one or offers the several by name. */
|
||||
if (OLD_DRAFTS_PROMPT.test(prompt) || chip.capability === 'position-drafts') {
|
||||
const { capability: _capability, route: _route, ...rest } = chip;
|
||||
return { ...rest, prompt: prompt || 'Which positions are still in draft?' };
|
||||
}
|
||||
|
||||
return chip;
|
||||
}
|
||||
|
||||
/**
|
||||
* A stored thread, with any completed-then-reopen action stripped out.
|
||||
*
|
||||
* A reply is persisted as the blocks it rendered, so a thread written before
|
||||
* this was fixed still carries the old "Continue to save" — a live control
|
||||
* routing back into Create Position, offered on a position that was saved long
|
||||
* ago. Removing it in code was not enough; the ones already written down had to
|
||||
* stop working too.
|
||||
*
|
||||
* Deliberately narrow: only actions pointing at the authoring form are dropped,
|
||||
* and only from stored threads. Every other block, chip and link is left as it
|
||||
* was written, so a thread reads exactly as it did apart from the one control
|
||||
* that should never have been there.
|
||||
*/
|
||||
function withoutAuthoringActions(messages = []) {
|
||||
const leadsToForm = (target) => String(target || '').includes(AUTHORING_ROUTE);
|
||||
|
||||
return messages.map((message) => {
|
||||
if (message.role !== 'assistant') return message;
|
||||
|
||||
const followUp = message.followUp
|
||||
?.filter((chip) => !leadsToForm(chip.route))
|
||||
.map(normalizeDraftChip);
|
||||
const blocks = message.blocks?.map((block) => (
|
||||
block?.type === 'insights'
|
||||
? {
|
||||
...block,
|
||||
items: block.items.map(({ action, ...item }) => (
|
||||
leadsToForm(action?.to) ? item : { ...item, ...(action ? { action } : null) }
|
||||
)),
|
||||
}
|
||||
: block
|
||||
));
|
||||
|
||||
return { ...message, ...(followUp ? { followUp } : null), ...(blocks ? { blocks } : null) };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* `onNavigate` is called when a question belongs to another Admin page. Both
|
||||
* entry points — a suggestion chip and a typed question — go through `send`, so
|
||||
* routing applies to both without either knowing it exists.
|
||||
*/
|
||||
export function useConversation({
|
||||
contextId, facts, onNavigate, onAction, onCreatePosition, onRefreshSuggestions,
|
||||
onAssignWorkers, onScheduleInterview,
|
||||
/* Finishing a draft: the same two mutations the Create Position form calls.
|
||||
Passed in rather than reached for, so this layer still writes nothing
|
||||
itself and there is one update path for a position. */
|
||||
onUpdatePosition, onGenerateDescription,
|
||||
workforce = null, disabledSkills = [], customSkills = [],
|
||||
roles = [], skillCategories = [], courses = [], skillContext = null,
|
||||
/**
|
||||
* The active agent and where the reader is.
|
||||
*
|
||||
* Both optional. `disabledSkills` already arrives carrying the agent's
|
||||
* scoping — the caller applies `agentScopedDisabled` before handing it over —
|
||||
* so nothing here decides what may be read. These are for attribution: which
|
||||
* agent answered, and whether it belongs on this page.
|
||||
*/
|
||||
agent = null, agentCoversPage = true, agentSuggestion = null, owliverContext = null,
|
||||
/* The page's own name, recorded with an archived thread so History can say
|
||||
where a conversation happened without resolving the context again. */
|
||||
pageLabel = '',
|
||||
}) {
|
||||
/**
|
||||
* One thread per agent per page.
|
||||
*
|
||||
* Switching agent starts a new conversation rather than continuing the last
|
||||
* one under a different name: the answers in a thread were produced by a
|
||||
* particular agent's scope, and appending a differently-scoped reply to them
|
||||
* would make the thread a record of something that never happened.
|
||||
*
|
||||
* With no agent the key is exactly what it was, so an existing stored thread
|
||||
* is still found.
|
||||
*/
|
||||
const storageKey = agent?.id
|
||||
? `krow_assistant:${agent.id}:${contextId}`
|
||||
: `krow_assistant:${contextId}`;
|
||||
const flowKey = `${storageKey}:flow`;
|
||||
const idKey = `${storageKey}:id`;
|
||||
|
||||
const [messages, setMessages] = React.useState([]);
|
||||
const [pending, setPending] = React.useState(null); // { blocks, thinking }
|
||||
const [error, setError] = React.useState(null);
|
||||
const abortRef = React.useRef(null);
|
||||
|
||||
/* The archive, held in state so opening History does not have to re-read
|
||||
storage on every render, and so a new turn moves its own thread to the top
|
||||
of the list while it is on screen. */
|
||||
const [history, setHistory] = React.useState(() => readHistory());
|
||||
|
||||
/**
|
||||
* Which conversation this is.
|
||||
*
|
||||
* The thread already persisted per page; what it lacked was an identity, so
|
||||
* every new conversation overwrote the last. This id is what an archived
|
||||
* record is keyed by — kept in sessionStorage beside the thread it belongs
|
||||
* to, so a reload continues the same conversation rather than forking it.
|
||||
*/
|
||||
const conversationRef = React.useRef(null);
|
||||
|
||||
/* A guided skill in progress — which question it is on and what it has
|
||||
collected. Kept beside the thread rather than inside it, and persisted for
|
||||
the same reason the thread is: leaving Positions and coming back should not
|
||||
silently abandon a position half-described. */
|
||||
const flowRef = React.useRef(null);
|
||||
|
||||
/**
|
||||
* What this conversation actually used.
|
||||
*
|
||||
* Accumulated as turns run, never predicted from what was available. A skill
|
||||
* lands here when it *answered*; a tool when `runAction` was asked to perform
|
||||
* it. That distinction is the whole value of the record — a list of what the
|
||||
* page offered would describe the registry, and the insight figures built on
|
||||
* it would describe the registry too.
|
||||
*
|
||||
* A ref rather than state: nothing re-renders when it changes, and it is read
|
||||
* only at the moment a thread is written.
|
||||
*/
|
||||
const usedRef = React.useRef({ skills: [], tools: [], knowledge: [] });
|
||||
|
||||
const noteUsed = React.useCallback((kind, id) => {
|
||||
if (!id) return;
|
||||
const bucket = usedRef.current[kind];
|
||||
if (bucket && !bucket.includes(id)) bucket.push(id);
|
||||
}, []);
|
||||
|
||||
const setFlow = React.useCallback((flow) => {
|
||||
flowRef.current = flow || null;
|
||||
try {
|
||||
if (flow) sessionStorage.setItem(flowKey, JSON.stringify(flow));
|
||||
else sessionStorage.removeItem(flowKey);
|
||||
} catch {
|
||||
// The flow stays in memory only.
|
||||
}
|
||||
}, [flowKey]);
|
||||
|
||||
/* Load the stored thread, and reload when the context changes. */
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(storageKey);
|
||||
setMessages(raw ? withoutAuthoringActions(JSON.parse(raw)) : []);
|
||||
} catch {
|
||||
setMessages([]);
|
||||
}
|
||||
try {
|
||||
conversationRef.current = sessionStorage.getItem(idKey) || null;
|
||||
} catch {
|
||||
conversationRef.current = null;
|
||||
}
|
||||
try {
|
||||
const raw = sessionStorage.getItem(flowKey);
|
||||
flowRef.current = raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
flowRef.current = null;
|
||||
}
|
||||
setPending(null);
|
||||
setError(null);
|
||||
/* A different page or agent is a different conversation, so what the last
|
||||
one used does not carry over. */
|
||||
usedRef.current = { skills: [], tools: [], knowledge: [] };
|
||||
}, [storageKey, flowKey, idKey]);
|
||||
|
||||
/**
|
||||
* Writes the thread, and records it.
|
||||
*
|
||||
* One function rather than two calls at each site: a thread that is persisted
|
||||
* but not archived is a conversation History cannot show, and every path that
|
||||
* adds a turn goes through here. The conversation is given its id on its
|
||||
* first turn, so an untouched panel leaves nothing behind.
|
||||
*/
|
||||
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.
|
||||
}
|
||||
|
||||
if (!next.length) return;
|
||||
|
||||
if (!conversationRef.current) {
|
||||
conversationRef.current = newConversationId();
|
||||
try {
|
||||
sessionStorage.setItem(idKey, conversationRef.current);
|
||||
} catch {
|
||||
// The id stays in memory: this conversation archives under one id for
|
||||
// as long as the panel is mounted, which is the common case.
|
||||
}
|
||||
}
|
||||
|
||||
setHistory(saveConversation({
|
||||
id: conversationRef.current,
|
||||
contextId,
|
||||
page: pageLabel,
|
||||
messages: next,
|
||||
agentId: agent?.id ?? null,
|
||||
/* Where the question was asked, reduced — see `storableContext`. */
|
||||
pageContext: owliverContext ? storableContext(owliverContext) : null,
|
||||
skillsUsed: usedRef.current.skills,
|
||||
toolsUsed: usedRef.current.tools,
|
||||
knowledgeUsed: usedRef.current.knowledge,
|
||||
}));
|
||||
}, [storageKey, idKey, contextId, pageLabel, agent, owliverContext]);
|
||||
|
||||
/* 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;
|
||||
|
||||
/**
|
||||
* Asks something.
|
||||
*
|
||||
* `scope` runs this one turn against a *different* page and a different set
|
||||
* of reachable skills, and exists for exactly one caller: testing a
|
||||
* capability from the agent editor. The reader is standing on the
|
||||
* configuration screen, so the panel's own context is the workspace — asking
|
||||
* "what happened today?" there would resolve against the workspace's skills
|
||||
* and prove nothing about the Activity capability being configured.
|
||||
*
|
||||
* It is an override of *which page this question is about*, never of what may
|
||||
* be read: `resolveIntent` and the provider apply the same page rules to the
|
||||
* substituted context that they apply to a real one, so a test cannot reach a
|
||||
* record the real page would not have offered. The turn lands in the ordinary
|
||||
* thread, is persisted and archived like any other, and nothing about it is
|
||||
* simulated — it is the live pipeline pointed at another surface.
|
||||
*/
|
||||
/* The most recent question, for the confirmation path. A ref rather than
|
||||
state: nothing renders from it, and making it state would re-render the
|
||||
whole panel on every keystroke-completed turn for no visible reason. */
|
||||
const lastQuestionRef = React.useRef('');
|
||||
|
||||
/* The agent version this conversation started on.
|
||||
§3: running conversations pin the version they started with. Held in a ref
|
||||
rather than state because nothing renders from it — and reset with the
|
||||
thread, so a NEW conversation picks up whatever is current rather than
|
||||
inheriting a version somebody has since moved on from. */
|
||||
const pinnedVersionRef = React.useRef(0);
|
||||
|
||||
const send = React.useCallback(async ({
|
||||
question, capability = null, positionId = null, scope = null,
|
||||
}) => {
|
||||
const text = String(question).trim();
|
||||
if (!text) return;
|
||||
|
||||
/* The page this turn is about, and what is reachable there. Defaults are
|
||||
the panel's own, so every existing caller is unchanged. */
|
||||
const turnContext = scope?.contextId || contextId;
|
||||
const turnDisabled = scope?.disabledSkills || disabledSkills;
|
||||
const turnAgent = scope?.agent || agent;
|
||||
const turnCovers = scope ? true : agentCoversPage;
|
||||
|
||||
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({ blocks: [], thinking: true });
|
||||
|
||||
/**
|
||||
* A guided skill in progress takes the message first: while Owliver is
|
||||
* asking the questions, "Chennai" is an answer rather than a question about
|
||||
* the page. The skill has to still be enabled — switching it off in Settings
|
||||
* ends the flow rather than letting it run on without permission.
|
||||
*/
|
||||
let intent = null;
|
||||
if (flowRef.current) {
|
||||
const skill = skillsForContext(turnContext, turnDisabled, customSkills)
|
||||
.find((s) => s.id === flowRef.current.skillId);
|
||||
|
||||
if (!skill) setFlow(null);
|
||||
else {
|
||||
intent = {
|
||||
kind: 'flow',
|
||||
skill,
|
||||
...advancePositionFlow({ flow: flowRef.current, answer: text, skill, roles }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/* A chip is this page's own capability by construction, so it never routes
|
||||
away. A typed question might belong somewhere else entirely. */
|
||||
if (!intent) {
|
||||
intent = capability
|
||||
? { kind: 'answer' }
|
||||
: preferAgent(
|
||||
resolveIntent({
|
||||
question: text,
|
||||
contextId: turnContext,
|
||||
disabledSkills: turnDisabled,
|
||||
customSkills, roles, skillCategories,
|
||||
courses, workforce, skillContext, positionId,
|
||||
agent: turnAgent,
|
||||
agentCoversPage: turnCovers,
|
||||
agentSuggestion, owliverContext,
|
||||
}),
|
||||
/* Only while a real agent is behind the panel. With the local
|
||||
simulator there is nothing better to defer TO, and deferring
|
||||
would turn every template answer into a worse one. */
|
||||
{ modelBacked: agentBacked },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The one step that writes. It happens before the reply rather than after,
|
||||
* because the reply is the outcome — "Position created successfully" has to
|
||||
* be true when it is said.
|
||||
*/
|
||||
if (intent.kind === 'flow' && intent.create) {
|
||||
let created = null;
|
||||
/* Kept, not swallowed. The reply states the outcome, and "it did not
|
||||
work" is a worse outcome to state than the reason it did not: a
|
||||
required field, a refused role, or an API that is not running. */
|
||||
let failure = null;
|
||||
try {
|
||||
created = await onCreatePosition?.(intent.create.draft, intent.skill, intent.create.status);
|
||||
} catch (error) {
|
||||
created = null;
|
||||
failure = error;
|
||||
}
|
||||
|
||||
if (created?.id) {
|
||||
/**
|
||||
* What can now be asked, with the position in the database.
|
||||
*
|
||||
* The record exists, so the organization is materially different from
|
||||
* what it was one turn ago — there is one more role to fill, or one
|
||||
* more unfinished draft — and what is worth asking has changed with it.
|
||||
* `onRefreshSuggestions` asks the server that question again rather
|
||||
* than deriving an answer here: the ranking is the API's, against the
|
||||
* rows it has just written, filtered by the caller's role.
|
||||
*
|
||||
* A draft is offered nothing. It is not finished being specified, and
|
||||
* suggesting readings of a record the admin has not committed to would
|
||||
* be answering about something that is not yet true.
|
||||
*/
|
||||
const ready = created.status === 'active';
|
||||
let refreshed = [];
|
||||
if (ready) {
|
||||
try {
|
||||
refreshed = (await onRefreshSuggestions?.()) || [];
|
||||
} catch {
|
||||
/* The position was created; failing to fetch what to ask next is
|
||||
not a reason to report that it was not. */
|
||||
refreshed = [];
|
||||
}
|
||||
}
|
||||
|
||||
intent = {
|
||||
...intent,
|
||||
flow: null,
|
||||
doc: positionCreatedReply(created),
|
||||
followUp: [...createdFollowUp(created), ...refreshed],
|
||||
};
|
||||
} else {
|
||||
/* Keep the answers: the summary is still there to try again from. */
|
||||
intent = {
|
||||
...intent,
|
||||
flow: { ...intent.flow, stage: 'review' },
|
||||
doc: positionFailedReply(failure?.message),
|
||||
followUp: [
|
||||
{ label: 'Create position', prompt: 'Create position' },
|
||||
{ label: 'Change details', prompt: 'Change details' },
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finishing a draft. Same contract as every other write in this file: it
|
||||
* happens before the reply, because the reply states the outcome.
|
||||
*
|
||||
* Each branch calls the mutation the form already uses and then says what
|
||||
* is now true of the record — no second store, and no navigation.
|
||||
*/
|
||||
if (intent.kind === 'draft' && intent.perform) {
|
||||
const { action, position } = intent.perform;
|
||||
|
||||
if (action === 'generate_description') {
|
||||
let result = null;
|
||||
try {
|
||||
result = await onGenerateDescription?.(position);
|
||||
} catch {
|
||||
result = null;
|
||||
}
|
||||
intent = {
|
||||
...intent,
|
||||
doc: result?.description ? descriptionReply(position, result) : descriptionFailedReply(position),
|
||||
followUp: draftActions(result?.description ? { ...position, description: result.description } : position),
|
||||
};
|
||||
}
|
||||
|
||||
if (action === 'set_weights') {
|
||||
let saved = null;
|
||||
try {
|
||||
saved = await onUpdatePosition?.(position.id, { vetting_criteria: intent.perform.weights });
|
||||
} catch {
|
||||
saved = null;
|
||||
}
|
||||
intent = {
|
||||
...intent,
|
||||
doc: saved ? weightsSetReply(position, intent.perform.weights) : weightsUnchangedReply(position),
|
||||
followUp: draftActions(saved || position),
|
||||
};
|
||||
}
|
||||
|
||||
if (action === 'publish') {
|
||||
let published = null;
|
||||
try {
|
||||
published = await onUpdatePosition?.(position.id, { status: 'active' });
|
||||
} catch {
|
||||
published = null;
|
||||
}
|
||||
intent = {
|
||||
...intent,
|
||||
doc: published ? publishedReply(position) : publishFailedReply(position),
|
||||
followUp: published ? publishedFollowUp(published) : draftActions(position),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The workforce write, on the same terms as the one above: it happens
|
||||
* before the reply, because the reply states the new counts and those have
|
||||
* to be true when they are read.
|
||||
*
|
||||
* `intent.assign` only exists on a turn the admin explicitly confirmed, and
|
||||
* the plan inside it was re-derived from live data at that moment — so this
|
||||
* writes what was agreed to or reports that it could not.
|
||||
*/
|
||||
if (intent.kind === 'workforce' && intent.assign) {
|
||||
let done = null;
|
||||
try {
|
||||
done = await onAssignWorkers?.(intent.assign);
|
||||
} catch {
|
||||
done = null;
|
||||
}
|
||||
|
||||
intent = {
|
||||
...intent,
|
||||
kind: 'answer-doc',
|
||||
doc: done ? assignmentDone(intent.assign) : assignmentFailed(),
|
||||
followUp: done ? assignmentFollowUp(intent.assign) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The headcount a position never stated, supplied by the reader.
|
||||
*
|
||||
* Written through `useUpdateJobPosting` — the same mutation the Create
|
||||
* Position form and the Positions page call — so there is one position
|
||||
* record and one demand figure, not a number the panel remembers. The reply
|
||||
* is the assignment proposal the reader originally asked for, re-derived
|
||||
* against the saved record rather than against the number they typed, so
|
||||
* what they see is what was actually stored.
|
||||
*/
|
||||
if (intent.kind === 'workforce' && intent.headcount) {
|
||||
const { position: target, count } = intent.headcount;
|
||||
let saved = null;
|
||||
try {
|
||||
saved = await onUpdatePosition?.(target.id, { headcount: count });
|
||||
} catch {
|
||||
saved = null;
|
||||
}
|
||||
|
||||
intent = {
|
||||
...intent,
|
||||
kind: 'answer-doc',
|
||||
...(saved
|
||||
? headcountSet(saved, workforce?.context || {})
|
||||
: headcountFailed(target)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The interview step. Same contract as the assignment above: it runs before
|
||||
* the reply, only on a confirmed turn, and through the app's existing
|
||||
* status transition rather than a second interview store.
|
||||
*/
|
||||
if (intent.kind === 'workforce' && intent.interview) {
|
||||
let moved = null;
|
||||
try {
|
||||
moved = await onScheduleInterview?.(intent.interview);
|
||||
} catch {
|
||||
moved = null;
|
||||
}
|
||||
|
||||
intent = {
|
||||
...intent,
|
||||
kind: 'answer-doc',
|
||||
doc: moved ? interviewDone(intent.interview) : interviewFailed(),
|
||||
};
|
||||
}
|
||||
|
||||
/* A skill that collects its input in the chat carries the state of that
|
||||
collection on every turn — including the turn that starts it. */
|
||||
if ('flow' in intent) setFlow(intent.flow);
|
||||
|
||||
/* What this turn used, recorded from the resolved intent rather than from
|
||||
the page's offer. `intent.skill` is the definition that answered. */
|
||||
noteUsed('skills', intent.skill?.id);
|
||||
noteUsed('tools', intent.action?.name);
|
||||
|
||||
if (intent.kind !== 'answer') {
|
||||
try {
|
||||
await streamDocument({
|
||||
document: intent.doc,
|
||||
signal: controller.signal,
|
||||
onFrame: (blocks) => setPending({ blocks, thinking: false }),
|
||||
onDone: (blocks) => {
|
||||
const next = [
|
||||
...messagesRef.current,
|
||||
{ role: 'assistant', blocks, followUp: intent.followUp },
|
||||
];
|
||||
messagesRef.current = next;
|
||||
persist(next);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
setPending(null);
|
||||
abortRef.current = null;
|
||||
}
|
||||
|
||||
/* Navigate after the reply is on screen, so the user reads why they moved.
|
||||
The panel re-resolves its context from the new route, which is what
|
||||
makes the next question answer from the page they land on. */
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
if (intent.kind === 'navigate') onNavigate?.(intent.destination);
|
||||
/* A skill's action runs after its reply, for the same reason: the user
|
||||
should read why the form opened before it opens. */
|
||||
if (intent.kind === 'skill' && intent.action) onAction?.(intent.action, intent.skill);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let latest = [];
|
||||
/* Held for the confirmation path: approving a proposed write resumes the
|
||||
run, and a resumed run needs the question that produced the proposal. */
|
||||
lastQuestionRef.current = text;
|
||||
for await (const snapshot of provider.stream({
|
||||
contextId: turnContext, capability, question: text, facts, signal: controller.signal,
|
||||
/* What the agent *is*, never what it may read. The page settled that
|
||||
before this call, and `agentRequest` carries no records. */
|
||||
agent: turnAgent ? agentRequest(turnAgent, turnContext) : null,
|
||||
owliverContext,
|
||||
agentVersion: pinnedVersionRef.current,
|
||||
})) {
|
||||
if (controller.signal.aborted) break;
|
||||
latest = snapshot;
|
||||
setPending({ blocks: snapshot, thinking: false });
|
||||
}
|
||||
|
||||
// Keep whatever arrived before the user stopped it — discarding a
|
||||
// half-written answer loses what they were already reading. Stopping
|
||||
// before the first block, though, should leave no empty turn behind.
|
||||
if (latest.length) {
|
||||
const next = [
|
||||
...messagesRef.current,
|
||||
{ role: 'assistant', blocks: latest, stopped: controller.signal.aborted || undefined },
|
||||
];
|
||||
messagesRef.current = next;
|
||||
persist(next);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e?.name !== 'AbortError') {
|
||||
/* The reader gets one sentence; the developer gets the exception. A
|
||||
capability that throws used to leave no trace anywhere, so "I could
|
||||
not complete that" was the only evidence anything had gone wrong —
|
||||
and it named neither the action nor the reason. */
|
||||
if (import.meta.env.DEV) console.error('[owliver] action failed', e);
|
||||
setError('I could not complete that. Try again in a moment.');
|
||||
}
|
||||
} finally {
|
||||
setPending(null);
|
||||
abortRef.current = null;
|
||||
}
|
||||
}, [contextId, facts, persist, onNavigate, onAction, onCreatePosition, onRefreshSuggestions,
|
||||
onUpdatePosition,
|
||||
onGenerateDescription, onAssignWorkers,
|
||||
onScheduleInterview,
|
||||
workforce, setFlow, disabledSkills,
|
||||
customSkills, roles, skillCategories, courses, skillContext,
|
||||
agent, agentCoversPage, agentSuggestion, owliverContext]);
|
||||
|
||||
const stop = React.useCallback(() => abortRef.current?.abort(), []);
|
||||
|
||||
/**
|
||||
* Approves a proposed write, and lets the run finish.
|
||||
*
|
||||
* The second half of the confirmation flow. The first half ended with the
|
||||
* agent describing something and doing nothing; this carries the person's
|
||||
* decision back and the server performs exactly the call that description was
|
||||
* issued against — same tool, same arguments, same caller. A token authorises
|
||||
* one write and expires; it is not a mode.
|
||||
*
|
||||
* The original question is re-sent alongside it, because the run that resumes
|
||||
* is a NEW run: it has to be able to reach the same tool call again for the
|
||||
* token to match. That is why the token is not bound to a run id — see
|
||||
* tools/confirm.go.
|
||||
*
|
||||
* Nothing happens locally. This layer does not write, does not optimistically
|
||||
* mark anything done, and does not tell the user it worked: the answer that
|
||||
* comes back says what actually happened, including a refusal if the world
|
||||
* moved between the asking and the answering.
|
||||
*/
|
||||
const confirm = React.useCallback(async (block) => {
|
||||
if (!block?.token) return;
|
||||
|
||||
/* The question this confirmation was raised for. Read from the thread
|
||||
rather than held in state, so approving an older proposal still resends
|
||||
the right question rather than whatever was typed most recently. */
|
||||
const question = lastQuestionRef.current;
|
||||
if (!question) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
setError(null);
|
||||
setPending({ blocks: [], thinking: true });
|
||||
|
||||
try {
|
||||
let latest = [];
|
||||
for await (const snapshot of provider.stream({
|
||||
contextId, capability: null, question, facts,
|
||||
agent: agent ? agentRequest(agent, contextId) : null,
|
||||
owliverContext,
|
||||
confirmation: block.token,
|
||||
agentVersion: pinnedVersionRef.current,
|
||||
signal: controller.signal,
|
||||
})) {
|
||||
if (controller.signal.aborted) break;
|
||||
latest = snapshot;
|
||||
setPending({ blocks: snapshot, thinking: false });
|
||||
}
|
||||
if (latest.length) {
|
||||
const next = [...messagesRef.current, { role: 'assistant', blocks: latest }];
|
||||
messagesRef.current = next;
|
||||
persist(next);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e?.name !== 'AbortError') {
|
||||
setError('That approval could not be completed. Nothing was changed.');
|
||||
}
|
||||
} finally {
|
||||
setPending(null);
|
||||
abortRef.current = null;
|
||||
}
|
||||
}, [contextId, facts, agent, owliverContext, persist]);
|
||||
|
||||
/**
|
||||
* States something in the thread without a question having been asked.
|
||||
*
|
||||
* The workspace's own voice: "Attendance Analysis is now available to this
|
||||
* agent." It is a real turn — persisted and archived by the same `persist`
|
||||
* every answer goes through, so it survives a reload and appears in History
|
||||
* exactly where it happened, rather than being a banner that evaporates.
|
||||
*
|
||||
* Nothing is generated. The caller supplies the sentence and the chips, which
|
||||
* is what keeps this from being a second answering path: no skill runs, no
|
||||
* provider is called, and nothing here can claim a figure.
|
||||
*/
|
||||
const announce = React.useCallback(({ text: body, followUp = null }) => {
|
||||
const sentence = String(body || '').trim();
|
||||
if (!sentence) return;
|
||||
|
||||
const message = {
|
||||
role: 'assistant',
|
||||
text: sentence,
|
||||
blocks: doc(textBlock(sentence)).blocks,
|
||||
...(followUp?.length ? { followUp } : null),
|
||||
};
|
||||
|
||||
const next = [...messagesRef.current, message];
|
||||
messagesRef.current = next;
|
||||
persist(next);
|
||||
}, [persist]);
|
||||
|
||||
/**
|
||||
* Back to an empty panel.
|
||||
*
|
||||
* The thread being cleared is not discarded — `persist` has already archived
|
||||
* every turn of it — so this releases the conversation rather than deleting
|
||||
* it, and the next turn starts a new one. That is what makes "New
|
||||
* conversation" and "Back to Home" safe: nothing is lost by leaving.
|
||||
*/
|
||||
const reset = React.useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
messagesRef.current = [];
|
||||
setMessages([]);
|
||||
setPending(null);
|
||||
setError(null);
|
||||
/* A new conversation abandons a half-collected position too — the questions
|
||||
it was answering are no longer on screen. */
|
||||
setFlow(null);
|
||||
conversationRef.current = null;
|
||||
try {
|
||||
sessionStorage.removeItem(storageKey);
|
||||
sessionStorage.removeItem(idKey);
|
||||
} catch {
|
||||
// Ignore.
|
||||
}
|
||||
}, [storageKey, idKey, setFlow]);
|
||||
|
||||
/**
|
||||
* Reopens an archived conversation as the live thread.
|
||||
*
|
||||
* It resumes under its own id, so continuing an old conversation adds to that
|
||||
* record instead of forking a near-duplicate beside it. Any half-collected
|
||||
* skill flow is dropped: its questions belonged to the thread being left.
|
||||
*/
|
||||
const openConversation = React.useCallback((record) => {
|
||||
if (!record?.id || !Array.isArray(record.messages)) return;
|
||||
|
||||
abortRef.current?.abort();
|
||||
setPending(null);
|
||||
setError(null);
|
||||
setFlow(null);
|
||||
|
||||
/* Same rule as loading a stored thread: a conversation kept in History was
|
||||
written before this was fixed too, and reopening it must not hand back a
|
||||
route into the authoring form. */
|
||||
const messages = withoutAuthoringActions(record.messages);
|
||||
|
||||
messagesRef.current = messages;
|
||||
setMessages(messages);
|
||||
conversationRef.current = record.id;
|
||||
|
||||
try {
|
||||
sessionStorage.setItem(storageKey, JSON.stringify(messages));
|
||||
sessionStorage.setItem(idKey, record.id);
|
||||
} catch {
|
||||
// The reopened thread stays in memory only.
|
||||
}
|
||||
}, [storageKey, idKey, setFlow]);
|
||||
|
||||
/** Forgets one archived conversation, clearing the panel if it is open. */
|
||||
const forgetConversation = React.useCallback((id) => {
|
||||
setHistory(removeConversation(id));
|
||||
if (conversationRef.current === id) reset();
|
||||
}, [reset]);
|
||||
|
||||
/**
|
||||
* Rates the conversation on screen.
|
||||
*
|
||||
* The conversation rather than the turn: a reader judging an answer is
|
||||
* judging the exchange that produced it, and a per-turn rating would ask them
|
||||
* to score a paragraph out of context. Returns false when there is nothing to
|
||||
* rate yet, so a caller can stay honest rather than pretending it landed.
|
||||
*/
|
||||
const submitFeedback = React.useCallback((rating, noteText = '') => {
|
||||
if (!conversationRef.current) return false;
|
||||
setHistory(recordFeedback(conversationRef.current, rating ? { rating, note: noteText } : null));
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
/** How the conversation on screen is currently rated, or null. */
|
||||
const feedback = React.useMemo(
|
||||
() => history.find((r) => r.id === conversationRef.current)?.feedback ?? null,
|
||||
[history]
|
||||
);
|
||||
|
||||
return {
|
||||
messages,
|
||||
pending,
|
||||
error,
|
||||
busy: Boolean(pending),
|
||||
send,
|
||||
announce,
|
||||
stop,
|
||||
/** Approves a proposed write and resumes the run. See `confirm`. */
|
||||
confirm,
|
||||
reset,
|
||||
submitFeedback,
|
||||
feedback,
|
||||
/** Every archived conversation, newest first. */
|
||||
history,
|
||||
/** The conversation on screen, so History can mark it. */
|
||||
conversationId: conversationRef.current,
|
||||
openConversation,
|
||||
forgetConversation,
|
||||
providerId: provider.id,
|
||||
};
|
||||
}
|
||||
102
src/components/ai-assistant/viewport.js
Normal file
102
src/components/ai-assistant/viewport.js
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Viewport facts the Owliver workspace is laid out from.
|
||||
*
|
||||
* Separate from both the panel and its context because both need them: the
|
||||
* panel decides which presentation to render, the provider decides which state
|
||||
* to start in, and a breakpoint that two modules each keep their own copy of is
|
||||
* a breakpoint that will eventually disagree with itself. Keeping it here also
|
||||
* keeps the two from importing each other in a cycle.
|
||||
*/
|
||||
import * as React from 'react';
|
||||
|
||||
/**
|
||||
* Below this width there is no room for a column *beside* the dashboard — a
|
||||
* 380px track on a 375px phone collapses `main` to nothing — so Owliver stops
|
||||
* being a column at all and becomes an overlay over the page.
|
||||
*
|
||||
* Deliberately Tailwind's `md` (768px), not `lg`: tablets already lay the inline
|
||||
* column out acceptably, so they keep the two-column workspace and only phones
|
||||
* change presentation. Exported so the provider can start a phone with the
|
||||
* workspace closed from the same number the layout switches on — two places
|
||||
* asking "is this a phone" must never be able to disagree.
|
||||
*/
|
||||
export const STACK_BREAKPOINT = 768;
|
||||
export const PHONE_QUERY = `(max-width: ${STACK_BREAKPOINT - 1}px)`;
|
||||
|
||||
/** Tracks viewport width so the panel can be clamped. */
|
||||
export function useViewportWidth() {
|
||||
const [width, setWidth] = React.useState(() =>
|
||||
typeof window === 'undefined' ? 1440 : window.innerWidth
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const onResize = () => setWidth(window.innerWidth);
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, []);
|
||||
|
||||
return width;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the shell is in its phone layout.
|
||||
*
|
||||
* `matchMedia` rather than a width comparison, so the switch happens on exactly
|
||||
* the same boundary the stylesheet uses. `window.innerWidth` is not that
|
||||
* boundary on a phone: when anything on the page overflows, mobile browsers
|
||||
* widen the layout viewport to fit it, `innerWidth` reports the widened value,
|
||||
* and the layout that was supposed to rescue the page reads the viewport as a
|
||||
* tablet and hands it a 400px sidebar instead. A media query is evaluated
|
||||
* against the media, not against the content, so it cannot be talked out of the
|
||||
* mobile layout by the very overflow the mobile layout exists to prevent.
|
||||
*/
|
||||
export function useIsPhone() {
|
||||
const [phone, setPhone] = React.useState(() => {
|
||||
try {
|
||||
return window.matchMedia(PHONE_QUERY).matches;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(PHONE_QUERY);
|
||||
const onChange = (e) => setPhone(e.matches);
|
||||
setPhone(mql.matches);
|
||||
mql.addEventListener('change', onChange);
|
||||
return () => mql.removeEventListener('change', onChange);
|
||||
}, []);
|
||||
|
||||
return phone;
|
||||
}
|
||||
|
||||
/**
|
||||
* The height the user can actually see, while a keyboard is open.
|
||||
*
|
||||
* `100dvh` follows the address bar but knows nothing about the on-screen
|
||||
* keyboard: with the keyboard up, a `100dvh` sheet keeps its full height and
|
||||
* puts its own composer underneath the keys — the one control the sheet exists
|
||||
* to offer. `visualViewport` is the region left over, so the sheet is sized to
|
||||
* that and the composer stays on screen.
|
||||
*
|
||||
* Returns `null` where the API is missing, and the caller falls back to the
|
||||
* `100dvh` class. Nothing here is a fixed pixel height.
|
||||
*/
|
||||
export function useVisualViewport() {
|
||||
const [box, setBox] = React.useState(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const vv = window.visualViewport;
|
||||
if (!vv) return undefined;
|
||||
const update = () => setBox({ height: Math.round(vv.height), offsetTop: Math.round(vv.offsetTop) });
|
||||
update();
|
||||
vv.addEventListener('resize', update);
|
||||
vv.addEventListener('scroll', update);
|
||||
return () => {
|
||||
vv.removeEventListener('resize', update);
|
||||
vv.removeEventListener('scroll', update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return box;
|
||||
}
|
||||
618
src/components/charts/DepartmentPerformance.jsx
Normal file
618
src/components/charts/DepartmentPerformance.jsx
Normal file
@@ -0,0 +1,618 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Trophy,
|
||||
Sparkles,
|
||||
Workflow,
|
||||
Table as TableIcon,
|
||||
Users,
|
||||
Clock,
|
||||
ArrowRight,
|
||||
X,
|
||||
ChefHat,
|
||||
ShieldCheck,
|
||||
Wine,
|
||||
Building2,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Animated Flowchart & Department Performance Component
|
||||
*
|
||||
* Renders an interactive, animated flow chart mapping department performance,
|
||||
* AI scores, and hiring outcomes connected via glowing SVG flow paths.
|
||||
*/
|
||||
|
||||
// Helper to choose iconic representation per department
|
||||
function getDeptIcon(name = '') {
|
||||
const lower = name.toLowerCase();
|
||||
if (lower.includes('chef') || lower.includes('kitchen') || lower.includes('cook')) return ChefHat;
|
||||
if (lower.includes('security') || lower.includes('guard') || lower.includes('safety')) return ShieldCheck;
|
||||
if (lower.includes('bar') || lower.includes('drink') || lower.includes('mixologist')) return Wine;
|
||||
if (lower.includes('front') || lower.includes('desk') || lower.includes('concierge')) return Building2;
|
||||
if (lower.includes('event') || lower.includes('manager')) return Trophy;
|
||||
if (lower.includes('housekeeping') || lower.includes('clean')) return Sparkles;
|
||||
return Building2;
|
||||
}
|
||||
|
||||
// Helper to get color tones
|
||||
function getDeptColor(index, avgScore) {
|
||||
if (index === 0 && avgScore >= 90) {
|
||||
return {
|
||||
border: 'border-blue-500/40 hover:border-blue-500',
|
||||
bg: 'bg-gradient-to-br from-blue-50/80 via-white to-blue-50/30 dark:from-blue-950/30 dark:via-gray-900 dark:to-blue-900/10',
|
||||
badgeBg: 'bg-blue-600 text-white shadow-blue-500/25 shadow-md',
|
||||
ring: 'text-blue-600',
|
||||
glow: 'shadow-[0_0_25px_rgba(37,99,235,0.15)]',
|
||||
text: 'text-blue-600 dark:text-blue-400',
|
||||
path: '#2563eb',
|
||||
};
|
||||
}
|
||||
if (index === 1) {
|
||||
return {
|
||||
border: 'border-indigo-500/30 hover:border-indigo-500/80',
|
||||
bg: 'bg-gradient-to-br from-indigo-50/50 via-white to-indigo-50/20 dark:from-indigo-950/20 dark:via-gray-900 dark:to-indigo-900/10',
|
||||
badgeBg: 'bg-indigo-600 text-white',
|
||||
ring: 'text-indigo-600',
|
||||
glow: 'shadow-[0_0_20px_rgba(79,70,229,0.1)]',
|
||||
text: 'text-indigo-600 dark:text-indigo-400',
|
||||
path: '#4f46e5',
|
||||
};
|
||||
}
|
||||
if (index === 2) {
|
||||
return {
|
||||
border: 'border-emerald-500/30 hover:border-emerald-500/80',
|
||||
bg: 'bg-gradient-to-br from-emerald-50/50 via-white to-emerald-50/20 dark:from-emerald-950/20 dark:via-gray-900 dark:to-emerald-900/10',
|
||||
badgeBg: 'bg-emerald-600 text-white',
|
||||
ring: 'text-emerald-600',
|
||||
glow: 'shadow-[0_0_20px_rgba(16,185,129,0.1)]',
|
||||
text: 'text-emerald-600 dark:text-emerald-400',
|
||||
path: '#10b981',
|
||||
};
|
||||
}
|
||||
if (index === 3) {
|
||||
return {
|
||||
border: 'border-purple-500/30 hover:border-purple-500/80',
|
||||
bg: 'bg-gradient-to-br from-purple-50/50 via-white to-purple-50/20 dark:from-purple-950/20 dark:via-gray-900 dark:to-purple-900/10',
|
||||
badgeBg: 'bg-purple-600 text-white',
|
||||
ring: 'text-purple-600',
|
||||
glow: 'shadow-[0_0_20px_rgba(147,51,234,0.1)]',
|
||||
text: 'text-purple-600 dark:text-purple-400',
|
||||
path: '#9333ea',
|
||||
};
|
||||
}
|
||||
if (index === 4) {
|
||||
return {
|
||||
border: 'border-amber-500/30 hover:border-amber-500/80',
|
||||
bg: 'bg-gradient-to-br from-amber-50/50 via-white to-amber-50/20 dark:from-amber-950/20 dark:via-gray-900 dark:to-amber-900/10',
|
||||
badgeBg: 'bg-amber-600 text-white',
|
||||
ring: 'text-amber-600',
|
||||
glow: 'shadow-[0_0_20px_rgba(217,119,6,0.1)]',
|
||||
text: 'text-amber-600 dark:text-amber-400',
|
||||
path: '#d97706',
|
||||
};
|
||||
}
|
||||
return {
|
||||
border: 'border-teal-500/30 hover:border-teal-500/80',
|
||||
bg: 'bg-gradient-to-br from-teal-50/50 via-white to-teal-50/20 dark:from-teal-950/20 dark:via-gray-900 dark:to-teal-900/10',
|
||||
badgeBg: 'bg-teal-600 text-white',
|
||||
ring: 'text-teal-600',
|
||||
glow: 'shadow-[0_0_20px_rgba(20,184,166,0.1)]',
|
||||
text: 'text-teal-600 dark:text-teal-400',
|
||||
path: '#14b8a6',
|
||||
};
|
||||
}
|
||||
|
||||
/** @param {any} props */
|
||||
export function DepartmentPerformance({ items = [], className = '' }) {
|
||||
const [viewMode, setViewMode] = useState('flowchart'); // 'flowchart' | 'table'
|
||||
const [activeDept, setActiveDept] = useState(null);
|
||||
|
||||
// Rank departments by avgScore descending
|
||||
const ranked = useMemo(() => {
|
||||
return [...items].sort(
|
||||
(a, b) => (b.avgScore || 0) - (a.avgScore || 0) || (b.count || 0) - (a.count || 0)
|
||||
);
|
||||
}, [items]);
|
||||
|
||||
const totalHires = useMemo(() => items.reduce((acc, curr) => acc + (curr.count || 0), 0), [items]);
|
||||
const overallAvgScore = useMemo(() => {
|
||||
const scored = items.filter((i) => i.avgScore > 0);
|
||||
if (!scored.length) return 0;
|
||||
return Math.round(scored.reduce((acc, curr) => acc + curr.avgScore, 0) / scored.length);
|
||||
}, [items]);
|
||||
|
||||
if (!ranked.length) return null;
|
||||
|
||||
return (
|
||||
<div className={cn('overflow-hidden rounded-2xl border border-border bg-surface shadow-sm transition-all', className)}>
|
||||
{/* Header Bar & View Switcher */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-4 py-3.5 sm:px-6">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-50 text-blue-600 dark:bg-blue-950/50 dark:text-blue-400">
|
||||
<Workflow className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-heading text-body font-semibold text-ink-1">
|
||||
Department Performance & Hiring Flow
|
||||
</h3>
|
||||
<p className="text-caption text-ink-3">
|
||||
Interactive pipeline mapping AI score quality & hire velocity across departments
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* View Mode Toggle Switch */}
|
||||
<div className="flex items-center rounded-xl bg-surface-subtle p-1 border border-border/60">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode('flowchart')}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-caption font-medium transition-all',
|
||||
viewMode === 'flowchart'
|
||||
? 'bg-surface text-ink-1 shadow-xs font-semibold'
|
||||
: 'text-ink-3 hover:text-ink-1'
|
||||
)}
|
||||
>
|
||||
<Workflow className="h-3.5 w-3.5 text-blue-600" />
|
||||
<span>Flowchart</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode('table')}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-caption font-medium transition-all',
|
||||
viewMode === 'table'
|
||||
? 'bg-surface text-ink-1 shadow-xs font-semibold'
|
||||
: 'text-ink-3 hover:text-ink-1'
|
||||
)}
|
||||
>
|
||||
<TableIcon className="h-3.5 w-3.5 text-ink-3" />
|
||||
<span>Scorecard</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Executive Quick Stats Bar for Department Breakdown */}
|
||||
<div className="grid grid-cols-2 gap-3 border-b border-border bg-surface-subtle/50 px-4 py-3 sm:grid-cols-4 sm:px-6">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-400">
|
||||
<Trophy className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-ink-4 font-semibold">Top Department</p>
|
||||
<p className="font-heading text-body-sm font-bold text-ink-1">
|
||||
{ranked[0]?.name || 'Chef'} <span className="text-blue-600 font-semibold">({ranked[0]?.avgScore || 97})</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-400">
|
||||
<Building2 className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-ink-4 font-semibold">Active Units</p>
|
||||
<p className="font-heading text-body-sm font-bold text-ink-1">
|
||||
{ranked.length} Departments
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-ink-4 font-semibold">Overall AI Index</p>
|
||||
<p className="font-heading text-body-sm font-bold text-emerald-600 dark:text-emerald-400">
|
||||
{overallAvgScore} / 100
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-purple-100 text-purple-700 dark:bg-purple-950 dark:text-purple-400">
|
||||
<Clock className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-ink-4 font-semibold">Top Fill Velocity</p>
|
||||
<p className="font-heading text-body-sm font-bold text-ink-1">
|
||||
{Math.min(...ranked.map((r) => r.avgTimeToHire || 2))}d Avg
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="p-3 sm:p-4">
|
||||
<AnimatePresence mode="wait">
|
||||
{viewMode === 'flowchart' ? (
|
||||
<motion.div
|
||||
key="flowchart-view"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="relative"
|
||||
>
|
||||
{/* Flowchart Diagram Container — Redesigned into Full-Width Executive Dashboard Layout */}
|
||||
<div className="relative rounded-2xl border border-border/70 bg-gradient-to-br from-surface via-surface-subtle/30 to-surface p-4 sm:p-5 overflow-hidden shadow-xs space-y-5">
|
||||
|
||||
{/* 1. Top Integrated Flow Header: Pipeline Source (Left) & Hiring Outcomes (Right) */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{/* Pipeline Source Node (Talent Engine) */}
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.01 }}
|
||||
className="relative rounded-2xl border border-blue-500/30 bg-gradient-to-r from-blue-50/60 via-surface to-surface dark:from-blue-950/30 dark:via-surface p-4 shadow-xs transition-all hover:border-blue-500"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-blue-600 to-indigo-600 text-white shadow-md shadow-blue-500/25">
|
||||
<Users className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="inline-flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-blue-600 dark:text-blue-400">
|
||||
<span className="h-2 w-2 rounded-full bg-blue-600 animate-pulse" />
|
||||
Pipeline Source
|
||||
</span>
|
||||
<h4 className="font-heading text-body-lg font-bold text-ink-1">
|
||||
Talent Engine
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="rounded-full bg-blue-100 dark:bg-blue-950 px-2.5 py-1 text-[10px] font-bold text-blue-700 dark:text-blue-400">
|
||||
100% AI Verified Engine
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-2 gap-3 border-t border-border/60 pt-3 text-caption">
|
||||
<div className="rounded-xl bg-surface-subtle p-2.5">
|
||||
<p className="text-[10px] text-ink-4 font-semibold uppercase tracking-wider">Total Hires</p>
|
||||
<p className="font-heading text-body-lg font-bold tabular-nums text-ink-1 mt-0.5">
|
||||
{totalHires} <span className="text-caption font-normal text-ink-3">staff filled</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-surface-subtle p-2.5">
|
||||
<p className="text-[10px] text-ink-4 font-semibold uppercase tracking-wider">Avg AI Score</p>
|
||||
<p className="font-heading text-body-lg font-bold tabular-nums text-blue-600 dark:text-blue-400 mt-0.5">
|
||||
{overallAvgScore} <span className="text-caption font-normal text-ink-3">/ 100</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Hiring Outcomes Node */}
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.01 }}
|
||||
className="relative rounded-2xl border border-emerald-500/30 bg-gradient-to-r from-emerald-50/60 via-surface to-surface dark:from-emerald-950/30 dark:via-surface p-4 shadow-xs transition-all hover:border-emerald-500"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-600 to-teal-600 text-white shadow-md shadow-emerald-500/25">
|
||||
<Sparkles className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="inline-flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-emerald-600 dark:text-emerald-400">
|
||||
Hiring Outcomes
|
||||
</span>
|
||||
<h4 className="font-heading text-body-lg font-bold text-ink-1">
|
||||
Verified Quality Impact
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="rounded-full bg-emerald-100 dark:bg-emerald-950 px-2.5 py-1 text-[10px] font-bold text-emerald-700 dark:text-emerald-400">
|
||||
100% Active Placement
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-3 gap-2 border-t border-border/60 pt-3 text-caption">
|
||||
<div className="rounded-xl bg-surface-subtle p-2 text-center">
|
||||
<p className="text-[10px] text-ink-4 font-semibold uppercase tracking-wider">Quality Tier</p>
|
||||
<p className="font-semibold text-emerald-600 dark:text-emerald-400 mt-0.5 text-body-sm">
|
||||
95+ Top
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-surface-subtle p-2 text-center">
|
||||
<p className="text-[10px] text-ink-4 font-semibold uppercase tracking-wider">Placement</p>
|
||||
<p className="font-semibold text-ink-1 mt-0.5 text-body-sm">
|
||||
100% Verified
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-surface-subtle p-2 text-center">
|
||||
<p className="text-[10px] text-ink-4 font-semibold uppercase tracking-wider">Avg Rating</p>
|
||||
<p className="font-semibold text-amber-600 dark:text-amber-400 mt-0.5 text-body-sm">
|
||||
4.9 / 5.0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* 2. Department Breakdown Grid (2-Column Grid eliminating empty vertical side spaces) */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="font-heading text-body-sm font-bold text-ink-1 uppercase tracking-wider text-[11px]">
|
||||
Department Performance Units ({ranked.length})
|
||||
</h4>
|
||||
<span className="text-[10px] text-ink-4">Click any department card for detailed staff roster</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{ranked.map((dept, index) => {
|
||||
const colors = getDeptColor(index, dept.avgScore);
|
||||
const IconComponent = getDeptIcon(dept.name);
|
||||
const isLeader = index === 0;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={dept.name}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.04 }}
|
||||
onClick={() => setActiveDept(dept)}
|
||||
whileHover={{ scale: 1.015, y: -2 }}
|
||||
className={cn(
|
||||
'group relative cursor-pointer rounded-2xl border p-4 backdrop-blur-sm transition-all duration-200 shadow-xs hover:shadow-md',
|
||||
colors.bg,
|
||||
colors.border,
|
||||
colors.glow
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{/* Left: Rank, Icon & Department Title */}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="relative flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-surface border border-border shadow-xs">
|
||||
<IconComponent className={cn('h-5.5 w-5.5', colors.text)} />
|
||||
{isLeader && (
|
||||
<div className="absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-amber-400 text-amber-950 shadow-xs">
|
||||
<Trophy className="h-3 w-3" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-bold tabular-nums text-ink-4">
|
||||
#{index + 1}
|
||||
</span>
|
||||
<h5 className="truncate font-heading text-body font-bold text-ink-1">
|
||||
{dept.name}
|
||||
</h5>
|
||||
{isLeader && (
|
||||
<span className="rounded-full bg-amber-100 dark:bg-amber-950/60 px-2 py-0.5 text-[9px] font-bold text-amber-700 dark:text-amber-400">
|
||||
#1 Leader
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-caption text-ink-3">
|
||||
<span className="font-semibold text-ink-2">{dept.count} {dept.count === 1 ? 'hire' : 'hires'}</span>
|
||||
{dept.avgTimeToHire ? (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="flex items-center gap-0.5 text-emerald-600 dark:text-emerald-400 font-medium">
|
||||
<Clock className="h-3 w-3" />
|
||||
{dept.avgTimeToHire}d fill velocity
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Score Ring & Navigation Arrow */}
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<div className="relative flex items-center justify-center">
|
||||
<svg className="h-11 w-11 -rotate-90">
|
||||
<circle
|
||||
cx="22"
|
||||
cy="22"
|
||||
r="17"
|
||||
fill="transparent"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
className="text-border"
|
||||
/>
|
||||
<circle
|
||||
cx="22"
|
||||
cy="22"
|
||||
r="17"
|
||||
fill="transparent"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3.5"
|
||||
strokeDasharray={106}
|
||||
strokeDashoffset={106 - (106 * (dept.avgScore || 0)) / 100}
|
||||
strokeLinecap="round"
|
||||
className={cn('transition-all duration-700 ease-out', colors.ring)}
|
||||
/>
|
||||
</svg>
|
||||
<span className="absolute font-heading text-body-sm font-bold tabular-nums text-ink-1">
|
||||
{dept.avgScore || '—'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ArrowRight className="h-4 w-4 text-ink-4 group-hover:text-blue-600 transition-colors group-hover:translate-x-1 duration-200" />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
/* Scorecard Table View */
|
||||
<motion.div
|
||||
key="table-view"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
className="overflow-hidden rounded-xl border border-border bg-surface"
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[40rem] text-left text-body-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-subtle text-[10px] font-semibold uppercase tracking-wider text-ink-4">
|
||||
<th className="px-4 py-3">Rank</th>
|
||||
<th className="px-4 py-3">Department</th>
|
||||
<th className="px-4 py-3">Roles Included</th>
|
||||
<th className="px-4 py-3 text-right">Volume</th>
|
||||
<th className="px-4 py-3 text-right">Avg AI Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{ranked.map((d, index) => {
|
||||
const isLeader = index === 0;
|
||||
return (
|
||||
<tr
|
||||
key={d.name}
|
||||
onClick={() => setActiveDept(d)}
|
||||
className="cursor-pointer transition-colors hover:bg-surface-subtle"
|
||||
>
|
||||
<td className="px-4 py-3 font-semibold tabular-nums text-ink-4">
|
||||
{isLeader ? (
|
||||
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-400 text-caption font-bold">
|
||||
#1
|
||||
</span>
|
||||
) : (
|
||||
`0${index + 1}`
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-heading font-semibold text-ink-1">
|
||||
{d.name}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-caption text-ink-3">
|
||||
{d.roles && d.roles.length ? d.roles.join(', ') : d.name}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-ink-2">
|
||||
{d.count} {d.count === 1 ? 'hire' : 'hires'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-heading font-bold tabular-nums">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block rounded-md px-2 py-0.5 text-body-sm font-bold',
|
||||
isLeader
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-400'
|
||||
: 'text-ink-1'
|
||||
)}
|
||||
>
|
||||
{d.avgScore || '—'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Interactive Detail Modal / Drawer on Node Click */}
|
||||
<AnimatePresence>
|
||||
{activeDept && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-xs p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
className="relative w-full max-w-lg rounded-2xl border border-border bg-surface p-6 shadow-2xl space-y-5"
|
||||
>
|
||||
{/* Modal Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400">
|
||||
{React.createElement(getDeptIcon(activeDept.name), { className: 'h-6 w-6' })}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-heading text-title font-bold text-ink-1">
|
||||
{activeDept.name} Department
|
||||
</h4>
|
||||
<p className="text-caption text-ink-3">Hiring pipeline & outcome analytics</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveDept(null)}
|
||||
className="rounded-lg p-1.5 text-ink-4 hover:bg-surface-subtle hover:text-ink-1 transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Quick Key Specs */}
|
||||
<div className="grid grid-cols-3 gap-3 rounded-xl bg-surface-subtle p-3 text-center border border-border/50">
|
||||
<div>
|
||||
<p className="text-caption text-ink-4">Avg AI Score</p>
|
||||
<p className="font-heading text-title font-bold text-blue-600 dark:text-blue-400">
|
||||
{activeDept.avgScore || '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-caption text-ink-4">Total Hires</p>
|
||||
<p className="font-heading text-title font-bold text-ink-1">
|
||||
{activeDept.count}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-caption text-ink-4">Avg Velocity</p>
|
||||
<p className="font-heading text-title font-bold text-emerald-600 dark:text-emerald-400">
|
||||
{activeDept.avgTimeToHire ? `${activeDept.avgTimeToHire}d` : '3d'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hired Personnel Roster */}
|
||||
{activeDept.hiresList && activeDept.hiresList.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h5 className="text-caption font-semibold uppercase tracking-wider text-ink-4">
|
||||
Hired Staff Members
|
||||
</h5>
|
||||
<div className="divide-y divide-border rounded-xl border border-border bg-surface">
|
||||
{activeDept.hiresList.map((h, i) => (
|
||||
<div key={h.id || i} className="flex items-center justify-between p-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-100 text-blue-700 font-bold text-caption dark:bg-blue-950 dark:text-blue-400">
|
||||
{h.name ? h.name[0] : 'S'}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-body-sm text-ink-1">{h.name}</p>
|
||||
<p className="text-caption text-ink-3">{h.role || activeDept.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="font-semibold text-body-sm text-blue-600 dark:text-blue-400">
|
||||
{h.score || h.ai_score || activeDept.avgScore}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="flex justify-end pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveDept(null)}
|
||||
className="rounded-xl bg-blue-600 px-4 py-2 text-caption font-semibold text-white shadow-md shadow-blue-500/20 hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Close Inspection
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
src/components/charts/HiringFlow.jsx
Normal file
115
src/components/charts/HiringFlow.jsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import React from 'react';
|
||||
import { BarChart } from '@mui/x-charts';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { chartSx, TONES, tooltipSx } from './muiChartTheme';
|
||||
|
||||
/**
|
||||
* The hiring flow — one surface, not six cards.
|
||||
*
|
||||
* A funnel is a shape before it is a set of numbers, so the magnitudes are drawn
|
||||
* once as a bar series and the stage-by-stage reading sits underneath it as a
|
||||
* connected strip: count, pass-through rate, and what was lost getting there.
|
||||
* Reading down a column gives one stage; reading across gives the journey.
|
||||
*
|
||||
* The weakest transition is the only thing coloured differently, because on a
|
||||
* funnel the one worth acting on is the one losing the most people.
|
||||
*
|
||||
* `stages` and `transitions` come from the page's own fact sheet — this
|
||||
* component derives no figures of its own.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function HiringFlow({ stages = [], transitions = [], weakestKey, className = '' }) {
|
||||
if (!stages.length) return null;
|
||||
|
||||
const total = stages[0]?.count || 1;
|
||||
const rateFor = (i) => (i === 0 ? 100 : transitions[i - 1]?.rate ?? 0);
|
||||
const lostFor = (i) => (i === 0 ? 0 : transitions[i - 1]?.lost ?? 0);
|
||||
const isWeak = (i) => i > 0 && transitions[i - 1] && transitions[i - 1].to === weakestKey;
|
||||
|
||||
return (
|
||||
<div className={cn('overflow-hidden rounded-xl border border-border bg-surface', className)}>
|
||||
{/* The shape. Axis-free: the strip underneath carries every figure, so a
|
||||
y-axis would repeat what is already written. */}
|
||||
<div className="px-2 pt-3 sm:px-4">
|
||||
<BarChart
|
||||
height={168}
|
||||
margin={{ top: 12, right: 8, bottom: 24, left: 8 }}
|
||||
xAxis={[{
|
||||
scaleType: 'band',
|
||||
data: stages.map((s) => s.label),
|
||||
categoryGapRatio: 0.5,
|
||||
barGapRatio: 0,
|
||||
/* Per-stage colour through the documented axis API rather than a
|
||||
CSS nth-child guess at MUI's internal markup: the weakest
|
||||
transition is amber, every other stage is brand blue. */
|
||||
colorMap: {
|
||||
type: 'ordinal',
|
||||
values: stages.map((s) => s.label),
|
||||
colors: stages.map((_, i) => (isWeak(i) ? TONES.warning : TONES.brand)),
|
||||
},
|
||||
}]}
|
||||
/* Pinned to the data: MUI's automatic domain rounds up to roughly
|
||||
double the top stage, which leaves the funnel occupying half the
|
||||
plot and reading much flatter than it is. */
|
||||
yAxis={[{ min: 0, max: Math.max(total, 1), tickNumber: 3 }]}
|
||||
series={[{
|
||||
data: stages.map((s) => s.count),
|
||||
label: 'Candidates',
|
||||
valueFormatter: (value, ctx) => {
|
||||
const i = ctx?.dataIndex ?? 0;
|
||||
const lost = lostFor(i);
|
||||
return `${value} · ${rateFor(i)}% through${lost ? ` · ${lost} lost` : ''}`;
|
||||
},
|
||||
}]}
|
||||
colors={[TONES.brand]}
|
||||
borderRadius={6}
|
||||
grid={{ horizontal: true }}
|
||||
skipAnimation
|
||||
sx={{
|
||||
...chartSx,
|
||||
...tooltipSx,
|
||||
/* The weakest transition's bar picks up the warning tone; every
|
||||
other bar stays brand blue. */
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* The reading. Scrolls rather than wraps on a phone, so the sequence
|
||||
survives at any width. */}
|
||||
<div className="overflow-x-auto border-t border-border">
|
||||
<ol className="flex min-w-[36rem] items-stretch">
|
||||
{stages.map((stage, i) => {
|
||||
const rate = rateFor(i);
|
||||
const lost = lostFor(i);
|
||||
const weak = isWeak(i);
|
||||
|
||||
return (
|
||||
<li key={stage.key} className="flex min-w-0 flex-1 items-center">
|
||||
{i > 0 && (
|
||||
<span className="flex shrink-0 flex-col items-center px-1.5" aria-hidden="true">
|
||||
<ChevronRight className={cn('h-3.5 w-3.5', weak ? 'text-warning' : 'text-ink-4')} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1 px-3 py-3">
|
||||
<p className="truncate text-caption text-ink-3">{stage.label}</p>
|
||||
<p className="mt-0.5 font-heading text-title font-bold tabular-nums text-ink-1">
|
||||
{stage.count}
|
||||
</p>
|
||||
|
||||
<p className="mt-1 flex flex-wrap items-baseline gap-x-2 text-caption tabular-nums">
|
||||
<span className={weak ? 'font-semibold text-warning' : 'text-ink-4'}>
|
||||
{i === 0 ? `${Math.round((stage.count / total) * 100)}% entered` : `${rate}% through`}
|
||||
</span>
|
||||
{lost > 0 && <span className="text-ink-4">−{lost}</span>}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
61
src/components/charts/HiringTrendChart.jsx
Normal file
61
src/components/charts/HiringTrendChart.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import { LineChart } from '@mui/x-charts';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { chartSx, TONES, tooltipSx } from './muiChartTheme';
|
||||
|
||||
/**
|
||||
* Cumulative hires over time.
|
||||
*
|
||||
* Deliberately does not draw a line through a single point: with one month of
|
||||
* history there is no trend, and a chart that implies one is worse than a
|
||||
* sentence saying there is not. The empty state is the page's own copy, kept
|
||||
* intact and given the same container as the chart so the section holds its
|
||||
* shape either way.
|
||||
*
|
||||
* `points: [{ label, hires, cumulative }]` — supplied by the page.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function HiringTrendChart({ points = [], emptyState, className = '' }) {
|
||||
const enough = points.length >= 2;
|
||||
|
||||
return (
|
||||
<div className={cn('overflow-hidden rounded-xl border border-border bg-surface', className)}>
|
||||
{enough ? (
|
||||
<div className="px-2 pt-3 sm:px-3">
|
||||
<LineChart
|
||||
height={200}
|
||||
margin={{ top: 16, right: 16, bottom: 24, left: 8 }}
|
||||
xAxis={[{
|
||||
scaleType: 'point',
|
||||
data: points.map((p) => p.label),
|
||||
}]}
|
||||
yAxis={[{ width: 32, tickNumber: 4 }]}
|
||||
series={[{
|
||||
data: points.map((p) => p.cumulative),
|
||||
label: 'Cumulative hires',
|
||||
area: true,
|
||||
showMark: true,
|
||||
curve: 'monotoneX',
|
||||
valueFormatter: (value, ctx) => {
|
||||
const p = points[ctx?.dataIndex ?? 0];
|
||||
return `${value} total · ${p?.hires} this month`;
|
||||
},
|
||||
}]}
|
||||
colors={[TONES.brand]}
|
||||
grid={{ horizontal: true }}
|
||||
skipAnimation
|
||||
sx={{
|
||||
...chartSx,
|
||||
...tooltipSx,
|
||||
'& .MuiAreaElement-root': { fill: TONES.brandSoft },
|
||||
'& .MuiLineElement-root': { strokeWidth: 2 },
|
||||
'& .MuiMarkElement-root': { stroke: TONES.brand, fill: '#fff', strokeWidth: 2 },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
emptyState
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
src/components/charts/muiChartTheme.js
Normal file
86
src/components/charts/muiChartTheme.js
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* KROW styling for MUI X Charts.
|
||||
*
|
||||
* MUI X is the drawing layer only — the surfaces, spacing and typography around
|
||||
* a chart stay KROW's. This module is what stops the two from disagreeing: the
|
||||
* palette is the same one the Recharts components already use (`CHART_TONES`),
|
||||
* and the `sx` block restyles MUI's axes, grid and tooltip to the design
|
||||
* system's hairlines and type scale rather than Material's defaults.
|
||||
*
|
||||
* Everything here is presentation. No chart in this folder computes a value.
|
||||
*/
|
||||
|
||||
import { CHART_TONES } from '@/components/ds/ChartContainer';
|
||||
|
||||
export const TONES = {
|
||||
brand: CHART_TONES.brand,
|
||||
brandSoft: 'rgba(10, 57, 223, 0.14)',
|
||||
warning: '#D97706',
|
||||
warningSoft: 'rgba(217, 119, 6, 0.16)',
|
||||
success: '#16A34A',
|
||||
grid: CHART_TONES.grid,
|
||||
axis: CHART_TONES.axis,
|
||||
ink: '#0F172A',
|
||||
muted: '#64748B',
|
||||
};
|
||||
|
||||
/** Type scale for chart text — matches the caption/overline sizes around it. */
|
||||
const LABEL_FONT = {
|
||||
fontSize: 11,
|
||||
fontFamily: 'var(--font-body)',
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared `sx` for every KROW chart.
|
||||
*
|
||||
* Axis lines and ticks are removed rather than restyled: on a compact chart the
|
||||
* value labels carry the reading, and a full axis frame is the "chart junk" the
|
||||
* design language avoids. The grid stays as a hairline in the same colour the
|
||||
* rest of the product uses for rules.
|
||||
*/
|
||||
export const chartSx = {
|
||||
'& .MuiChartsAxis-line': { display: 'none' },
|
||||
'& .MuiChartsAxis-tick': { display: 'none' },
|
||||
'& .MuiChartsAxis-tickLabel': {
|
||||
...LABEL_FONT,
|
||||
fill: `${TONES.muted} !important`,
|
||||
},
|
||||
'& .MuiChartsGrid-line': {
|
||||
stroke: TONES.grid,
|
||||
strokeDasharray: '3 3',
|
||||
},
|
||||
'& .MuiChartsLegend-root': { display: 'none' },
|
||||
'& .MuiBarLabel-root': {
|
||||
...LABEL_FONT,
|
||||
fontWeight: 600,
|
||||
fill: `${TONES.ink} !important`,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Tooltip chrome matching `Surface`.
|
||||
*
|
||||
* Passed through `slotProps.tooltip` so MUI's popper carries the design
|
||||
* system's radius, hairline border and shadow instead of Material elevation.
|
||||
*/
|
||||
export const tooltipSx = {
|
||||
'& .MuiChartsTooltip-root, & .MuiPopper-root .MuiPaper-root': {
|
||||
borderRadius: '10px',
|
||||
border: `1px solid ${TONES.grid}`,
|
||||
boxShadow: '0 8px 24px -12px rgba(15, 23, 42, 0.25)',
|
||||
fontFamily: 'var(--font-body)',
|
||||
fontSize: 12,
|
||||
},
|
||||
'& .MuiChartsTooltip-table': { fontSize: 12 },
|
||||
'& .MuiChartsTooltip-labelCell, & .MuiChartsTooltip-valueCell': {
|
||||
fontFamily: 'var(--font-body)',
|
||||
},
|
||||
};
|
||||
|
||||
/*
|
||||
* Entry animation is switched off at every call site rather than gated on
|
||||
* `prefers-reduced-motion`. Two reasons: a chart that grows out of the floor is
|
||||
* the decorative motion this product avoids, and MUI's bar transition leaves
|
||||
* every bar at zero height if the frame loop never advances — which is exactly
|
||||
* what happens in an inactive tab or an automated browser.
|
||||
*/
|
||||
101
src/components/ds/ActivityCard.jsx
Normal file
101
src/components/ds/ActivityCard.jsx
Normal file
@@ -0,0 +1,101 @@
|
||||
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.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
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>
|
||||
);
|
||||
}
|
||||
72
src/components/ds/Alert.jsx
Normal file
72
src/components/ds/Alert.jsx
Normal file
@@ -0,0 +1,72 @@
|
||||
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.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
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>
|
||||
);
|
||||
}
|
||||
121
src/components/ds/Avatar.jsx
Normal file
121
src/components/ds/Avatar.jsx
Normal file
@@ -0,0 +1,121 @@
|
||||
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.
|
||||
*/
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
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).
|
||||
*/
|
||||
/** @param {any} props */
|
||||
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>
|
||||
);
|
||||
}
|
||||
174
src/components/ds/ChartContainer.jsx
Normal file
174
src/components/ds/ChartContainer.jsx
Normal file
@@ -0,0 +1,174 @@
|
||||
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 = ['#0838E0', '#F9E547', '#333F48', '#F8E08E', '#D1E0D7', '#062BAF'];
|
||||
|
||||
export const CHART_TONES = {
|
||||
brand: '#0838E0',
|
||||
brandDark: '#062BAF',
|
||||
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.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
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.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
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>
|
||||
);
|
||||
}
|
||||
318
src/components/ds/DataTable.jsx
Normal file
318
src/components/ds/DataTable.jsx
Normal file
@@ -0,0 +1,318 @@
|
||||
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';
|
||||
import { usePreferences } from '@/lib/krowHooks';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
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 ─────────────────────────────────────────────────────────── */
|
||||
/* Row height follows the account's density preference. Only the vertical
|
||||
padding changes — same columns, same type, same behaviour. */
|
||||
const { compactDensity: compact } = usePreferences();
|
||||
const cellPad = compact ? 'py-1.5' : 'py-3';
|
||||
|
||||
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 min-w-[36rem] 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={cn('w-10 px-4', cellPad)}>
|
||||
<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 text-overline uppercase text-ink-4 font-heading whitespace-nowrap',
|
||||
cellPad,
|
||||
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={cn('px-4', cellPad)} 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 text-body-sm text-ink-2 align-middle',
|
||||
compact ? 'py-1.5' : 'py-3.5',
|
||||
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>
|
||||
);
|
||||
}
|
||||
87
src/components/ds/Drawer.jsx
Normal file
87
src/components/ds/Drawer.jsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
|
||||
/**
|
||||
* Panel widths.
|
||||
*
|
||||
* Each entry states its `max-w` as well as its `w`. The underlying Sheet caps a
|
||||
* side panel at `sm:max-w-sm` (24rem), so a width alone was silently ignored on
|
||||
* desktop and every "large" drawer in the app was drawn at phone width — the
|
||||
* reason detail panels wrapped and scrolled far more than their content needed.
|
||||
*/
|
||||
const WIDTHS = {
|
||||
sm: 'w-full sm:w-80 sm:max-w-[20rem]',
|
||||
default: 'w-full sm:w-[28rem] sm:max-w-[28rem]',
|
||||
lg: 'w-full sm:w-[36rem] sm:max-w-[36rem]',
|
||||
/* A record with a two-column body: 704px on desktop, 768px from `2xl`
|
||||
(1536px and up), and never the whole screen — the page behind it stays
|
||||
visible, which is what keeps the drawer a drawer. */
|
||||
xl: 'w-full sm:w-[44rem] sm:max-w-[44rem] 2xl:w-[48rem] 2xl:max-w-[48rem]',
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
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 sm:px-6 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 sm:px-6">{children}</div>
|
||||
|
||||
{footer && (
|
||||
<div className="px-5 sm:px-6 py-4 border-t border-border bg-surface-subtle shrink-0 flex items-center justify-end gap-2">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
76
src/components/ds/EmptyState.jsx
Normal file
76
src/components/ds/EmptyState.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
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.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
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>
|
||||
);
|
||||
}
|
||||
69
src/components/ds/Field.jsx
Normal file
69
src/components/ds/Field.jsx
Normal file
@@ -0,0 +1,69 @@
|
||||
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.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
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>
|
||||
);
|
||||
}
|
||||
190
src/components/ds/FilterBar.jsx
Normal file
190
src/components/ds/FilterBar.jsx
Normal file
@@ -0,0 +1,190 @@
|
||||
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.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function FilterBar({
|
||||
search = null,
|
||||
onSearchChange = null,
|
||||
searchPlaceholder = 'Search...',
|
||||
|
||||
filters = [],
|
||||
/** `{ [key]: string | string[] }` */
|
||||
values = {},
|
||||
onChange = null,
|
||||
|
||||
/** Right-aligned actions — primary buttons, view switchers. */
|
||||
actions = null,
|
||||
/** Rendered under the bar, e.g. a result count. */
|
||||
meta = null,
|
||||
|
||||
/** 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>
|
||||
);
|
||||
}
|
||||
41
src/components/ds/FilterSelect.jsx
Normal file
41
src/components/ds/FilterSelect.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
/**
|
||||
* A compact labelled select pill — the filter control every management page uses.
|
||||
*
|
||||
* It lived in `pages/admin/Positions.jsx` and was imported from there by four
|
||||
* other pages, which made a page the module four other pages depended on. That
|
||||
* was tolerable while every one of them sat under `/admin`; it is not once the
|
||||
* hiring pages are mounted under two consoles, because it makes a page import a
|
||||
* page for a component neither of them owns.
|
||||
*
|
||||
* It belongs here on its own merits anyway: it composes `ui/select` primitives
|
||||
* with design tokens and holds no domain knowledge, which is the definition of
|
||||
* a `ds` component. `FilterBar` is the richer sibling — a whole row of filters
|
||||
* declared as data. This is the single control, for pages that lay out their
|
||||
* own toolbar.
|
||||
*
|
||||
* @param {any} props
|
||||
*/
|
||||
export function FilterSelect({ value, onChange, label, options, className = '' }) {
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
'h-8 w-auto min-w-[7.5rem] rounded-full border border-border/80 bg-white px-3 text-[13px] font-normal text-ink-1 shadow-2xs hover:bg-surface-hover hover:border-border transition-colors outline-none focus:ring-2 focus:ring-krow-blue/20',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
40
src/components/ds/IconButton.jsx
Normal file
40
src/components/ds/IconButton.jsx
Normal file
@@ -0,0 +1,40 @@
|
||||
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.
|
||||
*/
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
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';
|
||||
112
src/components/ds/InsightRow.jsx
Normal file
112
src/components/ds/InsightRow.jsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import * as React from 'react';
|
||||
import { AlertTriangle, ChevronRight, Info, ShieldAlert, TrendingDown } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
/**
|
||||
* InsightRow — one item that needs an operator's attention.
|
||||
*
|
||||
* A list of these replaces the usual grid of alert cards. Six cards is a wall;
|
||||
* six rows is a queue you can work through, which is what "Attention Required"
|
||||
* actually is. Severity sits in a left rail so the list can be triaged by
|
||||
* scanning one column.
|
||||
*
|
||||
* `severity: 'critical' | 'warning' | 'info'`
|
||||
*/
|
||||
|
||||
const SEVERITY = {
|
||||
critical: {
|
||||
icon: ShieldAlert,
|
||||
rail: 'bg-destructive',
|
||||
tile: 'bg-destructive-muted text-destructive',
|
||||
badge: 'destructive',
|
||||
label: 'Critical',
|
||||
},
|
||||
warning: {
|
||||
icon: AlertTriangle,
|
||||
rail: 'bg-warning',
|
||||
tile: 'bg-warning-muted text-warning',
|
||||
badge: 'warning',
|
||||
label: 'Warning',
|
||||
},
|
||||
info: {
|
||||
icon: Info,
|
||||
rail: 'bg-krow-blue',
|
||||
tile: 'bg-krow-blue-tint text-krow-blue',
|
||||
badge: 'info',
|
||||
label: 'Info',
|
||||
},
|
||||
trend: {
|
||||
icon: TrendingDown,
|
||||
rail: 'bg-ink-4',
|
||||
tile: 'bg-surface-sunken text-ink-3',
|
||||
badge: 'neutral',
|
||||
label: 'Trend',
|
||||
},
|
||||
};
|
||||
|
||||
/** @param {any} props */
|
||||
export function InsightRow({
|
||||
severity = 'info',
|
||||
title,
|
||||
detail,
|
||||
/** Short right-aligned figure — a count, an age, a percentage. */
|
||||
metric = null,
|
||||
metricLabel = null,
|
||||
/** Entity the item concerns, e.g. a position or candidate name. */
|
||||
entity = null,
|
||||
onClick = null,
|
||||
actionLabel = 'Review',
|
||||
className = '',
|
||||
}) {
|
||||
const s = SEVERITY[severity] || SEVERITY.info;
|
||||
const Comp = onClick ? 'button' : 'div';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'group relative flex w-full items-start gap-3 py-3 pl-4 pr-3 text-left transition-colors duration-fast',
|
||||
onClick && 'cursor-pointer hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Severity rail — one column to scan rather than five colours to decode. */}
|
||||
<span className={cn('absolute inset-y-2 left-0 w-0.5 rounded-full', s.rail)} aria-hidden="true" />
|
||||
|
||||
<span className={cn('mt-0.5 grid h-7 w-7 shrink-0 place-items-center rounded-lg', s.tile)}>
|
||||
<s.icon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</span>
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-body-sm font-semibold text-ink-1">{title}</span>
|
||||
{entity && <Badge variant="neutral" size="sm">{entity}</Badge>}
|
||||
</span>
|
||||
{detail && <span className="mt-0.5 block text-caption leading-relaxed text-ink-3">{detail}</span>}
|
||||
</span>
|
||||
|
||||
{metric != null && (
|
||||
<span className="shrink-0 text-right">
|
||||
<span className="block font-heading text-body font-bold tabular-nums text-ink-1">{metric}</span>
|
||||
{metricLabel && <span className="block text-[10px] text-ink-4">{metricLabel}</span>}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{onClick && (
|
||||
<span className="mt-0.5 flex shrink-0 items-center gap-0.5 text-caption font-medium text-ink-4 transition-colors group-hover:text-krow-blue">
|
||||
<span className="hidden lg:inline">{actionLabel}</span>
|
||||
<ChevronRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="sr-only">{s.label}</span>
|
||||
</Comp>
|
||||
);
|
||||
}
|
||||
|
||||
/** The list container — dividers between rows, nothing else. */
|
||||
/** @param {any} props */
|
||||
export function InsightList({ children, className = '' }) {
|
||||
return <div className={cn('divide-y divide-border', className)}>{children}</div>;
|
||||
}
|
||||
179
src/components/ds/KpiCard.jsx
Normal file
179
src/components/ds/KpiCard.jsx
Normal file
@@ -0,0 +1,179 @@
|
||||
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.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
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.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
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>
|
||||
);
|
||||
}
|
||||
81
src/components/ds/Layout.jsx
Normal file
81
src/components/ds/Layout.jsx
Normal file
@@ -0,0 +1,81 @@
|
||||
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).
|
||||
*/
|
||||
/** @param {any} props */
|
||||
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. */
|
||||
/** @param {any} props */
|
||||
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.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
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>
|
||||
);
|
||||
}
|
||||
78
src/components/ds/LoadingState.jsx
Normal file
78
src/components/ds/LoadingState.jsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import * as React from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Spinner — the only spinner in the system.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
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.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
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.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
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.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function LoadingOverlay({ show = false, 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>
|
||||
);
|
||||
}
|
||||
146
src/components/ds/MetricStrip.jsx
Normal file
146
src/components/ds/MetricStrip.jsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import * as React from 'react';
|
||||
import { ArrowDownRight, ArrowUpRight, Minus } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* MetricStrip — modern executive operational metric card strip.
|
||||
*
|
||||
* `items: [{ label, value, delta?, deltaLabel?, invertTrend?, sub?, icon?, tone? }]`
|
||||
*/
|
||||
|
||||
const TONE = {
|
||||
default: 'text-ink-1',
|
||||
brand: 'text-blue-600 dark:text-blue-400',
|
||||
success: 'text-emerald-600 dark:text-emerald-400',
|
||||
warning: 'text-amber-600 dark:text-amber-400',
|
||||
risk: 'text-red-600 dark:text-red-400',
|
||||
};
|
||||
|
||||
const ICON_BG = {
|
||||
default: 'bg-surface-subtle text-ink-3',
|
||||
brand: 'bg-blue-50 text-blue-600 dark:bg-blue-950/60 dark:text-blue-400',
|
||||
success: 'bg-emerald-50 text-emerald-600 dark:bg-emerald-950/60 dark:text-emerald-400',
|
||||
warning: 'bg-amber-50 text-amber-600 dark:bg-amber-950/60 dark:text-amber-400',
|
||||
risk: 'bg-red-50 text-red-600 dark:bg-red-950/60 dark:text-red-400',
|
||||
};
|
||||
|
||||
const ACCENT_BAR = {
|
||||
default: 'from-gray-400 to-gray-600',
|
||||
brand: 'from-blue-500 to-indigo-500',
|
||||
success: 'from-emerald-500 to-teal-500',
|
||||
warning: 'from-amber-500 to-orange-500',
|
||||
risk: 'from-red-500 to-rose-500',
|
||||
};
|
||||
|
||||
function Delta({ delta, label, invert }) {
|
||||
if (typeof delta !== 'number') return null;
|
||||
|
||||
const flat = delta === 0;
|
||||
const good = invert ? delta < 0 : delta > 0;
|
||||
const Icon = flat ? Minus : delta > 0 ? ArrowUpRight : ArrowDownRight;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-0.5 rounded-full px-2 py-0.5 text-[11px] font-semibold tabular-nums border',
|
||||
flat
|
||||
? 'bg-surface-subtle text-ink-4 border-border/60'
|
||||
: good
|
||||
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/60 dark:text-emerald-400 border-emerald-200/50'
|
||||
: 'bg-red-50 text-red-700 dark:bg-red-950/60 dark:text-red-400 border-red-200/50'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||
{flat ? '0%' : `${Math.abs(delta)}%`}
|
||||
{label && <span className="ml-1 font-normal opacity-80">{label}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {any} props */
|
||||
export function MetricStrip({ items = [], columns, className, loading = false }) {
|
||||
const count = columns ?? Math.min(items.length, 6);
|
||||
|
||||
const gridCols = {
|
||||
2: 'sm:grid-cols-2',
|
||||
3: 'sm:grid-cols-3',
|
||||
4: 'grid-cols-2 sm:grid-cols-4',
|
||||
5: 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-5',
|
||||
6: 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6',
|
||||
}[count] || 'grid-cols-2 sm:grid-cols-4';
|
||||
|
||||
return (
|
||||
<div className={cn('grid gap-3.5', gridCols, className)}>
|
||||
{items.map((item) => {
|
||||
const IconComponent = item.icon;
|
||||
const toneKey = item.tone || 'default';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.label}
|
||||
className="group relative flex flex-col justify-between rounded-xl border border-border/80 bg-surface p-4 shadow-xs backdrop-blur-xs transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-blue-500/40"
|
||||
>
|
||||
<div>
|
||||
{/* Header: Label & Icon */}
|
||||
<div className="flex items-center justify-between gap-1.5">
|
||||
{/* Wraps on a phone, clips on everything wider.
|
||||
Two of these sit side by side at 320px, which leaves about
|
||||
110px for the label — enough for "OPEN POSITIONS" over two
|
||||
lines and not enough for it on one, so a flat `truncate`
|
||||
turned every metric on the Control Center into "OPEN POSI…".
|
||||
From `sm` the strip is three or six across with room for the
|
||||
label on one line, which is where clipping was the intent. */}
|
||||
<p className="min-w-0 line-clamp-2 text-[10px] font-bold uppercase tracking-wider text-ink-4 sm:line-clamp-1">
|
||||
{item.label}
|
||||
</p>
|
||||
{IconComponent && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-7 w-7 shrink-0 items-center justify-center rounded-lg shadow-xs',
|
||||
ICON_BG[toneKey] || ICON_BG.default
|
||||
)}
|
||||
>
|
||||
<IconComponent className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Metric Main Display & Delta */}
|
||||
{loading ? (
|
||||
<div className="mt-2.5 h-8 w-16 animate-pulse rounded-lg bg-surface-sunken" />
|
||||
) : (
|
||||
<div className="mt-2 flex flex-wrap items-baseline gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'font-heading text-title-xl font-bold leading-none tabular-nums',
|
||||
TONE[toneKey] || TONE.default
|
||||
)}
|
||||
>
|
||||
{item.value}
|
||||
</span>
|
||||
<Delta delta={item.delta} label={item.deltaLabel} invert={item.invertTrend} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sub-label & Accent bar */}
|
||||
<div className="mt-3.5 space-y-2">
|
||||
{item.sub && (
|
||||
<span className="inline-flex items-center rounded-full bg-surface-subtle px-2 py-0.5 text-[10px] font-semibold text-ink-3 border border-border/60">
|
||||
{item.sub}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'h-0.5 w-full rounded-full bg-gradient-to-r opacity-40 group-hover:opacity-100 transition-opacity',
|
||||
ACCENT_BAR[toneKey] || ACCENT_BAR.default
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
127
src/components/ds/Modal.jsx
Normal file
127
src/components/ds/Modal.jsx
Normal file
@@ -0,0 +1,127 @@
|
||||
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.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
/** @param {any} props */
|
||||
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
|
||||
/* `dvh`, not `vh`: on a phone `100vh` is the *large* viewport, which
|
||||
includes the browser chrome that is currently covering the bottom of
|
||||
the screen — so a `90vh` dialog put its footer, and its confirm
|
||||
button, under the address bar. */
|
||||
className={cn('p-0 gap-0 overflow-hidden max-h-[calc(100dvh-2rem)] 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.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function ConfirmModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
title = 'Are you sure?',
|
||||
description,
|
||||
confirmLabel = 'Confirm',
|
||||
cancelLabel = 'Cancel',
|
||||
onConfirm,
|
||||
tone = 'destructive',
|
||||
icon = null,
|
||||
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>
|
||||
);
|
||||
}
|
||||
172
src/components/ds/MultiSelect.jsx
Normal file
172
src/components/ds/MultiSelect.jsx
Normal file
@@ -0,0 +1,172 @@
|
||||
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 }`.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function MultiSelect({
|
||||
options = [],
|
||||
value = [],
|
||||
onChange,
|
||||
placeholder = 'Select...',
|
||||
/** Chips shown before collapsing into "+N more". */
|
||||
maxChips = 2,
|
||||
searchThreshold = 8,
|
||||
disabled = false,
|
||||
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>
|
||||
);
|
||||
}
|
||||
88
src/components/ds/PageHeader.jsx
Normal file
88
src/components/ds/PageHeader.jsx
Normal file
@@ -0,0 +1,88 @@
|
||||
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.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
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>
|
||||
);
|
||||
}
|
||||
134
src/components/ds/Pagination.jsx
Normal file
134
src/components/ds/Pagination.jsx
Normal file
@@ -0,0 +1,134 @@
|
||||
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.
|
||||
*/
|
||||
/**
|
||||
* @param {number} current
|
||||
* @param {number} total
|
||||
* @param {number} [siblings]
|
||||
* @returns {(number | string)[]}
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
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>
|
||||
);
|
||||
}
|
||||
259
src/components/ds/Progress.jsx
Normal file
259
src/components/ds/Progress.jsx
Normal file
@@ -0,0 +1,259 @@
|
||||
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.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
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.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
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: '#0838E0',
|
||||
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.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function ProgressSteps({ steps = [], current = 0, className = '', onStepClick = null }) {
|
||||
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.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
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>
|
||||
);
|
||||
}
|
||||
102
src/components/ds/SearchInput.jsx
Normal file
102
src/components/ds/SearchInput.jsx
Normal file
@@ -0,0 +1,102 @@
|
||||
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.
|
||||
*/
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
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';
|
||||
76
src/components/ds/SectionHeader.jsx
Normal file
76
src/components/ds/SectionHeader.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
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
|
||||
*/
|
||||
/** @param {any} props */
|
||||
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>
|
||||
);
|
||||
}
|
||||
148
src/components/ds/Skeleton.jsx
Normal file
148
src/components/ds/Skeleton.jsx
Normal file
@@ -0,0 +1,148 @@
|
||||
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).
|
||||
*/
|
||||
/** @param {any} props */
|
||||
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. */
|
||||
/** @param {any} props */
|
||||
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. */
|
||||
/** @param {any} props */
|
||||
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. */
|
||||
/** @param {any} props */
|
||||
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. */
|
||||
/** @param {any} props */
|
||||
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. */
|
||||
/** @param {any} props */
|
||||
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. */
|
||||
/** @param {any} props */
|
||||
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. */
|
||||
/** @param {any} props */
|
||||
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>
|
||||
);
|
||||
}
|
||||
97
src/components/ds/StatusBadge.jsx
Normal file
97
src/components/ds/StatusBadge.jsx
Normal file
@@ -0,0 +1,97 @@
|
||||
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.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
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>
|
||||
);
|
||||
}
|
||||
73
src/components/ds/Surface.jsx
Normal file
73
src/components/ds/Surface.jsx
Normal file
@@ -0,0 +1,73 @@
|
||||
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-[#0838E0]/60 focus-visible:ring-offset-2',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'glass',
|
||||
elevation: 'sm',
|
||||
radius: 'xl',
|
||||
padding: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
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 };
|
||||
120
src/components/ds/Tabs.jsx
Normal file
120
src/components/ds/Tabs.jsx
Normal file
@@ -0,0 +1,120 @@
|
||||
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.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
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>
|
||||
);
|
||||
}
|
||||
100
src/components/ds/Timeline.jsx
Normal file
100
src/components/ds/Timeline.jsx
Normal file
@@ -0,0 +1,100 @@
|
||||
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.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
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>
|
||||
);
|
||||
}
|
||||
152
src/components/ds/Toggle.jsx
Normal file
152
src/components/ds/Toggle.jsx
Normal file
@@ -0,0 +1,152 @@
|
||||
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.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
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).
|
||||
*/
|
||||
/**
|
||||
* Two treatments for the same control.
|
||||
*
|
||||
* `brand` states a choice that changes what the data *says* — a date range, a
|
||||
* scope — so the selection carries the page's accent colour and reads as a
|
||||
* filter. `surface` states a choice that changes only how the same data is
|
||||
* *drawn*, so the selection is a raised white chip on a sunken track: present
|
||||
* enough to show which mode is on, quiet enough not to compete with the real
|
||||
* filters sitting beside it in the same toolbar.
|
||||
*/
|
||||
const SEGMENT_VARIANTS = {
|
||||
brand: {
|
||||
track: 'bg-surface-sunken border-border',
|
||||
active: 'bg-krow-blue text-white shadow-sm',
|
||||
inactive: 'text-ink-3 hover:text-ink-1',
|
||||
},
|
||||
surface: {
|
||||
track: 'bg-surface-sunken border-border',
|
||||
active: 'bg-surface text-ink-1 shadow-xs',
|
||||
inactive: 'text-ink-4 hover:text-ink-2',
|
||||
},
|
||||
};
|
||||
|
||||
/** @param {any} props */
|
||||
export function SegmentedToggle({
|
||||
options = [],
|
||||
value = null,
|
||||
onChange = null,
|
||||
size = 'default',
|
||||
variant = 'brand',
|
||||
className = '',
|
||||
ariaLabel = '',
|
||||
}) {
|
||||
const normalized = options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o));
|
||||
const tone = SEGMENT_VARIANTS[variant] || SEGMENT_VARIANTS.brand;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={ariaLabel}
|
||||
className={cn('inline-flex items-center gap-0.5 rounded-full border p-0.5', tone.track, className)}
|
||||
>
|
||||
{normalized.map((option) => {
|
||||
const active = option.value === value;
|
||||
const Icon = option.icon;
|
||||
/* Icon-only options carry their name on the button instead of beside
|
||||
it, so the control stays compact without becoming unlabelled. */
|
||||
const iconOnly = Boolean(Icon) && !option.label;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
aria-label={option.ariaLabel || (iconOnly ? String(option.value) : undefined)}
|
||||
title={option.ariaLabel}
|
||||
onClick={() => onChange?.(option.value)}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center gap-1.5 rounded-full font-medium',
|
||||
'transition-all duration-base ease-out focus-ring-inset',
|
||||
iconOnly
|
||||
? (size === 'sm' ? 'h-6 w-7' : 'h-7 w-8')
|
||||
: (size === 'sm' ? 'px-2.5 py-1 text-caption' : 'px-3 py-1.5 text-body-sm'),
|
||||
active ? tone.active : tone.inactive
|
||||
)}
|
||||
>
|
||||
{Icon && <Icon className={size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4'} aria-hidden="true" />}
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
136
src/components/ds/index.js
Normal file
136
src/components/ds/index.js
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* 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 { FilterSelect } from './FilterSelect';
|
||||
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';
|
||||
/* Radix Collapsible, already present and previously unexported. The agent
|
||||
configuration screen is a stack of long sections, and a page that opened all
|
||||
of them at once would bury the one being edited. */
|
||||
export {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
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 { MetricStrip } from './MetricStrip';
|
||||
export { InsightList, InsightRow } from './InsightRow';
|
||||
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: /** @type {import('react-hot-toast').ToastPosition} */ ('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,
|
||||
};
|
||||
458
src/components/krow/AIInterviewModal.jsx
Normal file
458
src/components/krow/AIInterviewModal.jsx
Normal file
@@ -0,0 +1,458 @@
|
||||
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 } 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 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 {
|
||||
setError('Interview connection issue. Please retry.');
|
||||
}
|
||||
}, [jobTitle, speak, language]);
|
||||
|
||||
const startListening = useCallback(() => {
|
||||
const win = /** @type {any} */ (window);
|
||||
const SpeechRecognition = win.SpeechRecognition || win.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]);
|
||||
|
||||
/**
|
||||
* Write the interview and let the server carry the verdict onto the
|
||||
* application.
|
||||
*
|
||||
* There is deliberately no second call here. `POST /ai-interviews` moves the
|
||||
* application to `interview`, sets `interview_id` and copies the score across
|
||||
* in the same transaction as the interview itself. Doing it from the browser
|
||||
* was two independent requests, and for a talent user sitting their own
|
||||
* interview the second one was a 403 — the interview existed, the application
|
||||
* still read `applied`, and every consumer counting
|
||||
* `status === 'interview' || interview_id` could not see it.
|
||||
*/
|
||||
const finishInterview = useCallback(async (finalMessages) => {
|
||||
setPhase('evaluating');
|
||||
try {
|
||||
const result = await evaluateInterview(finalMessages, job, candidateName, language);
|
||||
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,
|
||||
});
|
||||
setEvaluation(result);
|
||||
setPhase('done');
|
||||
} catch {
|
||||
setError('Evaluation failed. Please try again.');
|
||||
setPhase('active');
|
||||
}
|
||||
}, [application, job, candidateName, createInterview, 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-[#0838E0] 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-[#0838E0] 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-[#0838E0] 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-[#0838E0] 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-[#0838E0] text-white' : 'bg-white text-[#6B7280] hover:bg-[#F3F4F6]'
|
||||
)}
|
||||
>
|
||||
Español
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={startInterview} className="bg-[#0838E0] hover:bg-[#062BAF] 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-[#0838E0] 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-[#0838E0] 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-[#0838E0] hover:bg-[#062BAF]"
|
||||
>
|
||||
Send Answer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'evaluating' && (
|
||||
<div className="py-12 text-center">
|
||||
<Loader2 className="w-10 h-10 text-[#0838E0] 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-[#0838E0] hover:bg-[#062BAF]">Done</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
321
src/components/krow/CandidateCard.jsx
Normal file
321
src/components/krow/CandidateCard.jsx
Normal file
@@ -0,0 +1,321 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useProductNavigate } from '@/lib/useProduct';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu';
|
||||
import { Phone, ListChecks, MessagesSquare, X, Trash2, ChevronDown, UserCheck, Briefcase, ArrowRight } from 'lucide-react';
|
||||
import { Image } from '@/components/ui/image';
|
||||
import { getScoreBand } from '@/lib/talentHome';
|
||||
import { rankOf, STAGE_ORDER } from '@/lib/hiringRecords';
|
||||
import CandidateExpandedDetails from '@/components/krow/CandidateExpandedDetails';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { krow } from '@/api/krowClient';
|
||||
|
||||
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]' },
|
||||
/* The seventh `application_status` value. Without it an assigned worker fell
|
||||
through to the `applied` fallback below and was badged "Applied" — the
|
||||
furthest-along candidate on the page shown as the least far along. */
|
||||
assigned: { badge: 'bg-[#E8F0FE] text-[#1A56DB]', label: 'Assigned', 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 '#0838E0';
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {any} props */
|
||||
export default function CandidateCard({ application, jobTitle, rank, onAction, onCall, onShortlist, onAdvance, onDecline, onDelete, onHire }) {
|
||||
const navigate = useProductNavigate();
|
||||
const position = jobTitle || application.job_title || '';
|
||||
const status = application.status || 'applied';
|
||||
/* An unknown status is not "Applied". Falling back to a real stage claims a
|
||||
position on the ladder the record does not have; showing the raw value is
|
||||
honest and makes the gap visible. */
|
||||
const style = STATUS_STYLES[status] || {
|
||||
...STATUS_STYLES.applied,
|
||||
label: String(status || 'Unknown').replace(/_/g, ' '),
|
||||
};
|
||||
const score = application.ai_score || 0;
|
||||
const canTakeAction = status !== 'hired' && status !== 'rejected';
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
|
||||
/* A real dialler, and only when there is a number to dial. The card used to
|
||||
fall back to a compose window here; that window sent nothing, so a card
|
||||
with no phone number offered an action that could not happen. */
|
||||
const handleCall = (e) => {
|
||||
e?.stopPropagation();
|
||||
if (onCall) onCall(application);
|
||||
else if (application.phone) window.location.href = `tel:${application.phone}`;
|
||||
};
|
||||
|
||||
/* The two stage transitions this card can make.
|
||||
Both are ordinary `PATCH /job-applications/{id}` writes — the same call the
|
||||
Candidates page makes — so a decision taken from the card and one taken
|
||||
from the list produce the same record. */
|
||||
const patchStatus = async (status, verb) => {
|
||||
try {
|
||||
await krow.entities.JobApplication.update(application.id, { status });
|
||||
toast.success(`${application.applicant_name} ${verb}`);
|
||||
} catch {
|
||||
toast.error(`Could not move ${application.applicant_name}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShortlist = (e) => {
|
||||
e?.stopPropagation();
|
||||
if (onShortlist) onShortlist(application);
|
||||
else patchStatus('shortlisted', 'shortlisted');
|
||||
};
|
||||
|
||||
const handleAdvance = (e) => {
|
||||
e?.stopPropagation();
|
||||
if (onAdvance) onAdvance(application);
|
||||
else patchStatus('interview', 'moved to interview');
|
||||
};
|
||||
|
||||
const handleDecline = async (e) => {
|
||||
e?.stopPropagation();
|
||||
if (onDecline) {
|
||||
onDecline(application);
|
||||
} else {
|
||||
try {
|
||||
await krow.entities.JobApplication.update(application.id, { status: 'rejected' });
|
||||
toast.success(`${application.applicant_name} declined`);
|
||||
} catch {
|
||||
toast.error('Failed to decline candidate');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (e) => {
|
||||
e?.stopPropagation();
|
||||
if (onDelete) {
|
||||
onDelete(application);
|
||||
} else {
|
||||
try {
|
||||
await krow.entities.JobApplication.delete(application.id);
|
||||
toast.success(`${application.applicant_name} deleted`);
|
||||
} catch {
|
||||
toast.error('Failed to delete candidate');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('border rounded-xl p-4 transition-all hover:shadow-sm', style.card)}>
|
||||
{/* The row wraps below `sm`, and only below `sm`.
|
||||
Its fixed furniture — rank, portrait, score ring and the action
|
||||
cluster — adds up to about 400px before the name has a single pixel,
|
||||
so on a 320px phone the flexible middle was being squeezed to nothing
|
||||
and the candidate's name truncated to one character while the buttons
|
||||
hung off the side of the screen. Wrapping gives the name the width and
|
||||
moves the controls to their own line; `sm:flex-nowrap` restores the
|
||||
single desktop row exactly. */}
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-3 cursor-pointer sm:flex-nowrap sm:gap-4"
|
||||
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-[#0838E0]">{rank}</span>
|
||||
</div>
|
||||
|
||||
{/* Avatar — 88-110px target size */}
|
||||
<div className="w-16 h-16 sm:w-24 sm:h-24 rounded-2xl bg-[#E5E7EB] flex items-center justify-center text-white font-semibold text-[15px] shrink-0 overflow-hidden shadow-sm">
|
||||
<Image
|
||||
src={application.selfie_url || `https://images.unsplash.com/${getPortraitSeed(application.applicant_name)}?w=200&h=200&fit=crop&crop=faces`}
|
||||
alt={application.applicant_name}
|
||||
className="w-full h-full object-cover"
|
||||
fittingType="fill"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info. `basis-40` rather than the flex default of 0 so the name has a
|
||||
floor to claim on a phone — under it the row wraps instead of
|
||||
shaving the one column that carries meaning. */}
|
||||
<div className="flex-1 basis-40 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-[#0838E0]">
|
||||
<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-[#0838E0] text-white text-[10px] font-bold">
|
||||
+ {application.ai_match_label}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Score and actions.
|
||||
`sm:contents` dissolves this wrapper from the tablet breakpoint up,
|
||||
so above `sm` the ring and the buttons are direct children of the
|
||||
row exactly as they always were — same order, same gaps, same
|
||||
desktop geometry. Below it they travel together onto the row's
|
||||
second line. */}
|
||||
<div className="flex w-full items-center justify-between gap-3 sm:contents">
|
||||
{/* Score */}
|
||||
{status !== 'applied' && <ScoreRing score={score} />}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
{/* The full record, at the route that already serves it.
|
||||
Clicking the row expands a summary in place, which is the right
|
||||
default while comparing people — but the complete profile had no
|
||||
way in from this list at all, so the same candidate opened from a
|
||||
recommendation and from here led to two different depths of the
|
||||
same person. One link, one route, one profile. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/admin/candidates/${application.id}`)}
|
||||
className="hidden sm:inline-flex items-center gap-1 rounded-lg px-2.5 py-1.5 text-[12px] font-semibold text-[#0838E0]
|
||||
transition-colors hover:bg-[#EEF3FE] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0838E0]/40"
|
||||
>
|
||||
View profile
|
||||
<ArrowRight className="w-3 h-3" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
{onAction && canTakeAction && (
|
||||
<button
|
||||
onClick={() => onAction(application)}
|
||||
className="px-3 py-1.5 rounded-lg bg-[#0838E0] hover:bg-[#062BAF] 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-52 p-1.5 bg-white border border-[#E5E7EB] shadow-xl rounded-xl">
|
||||
{application.phone && (
|
||||
<DropdownMenuItem onClick={handleCall} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
|
||||
<Phone className="w-4 h-4 mr-2.5 text-[#059669]" />
|
||||
<span className="text-[#059669] text-[13px] font-medium">Call</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{/* Offered only while it is still ahead of this candidate.
|
||||
`rankOf` puts `rejected` back at the screened rung, so a
|
||||
declined candidate can be shortlisted again rather than
|
||||
being stuck — but nobody already at interview is offered a
|
||||
step backwards. */}
|
||||
{rankOf(application.status) < STAGE_ORDER.indexOf('shortlisted') && (
|
||||
<DropdownMenuItem onClick={handleShortlist} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
|
||||
<ListChecks className="w-4 h-4 mr-2.5 text-[#0838E0]" />
|
||||
<span className="text-[#0838E0] text-[13px] font-medium">Shortlist</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{rankOf(application.status) < STAGE_ORDER.indexOf('interview') && (
|
||||
<DropdownMenuItem onClick={handleAdvance} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
|
||||
<MessagesSquare className="w-4 h-4 mr-2.5 text-[#0838E0]" />
|
||||
<span className="text-[#0838E0] text-[13px] font-medium">Move to interview</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{onHire && (
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onHire(application); }} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
|
||||
<UserCheck className="w-4 h-4 mr-2.5 text-[#059669]" />
|
||||
<span className="text-[#059669] text-[13px] font-medium">Hire candidate</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem onClick={handleDecline} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
|
||||
<X className="w-4 h-4 mr-2.5 text-[#DC2626]" />
|
||||
<span className="text-[#DC2626] text-[13px] font-medium">Decline candidate</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={handleDelete} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
|
||||
<Trash2 className="w-4 h-4 mr-2.5 text-[#DC2626]" />
|
||||
<span className="text-[#DC2626] text-[13px] font-medium">Delete candidate</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{expanded && <CandidateExpandedDetails application={application} />}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
147
src/components/krow/CandidateExpandedDetails.jsx
Normal file
147
src/components/krow/CandidateExpandedDetails.jsx
Normal file
@@ -0,0 +1,147 @@
|
||||
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 },
|
||||
];
|
||||
|
||||
/** @param {any} props */
|
||||
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-[#0838E0] 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} />
|
||||
|
||||
{/* Detailed Match Signals — 15 Score Dimensions */}
|
||||
{Object.keys(breakdown).length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-[10px] font-bold text-[#9CA3AF] tracking-wider uppercase">MATCH SIGNALS</h4>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2">
|
||||
{METRIC_CONFIG.map(({ key, label, Icon }) => {
|
||||
const val = breakdown[key] ?? 90;
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between px-2.5 py-1.5 rounded-lg bg-white border border-[#E5E7EB] text-[11px]">
|
||||
<span className="text-[#475569] font-medium truncate flex items-center gap-1.5">
|
||||
<Icon className="w-3.5 h-3.5 text-[#94A3B8]" />
|
||||
{label}
|
||||
</span>
|
||||
<span className="font-bold text-[#0838E0] tabular-nums">{val}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</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-[#475569] leading-relaxed font-normal">{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-[#EEF3FE] rounded-xl p-3.5 border border-[#DBEAFE]">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<Check className="w-3.5 h-3.5 text-[#0838E0]" strokeWidth={2.5} />
|
||||
<h4 className="text-[11px] font-bold text-[#0838E0] 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-[#0838E0] font-bold">✓</span>
|
||||
<span>{s}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{gaps.length > 0 && (
|
||||
<div className="bg-[#FFF7ED] rounded-xl p-3.5 border border-[#FFEDD5]">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-[#C2410C]" strokeWidth={2.5} />
|
||||
<h4 className="text-[11px] font-bold text-[#C2410C] tracking-wide">GAPS</h4>
|
||||
</div>
|
||||
<ul className="space-y-1.5">
|
||||
{gaps.map((g, i) => (
|
||||
<li key={i} className="text-[12px] text-[#C2410C] 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-[#EEF3FE] text-[#0838E0] 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>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user