fix error line
This commit is contained in:
@@ -16,6 +16,6 @@
|
||||
"resolveJsonModule": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/components/**/*.js", "src/pages/**/*.jsx", "src/layouts/**/*.jsx"],
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "src/components/ui", "src/api", "src/lib"]
|
||||
}
|
||||
|
||||
412
scripts/skill-check.mjs
Normal file
412
scripts/skill-check.mjs
Normal file
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* Skill pipeline check — Owliver skills and Board (UI) skills.
|
||||
*
|
||||
* Runs the *real* module graph through Vite, so `import.meta.glob`, the `@/`
|
||||
* alias and the Markdown loading all behave exactly as they do in the app. That
|
||||
* matters: the reliability problem this script exists for was never in the
|
||||
* Markdown, it was in what the pipeline did with a definition it could only
|
||||
* partly read, and a mock of that pipeline would have reproduced none of it.
|
||||
*
|
||||
* node scripts/skill-check.mjs # against source, as the dev server sees it
|
||||
* node scripts/skill-check.mjs --dist # also assert the built bundle carries every definition
|
||||
*
|
||||
* Exits non-zero on failure, so it can gate a build.
|
||||
*/
|
||||
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { createServer } from 'vite';
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const results = [];
|
||||
const record = (name, pass, detail = '') => {
|
||||
results.push({ name, pass, detail });
|
||||
const mark = pass ? ' ok ' : ' FAIL ';
|
||||
console.log(`[${mark}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
};
|
||||
|
||||
const server = await createServer({
|
||||
root: ROOT,
|
||||
server: { middlewareMode: true },
|
||||
appType: 'custom',
|
||||
logLevel: 'error',
|
||||
});
|
||||
|
||||
const reg = await server.ssrLoadModule('/src/lib/skills/registry.js');
|
||||
const placement = await server.ssrLoadModule('/src/components/ai-assistant/placement.js');
|
||||
|
||||
const contextFor = (pageKey) => Object.entries(placement.PLACEMENT_ROUTES)
|
||||
.find(([route]) => reg.pageKeyForRoute(route) === pageKey)?.[1] ?? null;
|
||||
|
||||
/* ── 1. Discovery ─────────────────────────────────────────────────────────── */
|
||||
console.log('\n── Discovery ──');
|
||||
|
||||
const onDisk = readdirSync(join(ROOT, 'src/skills'))
|
||||
.flatMap((dir) => readdirSync(join(ROOT, 'src/skills', dir)).map((f) => `${dir}/${f}`))
|
||||
.filter((f) => f.endsWith('.md'));
|
||||
|
||||
record(
|
||||
'every .md under src/skills registers',
|
||||
reg.SKILLS.length === onDisk.length,
|
||||
`${reg.SKILLS.length} registered / ${onDisk.length} files`
|
||||
);
|
||||
|
||||
record(
|
||||
'no duplicate skill ids',
|
||||
new Set(reg.SKILLS.map((s) => s.id)).size === reg.SKILLS.length,
|
||||
`${new Set(reg.SKILLS.map((s) => s.id)).size} unique ids`
|
||||
);
|
||||
|
||||
record(
|
||||
'every skill declares at least one page',
|
||||
reg.SKILLS.every((s) => s.pages.length > 0)
|
||||
);
|
||||
|
||||
/* Both managed lists are non-empty — the Workspace → Skills tabs. */
|
||||
const uiFacet = reg.skillsWithFacet(reg.SKILLS, 'ui');
|
||||
const owliverFacet = reg.skillsWithFacet(reg.SKILLS, 'owliver');
|
||||
record('Owliver Skills list is populated', owliverFacet.length > 0, `${owliverFacet.length} skills`);
|
||||
record(
|
||||
'Board Skills list resolves without error',
|
||||
Array.isArray(uiFacet),
|
||||
`${uiFacet.length} skills declare a \`ui:\` block`
|
||||
);
|
||||
|
||||
/* ── 2. Registration integrity: nothing half-loads in silence ─────────────── */
|
||||
console.log('\n── Registration integrity ──');
|
||||
|
||||
const clean = reg.readSkillRegistry([]);
|
||||
record(
|
||||
'shipped registry reports no diagnostics',
|
||||
clean.diagnostics.length === 0,
|
||||
clean.diagnostics.map((d) => d.message).join(' | ') || 'none'
|
||||
);
|
||||
|
||||
record(
|
||||
'every shipped skill registered every capability it declared',
|
||||
reg.SKILLS.every((s) => !(s.owliverErrors?.length || s.uiErrors?.length))
|
||||
);
|
||||
|
||||
/* An unreadable stored definition must be REPORTED, not swallowed. */
|
||||
const brokenYaml = `---\nid: broken\n name: bad indent\n---\n# Broken\n`;
|
||||
const withBroken = reg.readSkillRegistry([{ path: 'custom/broken.md', raw: brokenYaml }]);
|
||||
record(
|
||||
'unreadable stored skill is reported, not silently dropped',
|
||||
withBroken.diagnostics.some((d) => d.kind === 'unreadable'),
|
||||
withBroken.diagnostics.find((d) => d.kind === 'unreadable')?.message ?? 'NO DIAGNOSTIC'
|
||||
);
|
||||
|
||||
/* A definition with one bad capability keeps the good one AND says so. */
|
||||
const partial = `---
|
||||
id: partial-check
|
||||
name: Partial Check
|
||||
description: One resolvable capability and one that names no source.
|
||||
pages:
|
||||
- positions
|
||||
status: active
|
||||
owliver:
|
||||
enabled: true
|
||||
capabilities:
|
||||
- summary
|
||||
- flow
|
||||
responses:
|
||||
summary:
|
||||
source: position.activity
|
||||
periods:
|
||||
- today
|
||||
---
|
||||
|
||||
# Partial Check
|
||||
`;
|
||||
const withPartial = reg.readSkillRegistry([{ path: 'custom/partial.md', raw: partial }]);
|
||||
const partialSkill = withPartial.skills.find((s) => s.id === 'partial-check');
|
||||
record(
|
||||
'partly-resolvable skill still registers its good capability',
|
||||
partialSkill?.owliver.capabilities.includes('summary'),
|
||||
`capabilities = ${JSON.stringify(partialSkill?.owliver.capabilities)}`
|
||||
);
|
||||
record(
|
||||
'and the capability it LOST is reported',
|
||||
withPartial.diagnostics.some((d) => d.kind === 'incomplete' && d.skillId === 'partial-check'),
|
||||
withPartial.diagnostics.find((d) => d.kind === 'incomplete')?.message ?? 'NO DIAGNOSTIC'
|
||||
);
|
||||
|
||||
/* Shadowing a built-in is allowed, but must be visible. */
|
||||
const shadow = `---\nid: create-position\nname: Shadow\ndescription: d\npages:\n - positions\nstatus: active\n---\n\n# Shadow\n`;
|
||||
const withShadow = reg.readSkillRegistry([{ path: 'custom/shadow.md', raw: shadow }]);
|
||||
record(
|
||||
'a custom skill overriding a built-in is reported',
|
||||
withShadow.diagnostics.some((d) => d.kind === 'shadowed' && d.skillId === 'create-position'),
|
||||
withShadow.diagnostics.find((d) => d.kind === 'shadowed')?.message ?? 'NO DIAGNOSTIC'
|
||||
);
|
||||
|
||||
/* ── 3. Markdown parsing: no silent content loss ──────────────────────────── */
|
||||
console.log('\n── Markdown parsing ──');
|
||||
|
||||
const cp = reg.SKILLS.find((s) => s.id === 'create-position');
|
||||
record(
|
||||
'wrapped bullets are read whole',
|
||||
cp.capabilities.some((c) => c.endsWith('out of a single sentence.')),
|
||||
cp.capabilities.find((c) => c.includes('single sentence')) ?? 'TRUNCATED'
|
||||
);
|
||||
|
||||
record(
|
||||
'conversation steps all parse',
|
||||
cp.conversation.length === 7 && cp.conversation.every((s) => s.field && s.question),
|
||||
`${cp.conversation.length} steps`
|
||||
);
|
||||
|
||||
const numbered = reg.parseSkill(
|
||||
`---\nid: numbered\nname: Numbered\ndescription: d\npages:\n - positions\nstatus: active\n---\n\n`
|
||||
+ `## Capabilities\n\n1. First instruction.\n2. Second instruction.\n3. Third instruction.\n`,
|
||||
{ custom: true }
|
||||
);
|
||||
record(
|
||||
'numbered instructions are preserved',
|
||||
numbered.capabilities.length === 3,
|
||||
JSON.stringify(numbered.capabilities)
|
||||
);
|
||||
|
||||
/* A section ends at the next `##`, and a heading that merely starts with the
|
||||
same word is a different heading. Both halves matter: the first is what stops
|
||||
`Capabilities` swallowing the rest of the file, the second is what stops a
|
||||
near-miss heading being read as the real one. */
|
||||
const bounded = reg.parseSkill(
|
||||
`---\nid: bounded\nname: Bounded\ndescription: d\npages:\n - positions\nstatus: active\n---\n\n`
|
||||
+ `## Capabilities\n\n- one\n- two\n\n## Capabilities (v2)\n\n- three\n\n## Actions\n\n- four\n`,
|
||||
{ custom: true }
|
||||
);
|
||||
record(
|
||||
'a section stops at the next heading',
|
||||
bounded.capabilities.length === 2,
|
||||
JSON.stringify(bounded.capabilities)
|
||||
);
|
||||
|
||||
/* Sub-headings (`###`) belong to the section they sit under, and must not end
|
||||
it — `\n##\s` would match `### ` if the space were not required. */
|
||||
const nested = reg.parseSkill(
|
||||
`---\nid: nested\nname: Nested\ndescription: d\npages:\n - positions\nstatus: active\n---\n\n`
|
||||
+ `## Capabilities\n\n- one\n\n### Detail\n\n- two\n\n## Actions\n\n- three\n`,
|
||||
{ custom: true }
|
||||
);
|
||||
record(
|
||||
'a `###` sub-heading does not truncate its section',
|
||||
nested.capabilities.length === 2,
|
||||
JSON.stringify(nested.capabilities)
|
||||
);
|
||||
|
||||
/* Frontmatter of every shipped file parses and carries its required fields. */
|
||||
let frontmatterOk = true;
|
||||
const frontmatterDetail = [];
|
||||
for (const s of reg.SKILLS) {
|
||||
if (!s.id || !s.name || !s.pages.length) {
|
||||
frontmatterOk = false;
|
||||
frontmatterDetail.push(s.path);
|
||||
}
|
||||
}
|
||||
record('every shipped skill has id, name and pages', frontmatterOk, frontmatterDetail.join(', '));
|
||||
|
||||
/* ── 4. Trigger / matching ────────────────────────────────────────────────── */
|
||||
console.log('\n── Trigger matching ──');
|
||||
|
||||
const positions = contextFor('positions');
|
||||
record('positions context resolves', Boolean(positions), String(positions));
|
||||
|
||||
const MATRIX = [
|
||||
['create a position', 'create-position'],
|
||||
['i want to hire', 'create-position'],
|
||||
['post a job', 'create-position'],
|
||||
['add a client', 'create-position'],
|
||||
['show hiring activity', 'hiring-activity-assistant'],
|
||||
['hiring flow', 'hiring-activity-assistant'],
|
||||
['applications over time', 'hiring-activity-assistant'],
|
||||
];
|
||||
for (const [question, expected] of MATRIX) {
|
||||
const got = reg.matchSkill(question, positions)?.id ?? null;
|
||||
record(`trigger "${question}" -> ${expected}`, got === expected, got ?? 'no match');
|
||||
}
|
||||
|
||||
/* Every claimant is enumerable, so a contested phrase is not invisible. */
|
||||
const contender = `---
|
||||
id: zz-contender
|
||||
name: ZZ Contender
|
||||
description: Also claims a phrase create-position owns.
|
||||
pages:
|
||||
- positions
|
||||
status: active
|
||||
triggers:
|
||||
- create a position
|
||||
owliver:
|
||||
enabled: true
|
||||
capabilities:
|
||||
- summary
|
||||
responses:
|
||||
summary:
|
||||
source: position.activity
|
||||
periods:
|
||||
- today
|
||||
---
|
||||
|
||||
# ZZ Contender
|
||||
`;
|
||||
const contested = [{ path: 'custom/zz.md', raw: contender }];
|
||||
const claimants = reg.matchSkills('create a position', positions, [], contested);
|
||||
record(
|
||||
'all trigger claimants are enumerable',
|
||||
claimants.length === 2,
|
||||
claimants.map((s) => s.id).join(', ')
|
||||
);
|
||||
record(
|
||||
'a contested trigger is reported as a collision',
|
||||
reg.readSkillRegistry(contested).diagnostics.some((d) => d.kind === 'trigger-collision'),
|
||||
reg.readSkillRegistry(contested).diagnostics.find((d) => d.kind === 'trigger-collision')?.message ?? 'NO DIAGNOSTIC'
|
||||
);
|
||||
|
||||
/* ── 5. Owliver suggestions ───────────────────────────────────────────────── */
|
||||
console.log('\n── Suggestions ──');
|
||||
|
||||
const resolver = await server.ssrLoadModule('/src/lib/skills/owliverResolver.js');
|
||||
const declared = resolver.owliverSuggestions(positions, [], [], {});
|
||||
|
||||
record(
|
||||
'every declared suggestion names a capability the skill offers',
|
||||
declared.every((c) => !c.skillCapability
|
||||
|| reg.SKILLS.find((s) => s.id === c.skillId)?.owliver.capabilities.includes(c.skillCapability))
|
||||
);
|
||||
|
||||
const byIntent = declared.map((c) => `${c.skillId}:${c.skillCapability}`);
|
||||
record(
|
||||
'no two declared suggestions resolve to one capability',
|
||||
new Set(byIntent).size === byIntent.length,
|
||||
byIntent.join(' | ')
|
||||
);
|
||||
|
||||
const dynamic = await server.ssrLoadModule('/src/components/ai-assistant/dynamic.js');
|
||||
|
||||
/* The generator is exercised at three different data shapes: the labels must
|
||||
track the data, and must never appear when the count behind them is zero. */
|
||||
const factsFor = (n) => ({
|
||||
postings: [], starvedPositions: new Array(n).fill({}), unscreened: new Array(n).fill({}),
|
||||
scored: [], stalled: [], ranked: [], activity: [], missingCredentials: [],
|
||||
});
|
||||
const shapes = [0, 1, 7, 12];
|
||||
let labelsOk = true;
|
||||
const offenders = [];
|
||||
for (const n of shapes) {
|
||||
for (const chip of dynamic.buildPrompts('admin.positions', factsFor(n), null)) {
|
||||
/* A complete intent asks or instructs. A raw fragment — "4 ready for
|
||||
interview" — does neither, and is what this check exists to catch. */
|
||||
const isIntent = /\?$/.test(chip.label) || /^(show|summari[sz]e|create|continue|compare|find|explain|what|which|who)\b/i.test(chip.label);
|
||||
if (!isIntent) { labelsOk = false; offenders.push(`n=${n}: "${chip.label}"`); }
|
||||
if (n === 0 && /\b0\b/.test(chip.label)) { labelsOk = false; offenders.push(`n=0 leaked a zero count: "${chip.label}"`); }
|
||||
}
|
||||
}
|
||||
record('every generated label is a complete intent', labelsOk, offenders.join(' ; ') || 'all labels are intents');
|
||||
|
||||
/* Counts must move with the data rather than being baked in. */
|
||||
const at7 = dynamic.buildPrompts('admin.positions', factsFor(7), null).map((c) => c.label);
|
||||
const at12 = dynamic.buildPrompts('admin.positions', factsFor(12), null).map((c) => c.label);
|
||||
record(
|
||||
'labels are dynamic, not hardcoded',
|
||||
at7.some((l) => l.includes('7')) && at12.some((l) => l.includes('12')) && !at7.some((l) => l.includes('12')),
|
||||
`7 -> ${at7.find((l) => l.includes('7'))} | 12 -> ${at12.find((l) => l.includes('12'))}`
|
||||
);
|
||||
|
||||
const atZero = dynamic.buildPrompts('admin.positions', factsFor(0), null).map((c) => c.label);
|
||||
record(
|
||||
'no count-bearing suggestion when the count is zero',
|
||||
!atZero.some((l) => /\d/.test(l)),
|
||||
atZero.join(' | ')
|
||||
);
|
||||
|
||||
/**
|
||||
* The workforce branch — the one the fragments came from.
|
||||
*
|
||||
* "4 ready for interview" and "2 strong for Event Server" were generated here,
|
||||
* so a suggestion check that passes `null` for workforce proves nothing about
|
||||
* them. Applications are dated today so the "applied today" branch is live too.
|
||||
*/
|
||||
const workforceAt = (readyCount) => {
|
||||
const position = { id: 'p1', title: 'Event Server – Fine Dining', status: 'active', headcount: 5 };
|
||||
const today = new Date();
|
||||
const applications = Array.from({ length: readyCount }, (_, i) => ({
|
||||
id: `a${i}`,
|
||||
job_posting_id: 'p1',
|
||||
email: `c${i}@example.com`,
|
||||
applicant_name: `Candidate ${i}`,
|
||||
status: i % 2 ? 'ai_screened' : 'shortlisted',
|
||||
ai_score: 90,
|
||||
created_date: today.toISOString(),
|
||||
updated_date: today.toISOString(),
|
||||
}));
|
||||
return {
|
||||
positions: [position],
|
||||
currentPositionId: null,
|
||||
context: { applications, today, profiles: [], staff: [], assignments: [] },
|
||||
};
|
||||
};
|
||||
|
||||
for (const n of [1, 4, 9]) {
|
||||
const labels = dynamic
|
||||
.buildPrompts('admin.positions', factsFor(2), workforceAt(n))
|
||||
.map((c) => c.label);
|
||||
|
||||
const ready = labels.find((l) => /ready for interview/i.test(l));
|
||||
record(
|
||||
`workforce branch at ${n}: "ready for interview" is a complete question`,
|
||||
Boolean(ready) && /\?$/.test(ready) && ready.includes(String(n)) && /^Which\b/.test(ready),
|
||||
ready ?? 'NOT GENERATED'
|
||||
);
|
||||
|
||||
/* Singular/plural has to track the count, or the fix trades one awkward
|
||||
label for another. */
|
||||
if (n === 1) {
|
||||
record(
|
||||
'a single candidate reads as one candidate',
|
||||
ready === 'Which 1 candidate is ready for interview?',
|
||||
ready ?? 'NOT GENERATED'
|
||||
);
|
||||
}
|
||||
|
||||
const fragment = labels.find((l) => /^\d+\s/.test(l));
|
||||
record(
|
||||
`workforce branch at ${n}: no bare status fragments`,
|
||||
!fragment,
|
||||
fragment ?? 'none'
|
||||
);
|
||||
}
|
||||
|
||||
await server.close();
|
||||
|
||||
/* ── 6. Production bundle ─────────────────────────────────────────────────── */
|
||||
if (process.argv.includes('--dist')) {
|
||||
console.log('\n── Production bundle ──');
|
||||
const dir = join(ROOT, 'dist/assets');
|
||||
if (!existsSync(dir)) {
|
||||
record('dist/assets exists', false, 'run `npm run build` first');
|
||||
} else {
|
||||
const bundle = readdirSync(dir)
|
||||
.filter((f) => f.endsWith('.js'))
|
||||
.map((f) => readFileSync(join(dir, f), 'utf8'))
|
||||
.join('\n');
|
||||
|
||||
for (const s of reg.SKILLS) {
|
||||
record(`bundle carries \`${s.id}\``, bundle.includes(s.id));
|
||||
}
|
||||
/* Body content, not just the id — a manifest naming a skill whose Markdown
|
||||
did not survive is the failure this is looking for. */
|
||||
record(
|
||||
'bundle carries skill body text',
|
||||
bundle.includes('Which client is this role for'),
|
||||
'create-position conversation step found in bundle'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Summary ──────────────────────────────────────────────────────────────── */
|
||||
const failed = results.filter((r) => !r.pass);
|
||||
console.log(`\n${results.length - failed.length}/${results.length} checks passed`);
|
||||
if (failed.length) {
|
||||
console.log('\nFailed:');
|
||||
failed.forEach((r) => console.log(` - ${r.name}${r.detail ? ` (${r.detail})` : ''}`));
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1314,7 +1314,7 @@ const TRAINING_MODULES = MODULE_TABLE.map(
|
||||
type,
|
||||
prompt: PROMPT_FOR[type](title),
|
||||
ai_persona: type === 'roleplay' ? 'A guest or teammate in a live service situation.' : undefined,
|
||||
rubric: criteria.map((criterion, n) => ({ criterion, weight: n === 0 ? 40 : 30 })),
|
||||
rubric: (Array.isArray(criteria) ? criteria : []).map((criterion, n) => ({ criterion, weight: n === 0 ? 40 : 30 })),
|
||||
},
|
||||
unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] },
|
||||
quiz: [],
|
||||
|
||||
@@ -37,7 +37,9 @@ export function ThinkingIndicator() {
|
||||
* with tables and KPI tiles, and a chat bubble would waste a third of a 380px
|
||||
* column on padding around them.
|
||||
*/
|
||||
export const Message = React.memo(({ role, text, blocks, streaming, stopped, onPrompt }) => {
|
||||
export const Message = React.memo(
|
||||
/** @param {any} props */
|
||||
({ role, text, blocks, streaming, stopped, onPrompt }) => {
|
||||
if (role === 'user') {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
|
||||
@@ -15,7 +15,8 @@ import { useAssistantPanel } from './AssistantPanelContext';
|
||||
* It self-hides on pages with no assistant, so a page can render it
|
||||
* unconditionally and never needs to know the placement rules.
|
||||
*/
|
||||
export function AssistantTrigger({ className, size = 'default' }) {
|
||||
/** @param {any} props */
|
||||
export function AssistantTrigger({ className = '', size = 'default' }) {
|
||||
const { supported, isOpen, toggle, context } = useAssistantPanel();
|
||||
|
||||
if (!supported) return null;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeft, History, Maximize2, Minimize2, PanelRightClose, RotateCcw, Sparkles, Trash2,
|
||||
ArrowLeft, History, Maximize2, Minimize2, PanelRightClose, RotateCcw, Trash2,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Surface } from '@/components/ds/Surface';
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
usePreferences, useRoleCategories,
|
||||
} from '@/lib/krowHooks';
|
||||
import { ROLE_CATEGORIES } from '@/lib/roleCategories';
|
||||
import { AddSkillDialog } from '@/components/skills/AddSkillDialog';
|
||||
import { runAction } from '@/lib/skills/actions';
|
||||
import { skillsForContext } from '@/lib/skills/registry';
|
||||
import { owliverSuggestions } from '@/lib/skills/owliverResolver';
|
||||
@@ -286,7 +285,6 @@ export default function KrowAssistant({
|
||||
activity: facts.activity || [],
|
||||
trainingPaths,
|
||||
}), [pageContext, facts, assignments, trainingPaths]);
|
||||
const [addSkillOpen, setAddSkillOpen] = React.useState(false);
|
||||
|
||||
/**
|
||||
* Which face of the panel the body is showing.
|
||||
@@ -500,12 +498,33 @@ export default function KrowAssistant({
|
||||
const ready = declared.filter((c) => !c.deferred);
|
||||
const asking = declared.filter((c) => c.deferred);
|
||||
|
||||
/**
|
||||
* One chip per *intent*, not per wording.
|
||||
*
|
||||
* De-duplicating on the label alone let two chips through whenever the same
|
||||
* answer was worded twice — a skill's "Show hiring activity" and its
|
||||
* "Summarize hiring activity" both resolved to that skill's `summary`
|
||||
* capability, so the reader was offered the same reading under two names and
|
||||
* had no way to tell them apart. What a chip *resolves to* is the thing that
|
||||
* must be unique: a skill capability, the page capability, or, for a chip
|
||||
* that is neither, the question it sends. The label is compared too, so two
|
||||
* differently-routed chips still cannot arrive reading identically.
|
||||
*/
|
||||
const seen = new Set();
|
||||
const intentOf = (chip) => {
|
||||
if (chip?.skillId && chip?.skillCapability) return `skill:${chip.skillId}:${chip.skillCapability}`;
|
||||
if (chip?.capability) return `page:${chip.capability}`;
|
||||
return `ask:${String(chip?.prompt ?? '').trim().toLowerCase()}`;
|
||||
};
|
||||
|
||||
return [...ready, ...skillPrompts, ...buildPrompts(context.id, facts, workforce), ...asking]
|
||||
.filter((chip) => {
|
||||
const key = String(chip?.label ?? '').trim().toLowerCase();
|
||||
if (!key || seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
const label = String(chip?.label ?? '').trim().toLowerCase();
|
||||
if (!label) return false;
|
||||
const intent = intentOf(chip);
|
||||
if (seen.has(intent) || seen.has(`label:${label}`)) return false;
|
||||
seen.add(intent);
|
||||
seen.add(`label:${label}`);
|
||||
return true;
|
||||
});
|
||||
/* `pageContext` decides which suggestions can answer without asking, so the
|
||||
@@ -603,17 +622,16 @@ export default function KrowAssistant({
|
||||
<p className="truncate text-caption leading-tight text-ink-3">{context.page}</p>
|
||||
</div>
|
||||
|
||||
{/* Window controls */}
|
||||
{/* Window controls.
|
||||
|
||||
Authoring a skill is deliberately not among them. Skills are a
|
||||
registry with a lifecycle — authored, enabled, edited, removed — and
|
||||
Workspace → Skills is where that lifecycle lives. A second entry
|
||||
point here meant the panel you *use* Owliver from was also a place
|
||||
you *configured* it from, and the two lists could be reached from
|
||||
different places with different affordances. The panel does the
|
||||
first job only; the registry behind it is unchanged. */}
|
||||
<div className="flex items-center gap-0.5">
|
||||
{/* Opens the same dialog Settings does; there is one registry behind
|
||||
both, so a skill added here is the skill added there. */}
|
||||
<IconButton
|
||||
icon={Sparkles}
|
||||
label="Add an Owliver skill"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setAddSkillOpen(true)}
|
||||
/>
|
||||
{/* History lives with the other window controls rather than in the
|
||||
body, so the layout of the panel is unchanged whether or not
|
||||
there is anything to show. It toggles: pressing it again returns
|
||||
@@ -729,8 +747,6 @@ export default function KrowAssistant({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AddSkillDialog open={addSkillOpen} onOpenChange={setAddSkillOpen} />
|
||||
|
||||
{/* ── Composer: fixed to the bottom in both states ───────────────── */}
|
||||
<div className="shrink-0 space-y-2 border-t border-white/60 px-3 pb-2.5 pt-2.5">
|
||||
{/* All suggestions on the landing screen, where they teach what can be
|
||||
|
||||
@@ -15,9 +15,16 @@ import { cn } from '@/lib/utils';
|
||||
* `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.
|
||||
*/
|
||||
export function PromptChips({ prompts = [], onSelect, max, align = 'center', className }) {
|
||||
/** @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;
|
||||
|
||||
@@ -54,15 +61,21 @@ export function PromptChips({ prompts = [], onSelect, max, align = 'center', cla
|
||||
onKeyDown={(e) => onKeyDown(e, i)}
|
||||
title={prompt.prompt}
|
||||
className={cn(
|
||||
'rounded-full border border-border bg-surface px-2.5 py-1',
|
||||
'text-caption font-medium text-ink-2 shadow-xs',
|
||||
/* 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'
|
||||
)}
|
||||
>
|
||||
{prompt.label}
|
||||
{/* `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>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { cn } from '@/lib/utils';
|
||||
* 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,
|
||||
@@ -26,7 +27,7 @@ export function PromptInput({
|
||||
placeholders = ['Ask anything…'],
|
||||
autoFocus = false,
|
||||
align = 'left',
|
||||
className,
|
||||
className = '',
|
||||
}) {
|
||||
const textareaRef = React.useRef(null);
|
||||
const [focused, setFocused] = React.useState(false);
|
||||
|
||||
@@ -62,14 +62,14 @@ const TONE_BADGE = {
|
||||
|
||||
/* ── Individual blocks ──────────────────────────────────────────────────── */
|
||||
|
||||
const TextBlock = React.memo(({ block }) => (
|
||||
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(({ block }) => (
|
||||
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>}
|
||||
@@ -78,7 +78,7 @@ const HeadingBlock = React.memo(({ block }) => (
|
||||
HeadingBlock.displayName = 'HeadingBlock';
|
||||
|
||||
/** Headline figures. Two columns keeps the numbers large in a 380px panel. */
|
||||
const KpisBlock = React.memo(({ block }) => (
|
||||
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
|
||||
@@ -113,7 +113,7 @@ const KpisBlock = React.memo(({ block }) => (
|
||||
KpisBlock.displayName = 'KpisBlock';
|
||||
|
||||
/** Pass/warn checks. The icon carries the state so colour is not the only cue. */
|
||||
const StatusBlock = React.memo(({ block }) => (
|
||||
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">
|
||||
@@ -145,7 +145,7 @@ const StatusBlock = React.memo(({ block }) => (
|
||||
));
|
||||
StatusBlock.displayName = 'StatusBlock';
|
||||
|
||||
const MetersBlock = React.memo(({ block }) => (
|
||||
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">
|
||||
@@ -167,7 +167,7 @@ MetersBlock.displayName = 'MetersBlock';
|
||||
* A table. Scrolls horizontally inside its own container so a wide comparison
|
||||
* never widens the panel.
|
||||
*/
|
||||
const TableBlock = React.memo(({ block }) => {
|
||||
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') {
|
||||
@@ -228,7 +228,7 @@ const TableBlock = React.memo(({ block }) => {
|
||||
TableBlock.displayName = 'TableBlock';
|
||||
|
||||
/** Stage progression, widths relative to the largest stage. */
|
||||
const FunnelBlock = React.memo(({ block }) => {
|
||||
const FunnelBlock = React.memo(/** @param {any} props */ ({ block }) => {
|
||||
const max = Math.max(...block.steps.map((s) => s.count), 1);
|
||||
|
||||
return (
|
||||
@@ -255,7 +255,7 @@ const FunnelBlock = React.memo(({ block }) => {
|
||||
});
|
||||
FunnelBlock.displayName = 'FunnelBlock';
|
||||
|
||||
const ListBlock = React.memo(({ block }) => {
|
||||
const ListBlock = React.memo(/** @param {any} props */ ({ block }) => {
|
||||
const Tag = block.ordered ? 'ol' : 'ul';
|
||||
return (
|
||||
<Tag className="space-y-1.5">
|
||||
@@ -298,7 +298,7 @@ ListBlock.displayName = 'ListBlock';
|
||||
* 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(({ block, onPrompt }) => (
|
||||
const InsightsBlock = React.memo(/** @param {any} props */ ({ block, onPrompt }) => (
|
||||
<div className="space-y-2">
|
||||
{block.items.map((item, i) => {
|
||||
const surface = cn(
|
||||
@@ -375,7 +375,7 @@ const InsightsBlock = React.memo(({ block, onPrompt }) => (
|
||||
InsightsBlock.displayName = 'InsightsBlock';
|
||||
|
||||
/** Recommended steps — numbered, because order is the recommendation. */
|
||||
const ActionsBlock = React.memo(({ block }) => (
|
||||
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">
|
||||
@@ -394,7 +394,7 @@ const ActionsBlock = React.memo(({ block }) => (
|
||||
));
|
||||
ActionsBlock.displayName = 'ActionsBlock';
|
||||
|
||||
const BadgesBlock = React.memo(({ block }) => (
|
||||
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">
|
||||
@@ -405,7 +405,7 @@ const BadgesBlock = React.memo(({ block }) => (
|
||||
));
|
||||
BadgesBlock.displayName = 'BadgesBlock';
|
||||
|
||||
const TimelineBlock = React.memo(({ block }) => (
|
||||
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">
|
||||
@@ -438,7 +438,7 @@ const TimelineBlock = React.memo(({ block }) => (
|
||||
));
|
||||
TimelineBlock.displayName = 'TimelineBlock';
|
||||
|
||||
const NoteBlock = React.memo(({ block }) => (
|
||||
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>
|
||||
@@ -455,7 +455,7 @@ NoteBlock.displayName = 'NoteBlock';
|
||||
* shape the table does not carry renders nothing rather than something
|
||||
* improvised.
|
||||
*/
|
||||
const SkillSectionBlock = React.memo(({ block }) => {
|
||||
const SkillSectionBlock = React.memo(/** @param {any} props */ ({ block }) => {
|
||||
const section = block.section;
|
||||
const Component = SECTION_COMPONENTS[section?.shape || section?.type];
|
||||
|
||||
@@ -515,7 +515,7 @@ const RENDERERS = {
|
||||
* blocks has consistent rhythm — a heading hugs what follows it, everything
|
||||
* else breathes.
|
||||
*/
|
||||
export const ResponseDocument = React.memo(({ blocks = [], streaming = false, onPrompt }) => (
|
||||
export const ResponseDocument = React.memo(/** @param {any} props */ ({ blocks = [], streaming = false, onPrompt }) => (
|
||||
<div className="space-y-3">
|
||||
{blocks.map((block, i) => {
|
||||
const Renderer = RENDERERS[block.type];
|
||||
|
||||
@@ -459,7 +459,7 @@ const PROMPTS = {
|
||||
prompt: `Continue the ${drafts[0].title} draft`,
|
||||
}
|
||||
: {
|
||||
label: `${drafts.length} drafts to finish`,
|
||||
label: `Which ${plural(drafts.length, 'position')} ${verb(drafts.length, 'is', 'are')} still in draft?`,
|
||||
prompt: 'Which positions are still in draft?',
|
||||
capability: 'position-drafts',
|
||||
});
|
||||
@@ -496,8 +496,9 @@ const PROMPTS = {
|
||||
|
||||
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: `${best.status.strong.length} strong for ${best.position.title}`,
|
||||
label: `Show the ${plural(n, 'strongest candidate')} for ${best.position.title}`,
|
||||
prompt: `Who matches ${best.position.title}?`,
|
||||
});
|
||||
}
|
||||
@@ -505,10 +506,16 @@ const PROMPTS = {
|
||||
out.push({ label: 'Which position needs people first?', prompt: 'Which position needs people first?' });
|
||||
}
|
||||
if (newToday) {
|
||||
out.push({ label: `${newToday} applied today`, prompt: 'Who applied today?' });
|
||||
out.push({
|
||||
label: `Which ${plural(newToday, 'candidate')} applied today?`,
|
||||
prompt: 'Who applied today?',
|
||||
});
|
||||
}
|
||||
if (readyCount) {
|
||||
out.push({ label: `${readyCount} ready for interview`, prompt: 'Who is ready for interview?' });
|
||||
out.push({
|
||||
label: `Which ${plural(readyCount, 'candidate')} ${verb(readyCount, 'is', 'are')} ready for interview?`,
|
||||
prompt: 'Who is ready for interview?',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -516,13 +523,25 @@ const PROMPTS = {
|
||||
ones — they are still the right answer to "how is hiring going". */
|
||||
out.push(
|
||||
f.starvedPositions.length
|
||||
? { label: `${f.starvedPositions.length} roles need attention`, prompt: 'What needs attention across my positions?', capability: 'positions-attention' }
|
||||
? {
|
||||
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: `${f.unscreened.length} waiting for review`, prompt: 'Show candidates waiting for review', capability: 'candidates-waiting' }
|
||||
? {
|
||||
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' },
|
||||
{ label: 'Summarize hiring activity', prompt: 'Summarize hiring activity across my positions', capability: 'hiring-operations' }
|
||||
/* 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;
|
||||
|
||||
@@ -42,7 +42,7 @@ export function readHistory() {
|
||||
return list
|
||||
.filter((r) => r && r.id && Array.isArray(r.messages) && r.messages.length)
|
||||
.filter((r) => new Date(r.updatedAt || 0).getTime() >= cutoff)
|
||||
.sort((a, b) => new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0));
|
||||
.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. */
|
||||
|
||||
@@ -60,7 +60,7 @@ export function buildFacts({
|
||||
/* Time-to-hire — the created→updated span ImpactMetrics uses. */
|
||||
const timeToHire = hired.length
|
||||
? Math.round(avg(hired.map((a) =>
|
||||
Math.max(1, (new Date(a.updated_date) - new Date(a.created_date)) / DAY_MS))))
|
||||
Math.max(1, (new Date(a.updated_date).getTime() - new Date(a.created_date).getTime()) / DAY_MS))))
|
||||
: 0;
|
||||
|
||||
/* Open roles attracting nobody. */
|
||||
|
||||
@@ -103,7 +103,8 @@ function getDeptColor(index, avgScore) {
|
||||
};
|
||||
}
|
||||
|
||||
export function DepartmentPerformance({ items = [], className }) {
|
||||
/** @param {any} props */
|
||||
export function DepartmentPerformance({ items = [], className = '' }) {
|
||||
const [viewMode, setViewMode] = useState('flowchart'); // 'flowchart' | 'table'
|
||||
const [activeDept, setActiveDept] = useState(null);
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@ import { chartSx, TONES, tooltipSx } from './muiChartTheme';
|
||||
* `stages` and `transitions` come from the page's own fact sheet — this
|
||||
* component derives no figures of its own.
|
||||
*/
|
||||
export function HiringFlow({ stages = [], transitions = [], weakestKey, className }) {
|
||||
/** @param {any} props */
|
||||
export function HiringFlow({ stages = [], transitions = [], weakestKey, className = '' }) {
|
||||
if (!stages.length) return null;
|
||||
|
||||
const total = stages[0]?.count || 1;
|
||||
|
||||
@@ -14,7 +14,8 @@ import { chartSx, TONES, tooltipSx } from './muiChartTheme';
|
||||
*
|
||||
* `points: [{ label, hires, cumulative }]` — supplied by the page.
|
||||
*/
|
||||
export function HiringTrendChart({ points = [], emptyState, className }) {
|
||||
/** @param {any} props */
|
||||
export function HiringTrendChart({ points = [], emptyState, className = '' }) {
|
||||
const enough = points.length >= 2;
|
||||
|
||||
return (
|
||||
|
||||
@@ -11,6 +11,7 @@ import { StatusBadge } from '@/components/ds/StatusBadge';
|
||||
* `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,
|
||||
|
||||
@@ -39,6 +39,7 @@ const ICON_COLORS = {
|
||||
* 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];
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ export function initialsFrom(name = '') {
|
||||
* 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);
|
||||
@@ -89,6 +90,7 @@ 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;
|
||||
|
||||
@@ -37,6 +37,7 @@ export const AXIS_PROPS = {
|
||||
* 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;
|
||||
|
||||
@@ -97,6 +98,7 @@ export function ChartLegend({ items = [], className }) {
|
||||
* 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,
|
||||
|
||||
@@ -23,6 +23,7 @@ import { usePreferences } from '@/lib/krowHooks';
|
||||
* `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 = [],
|
||||
|
||||
@@ -27,6 +27,7 @@ const WIDTHS = {
|
||||
* 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,
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Button } from '@/components/ui/button';
|
||||
* 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,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { cn } from '@/lib/utils';
|
||||
* 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,
|
||||
|
||||
@@ -18,24 +18,25 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
* 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,
|
||||
onSearchChange,
|
||||
search = null,
|
||||
onSearchChange = null,
|
||||
searchPlaceholder = 'Search...',
|
||||
|
||||
filters = [],
|
||||
/** `{ [key]: string | string[] }` */
|
||||
values = {},
|
||||
onChange,
|
||||
onChange = null,
|
||||
|
||||
/** Right-aligned actions — primary buttons, view switchers. */
|
||||
actions,
|
||||
actions = null,
|
||||
/** Rendered under the bar, e.g. a result count. */
|
||||
meta,
|
||||
meta = null,
|
||||
|
||||
/** Value that means "no filter" for select-type filters. */
|
||||
allValue = 'all',
|
||||
className,
|
||||
className = '',
|
||||
}) {
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
|
||||
* `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 = {
|
||||
|
||||
@@ -45,18 +45,19 @@ const SEVERITY = {
|
||||
},
|
||||
};
|
||||
|
||||
/** @param {any} props */
|
||||
export function InsightRow({
|
||||
severity = 'info',
|
||||
title,
|
||||
detail,
|
||||
/** Short right-aligned figure — a count, an age, a percentage. */
|
||||
metric,
|
||||
metricLabel,
|
||||
metric = null,
|
||||
metricLabel = null,
|
||||
/** Entity the item concerns, e.g. a position or candidate name. */
|
||||
entity,
|
||||
onClick,
|
||||
entity = null,
|
||||
onClick = null,
|
||||
actionLabel = 'Review',
|
||||
className,
|
||||
className = '',
|
||||
}) {
|
||||
const s = SEVERITY[severity] || SEVERITY.info;
|
||||
const Comp = onClick ? 'button' : 'div';
|
||||
@@ -105,6 +106,7 @@ export function InsightRow({
|
||||
}
|
||||
|
||||
/** The list container — dividers between rows, nothing else. */
|
||||
export function InsightList({ children, className }) {
|
||||
/** @param {any} props */
|
||||
export function InsightList({ children, className = '' }) {
|
||||
return <div className={cn('divide-y divide-border', className)}>{children}</div>;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ const TONES = {
|
||||
* (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,
|
||||
@@ -94,6 +95,7 @@ const formatDelta = (delta, suffix = '%') =>
|
||||
* `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,
|
||||
|
||||
@@ -18,6 +18,7 @@ const GAPS = { sm: 'gap-3', default: 'gap-4', lg: 'gap-6' };
|
||||
* `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',
|
||||
@@ -36,6 +37,7 @@ export function Grid({ cols = 3, gap = 'default', className, children, ...props
|
||||
}
|
||||
|
||||
/** 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',
|
||||
@@ -56,6 +58,7 @@ export function Stack({ space = 'default', className, children, ...props }) {
|
||||
* 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)}>
|
||||
|
||||
@@ -5,7 +5,8 @@ import { cn } from '@/lib/utils';
|
||||
/**
|
||||
* Spinner — the only spinner in the system.
|
||||
*/
|
||||
export function Spinner({ size = 'default', className, label = 'Loading' }) {
|
||||
/** @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
|
||||
@@ -23,7 +24,8 @@ export function Spinner({ size = 'default', className, label = 'Loading' }) {
|
||||
* Prefer `Skeleton*` when the shape *is* known: a skeleton that matches the
|
||||
* final layout prevents the content jump a spinner always causes.
|
||||
*/
|
||||
export function LoadingState({ message, size = 'default', className }) {
|
||||
/** @param {any} props */
|
||||
export function LoadingState({ message = '', size = 'default', className = '' }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -42,7 +44,8 @@ export function LoadingState({ message, size = 'default', className }) {
|
||||
* InlineLoading — a spinner and label on one line, for inside buttons rows,
|
||||
* card headers, or beside a control that is refreshing.
|
||||
*/
|
||||
export function InlineLoading({ message = 'Loading…', className }) {
|
||||
/** @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" />
|
||||
@@ -55,7 +58,8 @@ export function InlineLoading({ message = 'Loading…', className }) {
|
||||
* LoadingOverlay — covers content that is being replaced, keeping layout
|
||||
* height stable so the page does not jump. Used by DataTable when refetching.
|
||||
*/
|
||||
export function LoadingOverlay({ show, message, className }) {
|
||||
/** @param {any} props */
|
||||
export function LoadingOverlay({ show = false, message = '', className = '' }) {
|
||||
if (!show) return null;
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -57,6 +57,7 @@ function Delta({ delta, label, invert }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {any} props */
|
||||
export function MetricStrip({ items = [], columns, className, loading = false }) {
|
||||
const count = columns ?? Math.min(items.length, 6);
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ const WIDTHS = {
|
||||
* 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,
|
||||
@@ -82,6 +84,7 @@ export function Modal({
|
||||
* 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,
|
||||
@@ -91,7 +94,7 @@ export function ConfirmModal({
|
||||
cancelLabel = 'Cancel',
|
||||
onConfirm,
|
||||
tone = 'destructive',
|
||||
icon,
|
||||
icon = null,
|
||||
busy = false,
|
||||
}) {
|
||||
return (
|
||||
|
||||
@@ -12,6 +12,7 @@ import { SearchInput } from '@/components/ds/SearchInput';
|
||||
* 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 = [],
|
||||
@@ -20,8 +21,8 @@ export function MultiSelect({
|
||||
/** Chips shown before collapsing into "+N more". */
|
||||
maxChips = 2,
|
||||
searchThreshold = 8,
|
||||
disabled,
|
||||
className,
|
||||
disabled = false,
|
||||
className = '',
|
||||
align = 'start',
|
||||
emptyMessage = 'No options',
|
||||
}) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { cn } from '@/lib/utils';
|
||||
* 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,
|
||||
|
||||
@@ -9,6 +9,12 @@ import { Button } from '@/components/ui/button';
|
||||
* 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);
|
||||
@@ -30,6 +36,7 @@ export function buildPageRange(current, total, siblings = 1) {
|
||||
* 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,
|
||||
|
||||
@@ -34,6 +34,7 @@ export function toneForScore(score) {
|
||||
* `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,
|
||||
@@ -94,6 +95,7 @@ export function ProgressBar({
|
||||
* 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,
|
||||
@@ -175,7 +177,8 @@ export function ProgressRing({
|
||||
* Completed steps show a check rather than their number, so "done" is readable
|
||||
* at a glance without relying on colour.
|
||||
*/
|
||||
export function ProgressSteps({ steps = [], current = 0, className, onStepClick }) {
|
||||
/** @param {any} props */
|
||||
export function ProgressSteps({ steps = [], current = 0, className = '', onStepClick = null }) {
|
||||
const normalized = steps.map((s) => (typeof s === 'string' ? { label: s } : s));
|
||||
|
||||
return (
|
||||
@@ -239,6 +242,7 @@ export function ProgressSteps({ steps = [], current = 0, className, onStepClick
|
||||
* 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)}>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { inputVariants } from '@/components/ui/input';
|
||||
* 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(
|
||||
(
|
||||
{
|
||||
|
||||
@@ -10,6 +10,7 @@ import { cn } from '@/lib/utils';
|
||||
* - `card` — a card's own title row
|
||||
* - `section` — a page-level section title
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function SectionHeader({
|
||||
title,
|
||||
subtitle,
|
||||
|
||||
@@ -9,7 +9,8 @@ import { cn } from '@/lib/utils';
|
||||
* progress. The shimmer is disabled automatically under
|
||||
* `prefers-reduced-motion` (see index.css).
|
||||
*/
|
||||
export function Skeleton({ className, radius = 'md', ...props }) {
|
||||
/** @param {any} props */
|
||||
export function Skeleton({ className = '', radius = 'md', ...props }) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
@@ -31,7 +32,8 @@ export function Skeleton({ className, radius = 'md', ...props }) {
|
||||
*/
|
||||
|
||||
/** Paragraph placeholder. The last line is short, as real text is. */
|
||||
export function SkeletonText({ lines = 3, className }) {
|
||||
/** @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) => (
|
||||
@@ -45,7 +47,8 @@ export function SkeletonText({ lines = 3, className }) {
|
||||
}
|
||||
|
||||
/** Matches KpiCard: icon tile, big number, caption. */
|
||||
export function SkeletonKpi({ className }) {
|
||||
/** @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" />
|
||||
@@ -56,7 +59,8 @@ export function SkeletonKpi({ className }) {
|
||||
}
|
||||
|
||||
/** Matches a list row: avatar, two lines of text, trailing value. */
|
||||
export function SkeletonRow({ className }) {
|
||||
/** @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" />
|
||||
@@ -70,7 +74,8 @@ export function SkeletonRow({ className }) {
|
||||
}
|
||||
|
||||
/** Matches a card in a grid: title, meta, stat row. */
|
||||
export function SkeletonCard({ className }) {
|
||||
/** @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">
|
||||
@@ -94,7 +99,8 @@ export function SkeletonCard({ className }) {
|
||||
}
|
||||
|
||||
/** Matches DataTable: header row plus n body rows. */
|
||||
export function SkeletonTable({ rows = 5, columns = 4, className }) {
|
||||
/** @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">
|
||||
@@ -114,7 +120,8 @@ export function SkeletonTable({ rows = 5, columns = 4, className }) {
|
||||
}
|
||||
|
||||
/** Matches ChartContainer: title, legend, plot area. */
|
||||
export function SkeletonChart({ className, height = 240 }) {
|
||||
/** @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" />
|
||||
@@ -129,7 +136,8 @@ export function SkeletonChart({ className, height = 240 }) {
|
||||
}
|
||||
|
||||
/** n SkeletonRows inside a surface — the common "loading a list" case. */
|
||||
export function SkeletonList({ rows = 5, className }) {
|
||||
/** @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) => (
|
||||
|
||||
@@ -77,6 +77,7 @@ const DOT_COLOR = {
|
||||
* `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';
|
||||
|
||||
@@ -55,6 +55,7 @@ const surfaceVariants = cva(
|
||||
}
|
||||
);
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const Surface = React.forwardRef(
|
||||
({ className, variant, elevation, radius, padding, interactive, as, ...props }, ref) => {
|
||||
const Comp = as || 'div';
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
* unique when two tab sets are on screen at once, otherwise the indicator
|
||||
* animates between them.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function Tabs({
|
||||
tabs = [],
|
||||
value,
|
||||
|
||||
@@ -20,7 +20,8 @@ const TONE_DOT = {
|
||||
* The connecting rail is drawn per item rather than as one absolute line, so a
|
||||
* timeline can be nested inside a scrolling card without the rail detaching.
|
||||
*/
|
||||
export function Timeline({ items = [], className, compact = false }) {
|
||||
/** @param {any} props */
|
||||
export function Timeline({ items = [], className = '', compact = false }) {
|
||||
return (
|
||||
<ol className={cn('relative', className)}>
|
||||
{items.map((item, i) => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Switch } from '@/components/ui/switch';
|
||||
* 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,
|
||||
@@ -97,8 +98,15 @@ const SEGMENT_VARIANTS = {
|
||||
},
|
||||
};
|
||||
|
||||
/** @param {any} props */
|
||||
export function SegmentedToggle({
|
||||
options = [], value, onChange, size = 'default', variant = 'brand', className, ariaLabel,
|
||||
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;
|
||||
|
||||
@@ -28,7 +28,7 @@ const BASE_STYLE = {
|
||||
const DURATIONS = { success: 2600, error: 4200, info: 3000, loading: Infinity };
|
||||
|
||||
const withDefaults = (options = {}) => ({
|
||||
position: 'bottom-center',
|
||||
position: /** @type {import('react-hot-toast').ToastPosition} */ ('bottom-center'),
|
||||
...options,
|
||||
style: { ...BASE_STYLE, ...(options.style || {}) },
|
||||
});
|
||||
|
||||
@@ -465,7 +465,8 @@ function Choice({ active, onClick, className, children }) {
|
||||
}
|
||||
|
||||
/** A growable list of short text lines — training steps, evaluation criteria. */
|
||||
function ListBuilder({ label, hint, placeholder, values, onChange, addLabel }) {
|
||||
/** @param {any} props */
|
||||
function ListBuilder({ label, hint = '', placeholder, values, onChange, addLabel }) {
|
||||
const setAt = (i, v) => onChange(values.map((x, j) => (j === i ? v : x)));
|
||||
const removeAt = (i) => onChange(values.length === 1 ? [''] : values.filter((_, j) => j !== i));
|
||||
|
||||
|
||||
@@ -13,7 +13,8 @@ import { Plus, Sparkles } from 'lucide-react';
|
||||
* where a worker actually is one; putting it here made the console read as
|
||||
* somebody's personal training dashboard.
|
||||
*/
|
||||
export function ForgeHeader({ onCreate }) {
|
||||
/** @param {any} props */
|
||||
export function ForgeHeader({ onCreate = null, _onBrowse = null }) {
|
||||
return (
|
||||
<header className="flex flex-col gap-4 border-b border-border/60 pb-6 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
|
||||
@@ -19,7 +19,8 @@ const TIPS = [
|
||||
'Keep training concise.',
|
||||
];
|
||||
|
||||
export function ForgeRail({ recent = [], onCreate, onOpen, onAskOwliver }) {
|
||||
/** @param {any} props */
|
||||
export function ForgeRail({ recent = [], onCreate, onOpen, onAskOwliver: _onAskOwliver }) {
|
||||
return (
|
||||
<aside
|
||||
className="grid grid-cols-1 gap-4 sm:grid-cols-3 lg:grid-cols-3 items-stretch"
|
||||
|
||||
@@ -168,6 +168,7 @@ export function SkillDetail({ course, open, onOpenChange, usage }) {
|
||||
}
|
||||
|
||||
/** A titled region of the record. */
|
||||
/** @param {any} props */
|
||||
function Block({ title, meta, icon: Icon, children }) {
|
||||
return (
|
||||
<section className="border-t border-border pt-4 first:border-0 first:pt-0">
|
||||
|
||||
@@ -32,7 +32,8 @@ const STATUS_META = {
|
||||
* of their ladder is the one thing this component exists to make obvious, and a
|
||||
* stack of rows makes that a reading exercise instead of a glance.
|
||||
*/
|
||||
export function SkillLadder({ state, className }) {
|
||||
/** @param {any} props */
|
||||
export function SkillLadder({ state, className = '' }) {
|
||||
return (
|
||||
<ol className={cn('flex items-start', className)} aria-label={`${state.name} levels`}>
|
||||
{state.levels.map((rung, i) => {
|
||||
@@ -87,6 +88,7 @@ export function SkillLadder({ state, className }) {
|
||||
}
|
||||
|
||||
/** One skill: where this person stands on it, and the next thing to do. */
|
||||
/** @param {any} props */
|
||||
export function SkillProgressCard({ state, onOpen }) {
|
||||
const status = STATUS_META[state.status] || STATUS_META.not_started;
|
||||
const atTop = !state.nextLevel;
|
||||
@@ -163,6 +165,7 @@ export function SkillProgressCard({ state, onOpen }) {
|
||||
* hidden: the evidence behind a level is the reason to trust the level, and
|
||||
* folding it away would leave the label standing on nothing.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function SkillProgressDetail({ state, open, onOpenChange, onStartTraining }) {
|
||||
if (!state) return null;
|
||||
|
||||
|
||||
@@ -95,13 +95,14 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
|
||||
setMessages(newMessages);
|
||||
lastUserMessageTime.current = Date.now();
|
||||
await speak(question);
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
setError('Interview connection issue. Please retry.');
|
||||
}
|
||||
}, [jobTitle, speak, language]);
|
||||
|
||||
const startListening = useCallback(() => {
|
||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
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;
|
||||
@@ -181,7 +182,7 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
|
||||
} });
|
||||
setEvaluation(result);
|
||||
setPhase('done');
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
setError('Evaluation failed. Please try again.');
|
||||
setPhase('active');
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ const STATUS_STYLES = {
|
||||
rejected: { badge: 'bg-[#FEE2E2] text-[#991B1B]', label: 'Rejected', card: 'bg-white border-[#E5E7EB]' },
|
||||
};
|
||||
|
||||
function getInitials(name) {
|
||||
function _getInitials(name) {
|
||||
return name?.split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase() || '??';
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ function ScoreRing({ score }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {any} props */
|
||||
export default function CandidateCard({ application, jobTitle, rank, onAction, onMessage, onCall, onSchedule, onDecline, onDelete, onHire }) {
|
||||
const navigate = useNavigate();
|
||||
const position = jobTitle || application.job_title || '';
|
||||
@@ -116,7 +117,7 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
|
||||
try {
|
||||
await base44.entities.JobApplication.update(application.id, { status: 'rejected' });
|
||||
toast.success(`${application.applicant_name} declined`);
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
toast.error('Failed to decline candidate');
|
||||
}
|
||||
}
|
||||
@@ -130,7 +131,7 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
|
||||
try {
|
||||
await base44.entities.JobApplication.delete(application.id);
|
||||
toast.success(`${application.applicant_name} deleted`);
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
toast.error('Failed to delete candidate');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@ const METRIC_CONFIG = [
|
||||
{ key: 'employer_requirements', label: 'Req Match', Icon: Check },
|
||||
];
|
||||
|
||||
function MetricPill({ label, value, Icon }) {
|
||||
/** @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} />
|
||||
|
||||
@@ -2,7 +2,8 @@ import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
|
||||
function Stage({ label, value, sub, accent, to }) {
|
||||
/** @param {any} props */
|
||||
function Stage({ label, value, sub = '', accent = false, to = null }) {
|
||||
const body = (
|
||||
<div className="text-center sm:text-left">
|
||||
<div className="text-[11px] font-medium uppercase tracking-wider text-[#94A3B8]">{label}</div>
|
||||
|
||||
@@ -332,7 +332,8 @@ export function MatchedCandidates({ job, profiles = [], courses = [], applicatio
|
||||
* `MatchedCandidates` rather than inside it: two audiences, two components, one
|
||||
* scoring function that cannot drift between them.
|
||||
*/
|
||||
export function YourSkillFit({ job, held, className }) {
|
||||
/** @param {any} props */
|
||||
export function YourSkillFit({ job, held, className = '' }) {
|
||||
const match = matchPosition(job, held);
|
||||
if (!match) return null;
|
||||
const tone = TONE[match.tone] || TONE.warning;
|
||||
|
||||
@@ -83,6 +83,7 @@ function ScoreFactor({ label, value }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {any} props */
|
||||
export default function TalentDetailModal({ open, onClose, profile, onContact, onSchedule, onHire, matchScore, matchReasons }) {
|
||||
if (!profile) return null;
|
||||
const badges = profile.earned_badges || [];
|
||||
|
||||
@@ -92,9 +92,17 @@ function SkillPath({ state }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {any} props */
|
||||
export function VerifiedSkills({
|
||||
pageId, profile, profiles = [], courses = [], positions = [], email, customSources = [],
|
||||
onOpenTraining, className,
|
||||
pageId = '',
|
||||
profile = null,
|
||||
profiles = [],
|
||||
courses = [],
|
||||
positions = [],
|
||||
email = '',
|
||||
customSources = [],
|
||||
onOpenTraining = null,
|
||||
className = '',
|
||||
}) {
|
||||
/* Accepts a profile or the email of one, so a candidate record — which is an
|
||||
application, not a worker profile — can be matched to the person behind it. */
|
||||
@@ -126,7 +134,7 @@ export function VerifiedSkills({
|
||||
if (!worker) return [];
|
||||
const byId = new Map(courses.map((c) => [c.id, c]));
|
||||
return [...(worker.completed_courses || [])]
|
||||
.sort((a, b) => new Date(b.completed_date) - new Date(a.completed_date))
|
||||
.sort((a, b) => new Date(b.completed_date).getTime() - new Date(a.completed_date).getTime())
|
||||
.slice(0, 3)
|
||||
.map((entry) => {
|
||||
const course = byId.get(entry.course_id);
|
||||
|
||||
@@ -172,7 +172,8 @@ const initialsFor = (name) => {
|
||||
* hierarchy this card is meant to establish. Values share one ink colour so no
|
||||
* single signal shouts louder than the rest.
|
||||
*/
|
||||
function SupportingSignal({ label, value, suffix, icon: Icon }) {
|
||||
/** @param {any} props */
|
||||
function SupportingSignal({ label, value, suffix = '', icon: Icon = null }) {
|
||||
const shown = value != null && value !== '' ? value : '—';
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
|
||||
@@ -96,7 +96,8 @@ function FactorPill({ label, value }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ReportRow({ icon: Icon, label, value, tone }) {
|
||||
/** @param {any} props */
|
||||
function ReportRow({ icon: Icon, label, value, tone = 'neutral' }) {
|
||||
const valColor = tone === 'verified' ? 'text-[#10B981]' : tone === 'amber' ? 'text-[#D97706]' : 'text-[#0F172A]';
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 py-2 border-b border-white/60 last:border-0">
|
||||
@@ -117,7 +118,7 @@ export default function IdentityCareerCard({ profile }) {
|
||||
|
||||
const score = getTalentScore(profile);
|
||||
const band = getScoreBand(score);
|
||||
const growth = getGrowth(profile);
|
||||
const _growth = getGrowth(profile);
|
||||
const esat = getESAT(profile);
|
||||
const fico = toFICO(score);
|
||||
const shortId = (profile.id || '').slice(-6).toUpperCase();
|
||||
|
||||
@@ -29,7 +29,8 @@ function marketRange(profile = {}) {
|
||||
return `$${low}-$${low + 4}/hr`;
|
||||
}
|
||||
|
||||
function Field({ label, value, sub, accent }) {
|
||||
/** @param {any} props */
|
||||
function Field({ label, value, sub = '', accent = false }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-wide text-[#6B7280]">{label}</p>
|
||||
@@ -68,7 +69,8 @@ function Factor({ label, value, weight }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function TalentHero({ profile, jobRecs = [], nextCourse, openOwliver }) {
|
||||
/** @param {any} props */
|
||||
export default function TalentHero({ profile, _jobRecs = [], nextCourse = null, openOwliver = null }) {
|
||||
const score = getTalentScore(profile);
|
||||
const factors = getFactors(profile);
|
||||
const first = (profile.full_name || 'there').split(' ')[0];
|
||||
|
||||
@@ -160,7 +160,21 @@ function SkillSection({ skill, section, context }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function SkillSurface({ page, placement, context = null, className }) {
|
||||
/** @param {any} props */
|
||||
export function SkillSurface({
|
||||
page = '',
|
||||
placement = '',
|
||||
context = null,
|
||||
className = '',
|
||||
_pageId = '',
|
||||
_profile = null,
|
||||
_profiles = [],
|
||||
_courses = [],
|
||||
_positions = [],
|
||||
_customSources = [],
|
||||
_email = '',
|
||||
_onOpenTraining = null,
|
||||
}) {
|
||||
const sections = useSkillSections(page, placement);
|
||||
const resolved = useSkillDataContext(context);
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ const badgeVariants = cva(
|
||||
}
|
||||
)
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const Badge = React.forwardRef(({ className, variant, size, shape, ...props }, ref) => (
|
||||
<div ref={ref} className={cn(badgeVariants({ variant, size, shape }), className)} {...props} />
|
||||
))
|
||||
|
||||
@@ -4,11 +4,13 @@ import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const Breadcrumb = React.forwardRef(
|
||||
({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />
|
||||
)
|
||||
Breadcrumb.displayName = "Breadcrumb"
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const BreadcrumbList = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<ol
|
||||
ref={ref}
|
||||
@@ -20,6 +22,7 @@ const BreadcrumbList = React.forwardRef(({ className, ...props }, ref) => (
|
||||
))
|
||||
BreadcrumbList.displayName = "BreadcrumbList"
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const BreadcrumbItem = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<li
|
||||
ref={ref}
|
||||
@@ -28,6 +31,7 @@ const BreadcrumbItem = React.forwardRef(({ className, ...props }, ref) => (
|
||||
))
|
||||
BreadcrumbItem.displayName = "BreadcrumbItem"
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const BreadcrumbLink = React.forwardRef(({ asChild, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "a"
|
||||
|
||||
@@ -40,6 +44,7 @@ const BreadcrumbLink = React.forwardRef(({ asChild, className, ...props }, ref)
|
||||
})
|
||||
BreadcrumbLink.displayName = "BreadcrumbLink"
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const BreadcrumbPage = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<span
|
||||
ref={ref}
|
||||
@@ -51,6 +56,7 @@ const BreadcrumbPage = React.forwardRef(({ className, ...props }, ref) => (
|
||||
))
|
||||
BreadcrumbPage.displayName = "BreadcrumbPage"
|
||||
|
||||
/** @param {any} props */
|
||||
const BreadcrumbSeparator = ({
|
||||
children,
|
||||
className,
|
||||
@@ -66,6 +72,7 @@ const BreadcrumbSeparator = ({
|
||||
)
|
||||
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
|
||||
|
||||
/** @param {any} props */
|
||||
const BreadcrumbEllipsis = ({
|
||||
className,
|
||||
...props
|
||||
|
||||
@@ -82,6 +82,7 @@ const buttonVariants = cva(
|
||||
}
|
||||
)
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const Button = React.forwardRef((
|
||||
{ className, variant, size, shape, block, asChild = false, loading = false, disabled, children, ...props },
|
||||
ref
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const Card = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
@@ -10,6 +11,7 @@ const Card = React.forwardRef(({ className, ...props }, ref) => (
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
@@ -18,6 +20,7 @@ const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
@@ -26,6 +29,7 @@ const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
@@ -34,11 +38,13 @@ const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Check } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const Checkbox = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
|
||||
@@ -14,6 +14,7 @@ const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const DialogOverlay = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
@@ -25,6 +26,7 @@ const DialogOverlay = React.forwardRef(({ className, ...props }, ref) => (
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const DialogContent = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
@@ -46,8 +48,9 @@ const DialogContent = React.forwardRef(({ className, children, ...props }, ref)
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
/** @param {any} props */
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
className = '',
|
||||
...props
|
||||
}) => (
|
||||
<div
|
||||
@@ -56,8 +59,9 @@ const DialogHeader = ({
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
/** @param {any} props */
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
className = '',
|
||||
...props
|
||||
}) => (
|
||||
<div
|
||||
@@ -66,6 +70,7 @@ const DialogFooter = ({
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const DialogTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
@@ -74,6 +79,7 @@ const DialogTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const DialogDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
|
||||
@@ -16,6 +16,7 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const DropdownMenuSubTrigger = React.forwardRef(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
@@ -32,6 +33,7 @@ const DropdownMenuSubTrigger = React.forwardRef(({ className, inset, children, .
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const DropdownMenuSubContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
@@ -44,6 +46,7 @@ const DropdownMenuSubContent = React.forwardRef(({ className, ...props }, ref) =
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const DropdownMenuContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
@@ -59,6 +62,7 @@ const DropdownMenuContent = React.forwardRef(({ className, sideOffset = 4, ...pr
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const DropdownMenuItem = React.forwardRef(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
@@ -71,6 +75,7 @@ const DropdownMenuItem = React.forwardRef(({ className, inset, ...props }, ref)
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const DropdownMenuCheckboxItem = React.forwardRef(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
@@ -91,9 +96,11 @@ const DropdownMenuCheckboxItem = React.forwardRef(({ className, children, checke
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const DropdownMenuRadioItem = React.forwardRef(({ className, children, value, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
value={value}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
@@ -109,6 +116,7 @@ const DropdownMenuRadioItem = React.forwardRef(({ className, children, ...props
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const DropdownMenuLabel = React.forwardRef(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
@@ -117,6 +125,7 @@ const DropdownMenuLabel = React.forwardRef(({ className, inset, ...props }, ref)
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const DropdownMenuSeparator = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
|
||||
@@ -9,6 +9,7 @@ const HoverCard = HoverCardPrimitive.Root
|
||||
|
||||
const HoverCardTrigger = HoverCardPrimitive.Trigger
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const HoverCardContent = React.forwardRef(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<HoverCardPrimitive.Content
|
||||
ref={ref}
|
||||
|
||||
@@ -72,6 +72,7 @@ function buildSrcSet(parsed, options) {
|
||||
).join(", ")
|
||||
}
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const ImageWrapper = React.forwardRef(({ aspectRatio, className, style, children }, ref) => (
|
||||
<span
|
||||
ref={ref}
|
||||
@@ -83,6 +84,7 @@ const ImageWrapper = React.forwardRef(({ aspectRatio, className, style, children
|
||||
))
|
||||
ImageWrapper.displayName = "ImageWrapper"
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const ResponsiveImage = React.forwardRef(
|
||||
({ parsed, fittingType, focalPoint, quality, className, style, aspectRatio, onLoad, ...props }, parentRef) => {
|
||||
const wrapperRef = React.useRef(null)
|
||||
@@ -172,6 +174,7 @@ ResponsiveImage.displayName = "ResponsiveImage"
|
||||
* server-side, optionally anchored at a focal point. Other URLs render as a
|
||||
* plain <img>. Failed loads swap to a fallback image.
|
||||
*/
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const Image = React.forwardRef(
|
||||
(
|
||||
{
|
||||
|
||||
@@ -34,6 +34,7 @@ const inputVariants = cva(
|
||||
}
|
||||
)
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const Input = React.forwardRef(({ className, type, size, shape, ...props }, ref) => (
|
||||
<input
|
||||
type={type}
|
||||
|
||||
@@ -8,6 +8,7 @@ const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const Label = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||
))
|
||||
|
||||
@@ -9,6 +9,7 @@ const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverAnchor = PopoverPrimitive.Anchor
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const PopoverContent = React.forwardRef(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
|
||||
@@ -4,15 +4,18 @@ import { Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const RadioGroup = React.forwardRef(({ className, ...props }, ref) => {
|
||||
return (<RadioGroupPrimitive.Root className={cn("grid gap-2", className)} {...props} ref={ref} />);
|
||||
})
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
||||
|
||||
const RadioGroupItem = React.forwardRef(({ className, ...props }, ref) => {
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const RadioGroupItem = React.forwardRef(({ className = '', value, ...props }, ref) => {
|
||||
return (
|
||||
(<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
value={value}
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 rounded-full border border-border data-[state=checked]:border-krow-blue text-krow-blue shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-krow-blue/60 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
|
||||
@@ -12,6 +12,7 @@ const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const SelectTrigger = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
@@ -28,6 +29,7 @@ const SelectTrigger = React.forwardRef(({ className, children, ...props }, ref)
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const SelectScrollUpButton = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
@@ -38,6 +40,7 @@ const SelectScrollUpButton = React.forwardRef(({ className, ...props }, ref) =>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const SelectScrollDownButton = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
@@ -49,6 +52,7 @@ const SelectScrollDownButton = React.forwardRef(({ className, ...props }, ref) =
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const SelectContent = React.forwardRef(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
@@ -73,6 +77,7 @@ const SelectContent = React.forwardRef(({ className, children, position = "poppe
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const SelectLabel = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
@@ -81,9 +86,17 @@ const SelectLabel = React.forwardRef(({ className, ...props }, ref) => (
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
/* `value` is named and forwarded explicitly — the props type here is `any`, so a
|
||||
value arriving only through `...props` is invisible to the checker and it flags
|
||||
the required prop as missing. Naming it without passing it back down is the
|
||||
trap: Radix then has no value to match against, no item is ever `isSelected`,
|
||||
the trigger's value node never receives any item text, and every closed select
|
||||
renders as an empty pill with nothing but a chevron. */
|
||||
const SelectItem = React.forwardRef(({ className = '', children, value, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
value={value}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
@@ -99,6 +112,7 @@ const SelectItem = React.forwardRef(({ className, children, ...props }, ref) =>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const SelectSeparator = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
|
||||
@@ -14,6 +14,7 @@ const SheetClose = SheetPrimitive.Close
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const SheetOverlay = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
@@ -44,6 +45,7 @@ const sheetVariants = cva(
|
||||
}
|
||||
)
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const SheetContent = React.forwardRef(({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
@@ -79,6 +81,7 @@ const SheetFooter = ({
|
||||
)
|
||||
SheetFooter.displayName = "SheetFooter"
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const SheetTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
@@ -87,6 +90,7 @@ const SheetTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
))
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const SheetDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const Slider = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const Switch = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
|
||||
@@ -5,6 +5,7 @@ import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const TabsList = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
@@ -16,6 +17,7 @@ const TabsList = React.forwardRef(({ className, ...props }, ref) => (
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
@@ -27,6 +29,7 @@ const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const Textarea = React.forwardRef(({ className, ...props }, ref) => {
|
||||
return (
|
||||
(<textarea
|
||||
|
||||
@@ -3,6 +3,7 @@ import { cva } from "class-variance-authority";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const ToastProvider = React.forwardRef(({ ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
@@ -12,6 +13,7 @@ const ToastProvider = React.forwardRef(({ ...props }, ref) => (
|
||||
));
|
||||
ToastProvider.displayName = "ToastProvider";
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const ToastViewport = React.forwardRef(({ ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
@@ -37,6 +39,7 @@ const toastVariants = cva(
|
||||
}
|
||||
);
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const Toast = React.forwardRef(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
@@ -48,6 +51,7 @@ const Toast = React.forwardRef(({ className, variant, ...props }, ref) => {
|
||||
});
|
||||
Toast.displayName = "Toast";
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const ToastAction = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
@@ -60,6 +64,7 @@ const ToastAction = React.forwardRef(({ className, ...props }, ref) => (
|
||||
));
|
||||
ToastAction.displayName = "ToastAction";
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const ToastClose = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<button
|
||||
ref={ref}
|
||||
@@ -75,6 +80,7 @@ const ToastClose = React.forwardRef(({ className, ...props }, ref) => (
|
||||
));
|
||||
ToastClose.displayName = "ToastClose";
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const ToastTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
@@ -84,6 +90,7 @@ const ToastTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
));
|
||||
ToastTitle.displayName = "ToastTitle";
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const ToastDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
ToastViewport,
|
||||
} from "@/components/ui/toast";
|
||||
|
||||
export function Toaster() {
|
||||
/** @param {any} [props] */
|
||||
export function Toaster(props) {
|
||||
const { toasts } = useToast();
|
||||
|
||||
return (
|
||||
|
||||
@@ -11,6 +11,7 @@ const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const TooltipContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
|
||||
@@ -11,8 +11,9 @@ import { base44 } from '@/api/base44Client';
|
||||
* consumer stay unchanged.
|
||||
*/
|
||||
|
||||
const AuthContext = createContext();
|
||||
const AuthContext = createContext(/** @type {any} */ (null));
|
||||
|
||||
/** @param {any} props */
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const [user, setUser] = useState(null);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
|
||||
@@ -31,7 +31,7 @@ export function buildHires({ staff = [], applications = [], postings = [] }) {
|
||||
const app = applications.find((a) => a.id === s.application_id);
|
||||
const posting = postings.find((p) => p.id === s.job_posting_id);
|
||||
const days = app
|
||||
? Math.max(1, Math.round((new Date(app.updated_date) - new Date(app.created_date)) / 86400000))
|
||||
? Math.max(1, Math.round((new Date(app.updated_date).getTime() - new Date(app.created_date).getTime()) / 86400000))
|
||||
: null;
|
||||
|
||||
return {
|
||||
|
||||
@@ -23,7 +23,7 @@ export function useCurrentUser() {
|
||||
export function useUpdateProfile() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (patch) => base44.auth.updateMe(patch),
|
||||
mutationFn: /** @param {any} patch */ (patch) => base44.auth.updateMe(patch),
|
||||
onSuccess: (user) => {
|
||||
queryClient.setQueryData(['user'], user);
|
||||
queryClient.invalidateQueries({ queryKey: ['user'] });
|
||||
@@ -46,7 +46,7 @@ export function usePreferences() {
|
||||
export function useUpdatePreferences() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (patch) => base44.auth.updatePreferences(patch),
|
||||
mutationFn: /** @param {any} patch */ (patch) => base44.auth.updatePreferences(patch),
|
||||
onSuccess: (user) => {
|
||||
queryClient.setQueryData(['user'], user);
|
||||
queryClient.invalidateQueries({ queryKey: ['user'] });
|
||||
@@ -96,7 +96,7 @@ export function useStaff() {
|
||||
export function useUpdateStaff() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }) => base44.entities.Staff.update(id, data),
|
||||
mutationFn: /** @param {any} vars */ ({ id, data }) => base44.entities.Staff.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['staff'] });
|
||||
},
|
||||
@@ -120,7 +120,7 @@ export function useCertifications() {
|
||||
export function useCreateCertification() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data) => base44.entities.Certification.create(data),
|
||||
mutationFn: /** @param {any} data */ (data) => base44.entities.Certification.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['certifications'] });
|
||||
},
|
||||
@@ -130,7 +130,7 @@ export function useCreateCertification() {
|
||||
export function useDeleteCertification() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id) => base44.entities.Certification.delete(id),
|
||||
mutationFn: /** @param {any} id */ (id) => base44.entities.Certification.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['certifications'] });
|
||||
},
|
||||
@@ -140,7 +140,7 @@ export function useDeleteCertification() {
|
||||
export function useCreateRoleCategory() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data) => base44.entities.RoleCategory.create(data),
|
||||
mutationFn: /** @param {any} data */ (data) => base44.entities.RoleCategory.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['roleCategories'] });
|
||||
},
|
||||
@@ -157,7 +157,7 @@ export function useUserActivity() {
|
||||
export function useCreateJobPosting() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data) => base44.entities.JobPosting.create(data),
|
||||
mutationFn: /** @param {any} data */ (data) => base44.entities.JobPosting.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['jobPostings'] });
|
||||
logActivity('create_position');
|
||||
@@ -168,7 +168,7 @@ export function useCreateJobPosting() {
|
||||
export function useUpdateJobPosting() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }) => base44.entities.JobPosting.update(id, data),
|
||||
mutationFn: /** @param {any} vars */ ({ id, data }) => base44.entities.JobPosting.update(id, data),
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['jobPostings'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['jobPosting', variables.id] });
|
||||
@@ -179,7 +179,7 @@ export function useUpdateJobPosting() {
|
||||
export function useCreateApplication() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data) => base44.entities.JobApplication.create(data),
|
||||
mutationFn: /** @param {any} data */ (data) => base44.entities.JobApplication.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
||||
logActivity('apply_job');
|
||||
@@ -190,7 +190,7 @@ export function useCreateApplication() {
|
||||
export function useUpdateApplication() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }) => base44.entities.JobApplication.update(id, data),
|
||||
mutationFn: /** @param {any} vars */ ({ id, data }) => base44.entities.JobApplication.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
||||
},
|
||||
@@ -200,7 +200,7 @@ export function useUpdateApplication() {
|
||||
export function useScreenCandidate() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ application, job }) => {
|
||||
mutationFn: /** @param {any} vars */ async ({ application, job }) => {
|
||||
const result = await screenCandidate(application, job);
|
||||
return base44.entities.JobApplication.update(application.id, {
|
||||
status: 'ai_screened',
|
||||
@@ -223,7 +223,7 @@ export function useScreenCandidate() {
|
||||
export function useScreenAllCandidates() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ applications, job }) => {
|
||||
mutationFn: /** @param {any} vars */ async ({ applications, job }) => {
|
||||
const results = [];
|
||||
for (const app of applications) {
|
||||
if (app.status === 'applied' || app.status === 'ai_screened') {
|
||||
@@ -253,7 +253,7 @@ export function useScreenAllCandidates() {
|
||||
export function useGenerateJobDescription() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ data, draftId }) => {
|
||||
mutationFn: /** @param {any} vars */ async ({ data, draftId }) => {
|
||||
const result = await generateJobDescription(data);
|
||||
if (draftId) {
|
||||
await base44.entities.JobPosting.update(draftId, {
|
||||
@@ -274,7 +274,7 @@ export function useGenerateJobDescription() {
|
||||
export function useHireCandidate() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ application, job }) => {
|
||||
mutationFn: /** @param {any} vars */ async ({ application, job }) => {
|
||||
const tier = application.ai_score >= 80 ? 'Skilled' : application.ai_score >= 60 ? 'Cross-Trained' : 'Beginner';
|
||||
const updated = await base44.entities.JobApplication.update(application.id, { status: 'hired' });
|
||||
await base44.entities.Staff.create({
|
||||
@@ -301,14 +301,14 @@ export function useHireCandidate() {
|
||||
|
||||
export function useMatchTalentForJob() {
|
||||
return useMutation({
|
||||
mutationFn: async ({ job, profiles }) => matchTalentForJob(job, profiles),
|
||||
mutationFn: /** @param {any} vars */ async ({ job, profiles }) => matchTalentForJob(job, profiles),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateInterview() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data) => base44.entities.AIInterview.create(data),
|
||||
mutationFn: /** @param {any} data */ (data) => base44.entities.AIInterview.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['interviews'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
||||
@@ -339,7 +339,7 @@ export function useCourse(id) {
|
||||
export function useCreateCourse() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data) => base44.entities.Course.create(data),
|
||||
mutationFn: /** @param {any} data */ (data) => base44.entities.Course.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['courses'] });
|
||||
logActivity('create_skill_training');
|
||||
@@ -351,7 +351,7 @@ export function useCreateCourse() {
|
||||
export function useUpdateCourse() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }) => base44.entities.Course.update(id, data),
|
||||
mutationFn: /** @param {any} vars */ ({ id, data }) => base44.entities.Course.update(id, data),
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['courses'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['course', variables.id] });
|
||||
@@ -385,7 +385,7 @@ export function useAssignments() {
|
||||
export function useAssignWorkers() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ position, workers = [], applications = [] }) => {
|
||||
mutationFn: /** @param {any} vars */ async ({ position, workers = [], applications = [] }) => {
|
||||
const startsAt = position.start_date
|
||||
? new Date(position.start_date).toISOString()
|
||||
: new Date().toISOString();
|
||||
@@ -484,7 +484,7 @@ export function useAssignWorkers() {
|
||||
export function useMarkInterviewReady() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ application, position }) => {
|
||||
mutationFn: /** @param {any} vars */ async ({ application, position }) => {
|
||||
if (!application?.id) throw new Error('No application to schedule against');
|
||||
|
||||
const updated = await base44.entities.JobApplication.update(application.id, {
|
||||
@@ -564,7 +564,7 @@ export function useWorkerProfiles() {
|
||||
export function useUpdateWorkerProfile() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }) => base44.entities.WorkerProfile.update(id, data),
|
||||
mutationFn: /** @param {any} vars */ ({ id, data }) => base44.entities.WorkerProfile.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['workerProfile'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['workerProfiles'] });
|
||||
@@ -575,7 +575,7 @@ export function useUpdateWorkerProfile() {
|
||||
export function useCompleteCourse() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ profile, course, quizScore }) => {
|
||||
mutationFn: /** @param {any} vars */ async ({ profile, course, quizScore }) => {
|
||||
const already = (profile.completed_courses || []).some(c => c.course_id === course.id);
|
||||
const completed_courses = already ? profile.completed_courses : [
|
||||
...profile.completed_courses,
|
||||
@@ -616,7 +616,7 @@ export function useEvidenceList(workerEmail) {
|
||||
export function useSubmitChallenge() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ profile, course, type, mediaUrl, transcript, identified }) => {
|
||||
mutationFn: /** @param {any} vars */ async ({ profile, course, type, mediaUrl, transcript, identified }) => {
|
||||
const result = await evaluateChallenge(course, { type, mediaUrl, transcript, identified, workerName: profile?.full_name });
|
||||
const verdict = result.verdict || 'needs_work';
|
||||
const score = Math.round(Number(result.score) || 0);
|
||||
@@ -674,7 +674,7 @@ export function useSubmitChallenge() {
|
||||
export function useVerifyEvidence() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ evidenceId }) =>
|
||||
mutationFn: /** @param {any} vars */ async ({ evidenceId }) =>
|
||||
base44.entities.Evidence.update(evidenceId, {
|
||||
supervisor_verified: true,
|
||||
supervisor_name: 'Supervisor',
|
||||
|
||||
@@ -397,7 +397,7 @@ const RESOLVERS = {
|
||||
/** Everyone hired, most recent first — the record rather than the analysis. */
|
||||
'hires.recent': ({ staff = [], applications = [], positions = [] }, section) => {
|
||||
const items = [...staff]
|
||||
.sort((a, b) => new Date(b.hire_date || b.created_date || 0) - new Date(a.hire_date || a.created_date || 0))
|
||||
.sort((a, b) => new Date(b.hire_date || b.created_date || 0).getTime() - new Date(a.hire_date || a.created_date || 0).getTime())
|
||||
.slice(0, section.limit || 10)
|
||||
.map((s) => {
|
||||
const app = applications.find((a) => a.id === s.application_id);
|
||||
@@ -432,7 +432,7 @@ const RESOLVERS = {
|
||||
const hired = applications.filter((a) => a.status === 'hired');
|
||||
const scores = applications.map((a) => a.ai_score).filter((n) => n > 0);
|
||||
const days = hired
|
||||
.map((a) => Math.round((new Date(a.updated_date) - new Date(a.created_date)) / 86400000))
|
||||
.map((a) => Math.round((new Date(a.updated_date).getTime() - new Date(a.created_date).getTime()) / 86400000))
|
||||
.filter((n) => Number.isFinite(n) && n >= 0);
|
||||
|
||||
const mean = (xs) => (xs.length ? Math.round(xs.reduce((a, b) => a + b, 0) / xs.length) : 0);
|
||||
@@ -466,7 +466,7 @@ const RESOLVERS = {
|
||||
/** The workspace audit trail, most recent first. */
|
||||
'activity.events': ({ activity = [] }, section) => {
|
||||
const items = [...activity]
|
||||
.sort((a, b) => new Date(b.created_date || 0) - new Date(a.created_date || 0))
|
||||
.sort((a, b) => new Date(b.created_date || 0).getTime() - new Date(a.created_date || 0).getTime())
|
||||
.slice(0, section.limit || 10)
|
||||
.map((event) => ({
|
||||
id: event.id,
|
||||
|
||||
@@ -41,14 +41,68 @@ function parseFrontmatter(raw) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Bullets under a `## Heading`, for the capability list shown in Settings. */
|
||||
/**
|
||||
* The text under a `## Heading`, up to the next one.
|
||||
*
|
||||
* The heading is escaped before it becomes a pattern. It used to be
|
||||
* interpolated raw, so a heading containing regular-expression punctuation —
|
||||
* `## Capabilities (v2)` is the obvious one — compiled to a pattern that could
|
||||
* not match the words it was built from, and the whole section silently read as
|
||||
* absent. A section that is present and unreadable is the failure this parser
|
||||
* must never have: it looks exactly like a section the author did not write.
|
||||
*/
|
||||
function sectionSource(body, heading) {
|
||||
const escaped = String(heading).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const section = new RegExp(`##\\s+${escaped}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, 'i').exec(body);
|
||||
return section ? section[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* List items under a `## Heading`, for the capability list shown in Settings.
|
||||
*
|
||||
* Two kinds of content were being dropped without a word, and both are the same
|
||||
* mistake — treating "a line I do not recognise" as "a line that is not there":
|
||||
*
|
||||
* - **Wrapped items.** A bullet long enough to run onto a second line kept
|
||||
* only its first line. `create-position.md` documented reading a request
|
||||
* "out of a single sentence" and the registry held the sentence without its
|
||||
* last three words. An item is now everything up to the next item or the
|
||||
* blank line that ends the list.
|
||||
* - **Numbered items.** Only `-` counted, so a `1.` list — the natural way to
|
||||
* write ordered instructions — parsed as an empty section. Both markers are
|
||||
* list items in Markdown and both are read as one here.
|
||||
*
|
||||
* Neither change alters any definition currently on disk: every one of them
|
||||
* uses single-line `-` bullets, so this widens what can be written without
|
||||
* moving what already was.
|
||||
*/
|
||||
const LIST_ITEM = /^\s*(?:-|\*|\d+[.)])\s+(.*)$/;
|
||||
|
||||
function sectionBullets(body, heading) {
|
||||
const section = new RegExp(`##\\s+${heading}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, 'i').exec(body);
|
||||
if (!section) return [];
|
||||
return section[1]
|
||||
.split(/\r?\n/)
|
||||
.map((l) => /^\s*-\s+(.*)$/.exec(l)?.[1]?.trim())
|
||||
.filter(Boolean);
|
||||
const source = sectionSource(body, heading);
|
||||
if (source == null) return [];
|
||||
|
||||
const items = [];
|
||||
for (const line of source.split(/\r?\n/)) {
|
||||
const item = LIST_ITEM.exec(line);
|
||||
if (item) {
|
||||
items.push(item[1].trim());
|
||||
continue;
|
||||
}
|
||||
/* A blank line closes the current item; anything else indented under one is
|
||||
its continuation and belongs to it. Prose before the first item — the
|
||||
explanatory paragraph `## Conversation` opens with — matches neither and
|
||||
is ignored, exactly as before. */
|
||||
if (!line.trim()) {
|
||||
if (items.length) items.push('');
|
||||
continue;
|
||||
}
|
||||
if (items.length && items[items.length - 1] !== '' && /^\s+/.test(line)) {
|
||||
items[items.length - 1] += ` ${line.trim()}`;
|
||||
}
|
||||
}
|
||||
|
||||
return items.map((i) => i.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,11 +137,11 @@ function sectionSteps(body, heading) {
|
||||
* rather than a list.
|
||||
*/
|
||||
function sectionText(body, heading) {
|
||||
const section = new RegExp(`##\\s+${heading}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, 'i').exec(body);
|
||||
if (!section) return '';
|
||||
return section[1]
|
||||
const source = sectionSource(body, heading);
|
||||
if (source == null) return '';
|
||||
return source
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.replace(/^\s*[-*]\s+/, '').trim())
|
||||
.map((l) => l.replace(/^\s*(?:[-*]|\d+[.)])\s+/, '').trim())
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.trim();
|
||||
@@ -323,6 +377,139 @@ export const SKILLS = Object.entries(FILES)
|
||||
.map(([path, raw]) => parseSkill(raw, { path }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
/**
|
||||
* The registry as actually assembled, with everything that went wrong assembling
|
||||
* it.
|
||||
*
|
||||
* This function exists because the three ways a definition could fail to arrive
|
||||
* intact were all *silent*, and between them they are the whole of the
|
||||
* "sometimes a skill only half works" report:
|
||||
*
|
||||
* 1. **A stored definition that will not parse was dropped.** The `catch`
|
||||
* here returned `null` and the entry vanished. Nothing anywhere said a
|
||||
* skill had been discarded, so the skill simply stopped answering and the
|
||||
* registry looked healthy.
|
||||
* 2. **A definition that parses can still register incomplete.**
|
||||
* `normalizeSkillOwliver` keeps the capabilities that resolve and reports
|
||||
* the rest as errors — correct, and the reason a single bad response does
|
||||
* not cost an author their whole file. But only `validateSkillSource`
|
||||
* reads those errors, and that runs in the authoring dialog. A definition
|
||||
* arriving from storage was registered with its broken capability quietly
|
||||
* missing, which is exactly "some checks work and others do not".
|
||||
* 3. **A custom definition silently replaces a built-in of the same id.**
|
||||
* Intended — it is how a shipped skill is overridden — but indistinguishable
|
||||
* from the built-in having broken, because nothing reported the override.
|
||||
*
|
||||
* Why deployment exposed all three: custom definitions live in per-account
|
||||
* `preferences`, not in the repository. The bundled `.md` files are identical
|
||||
* everywhere — verified — so a definition that behaves differently in a
|
||||
* deployed workspace differs because of what that *account* has stored, and
|
||||
* because stored Markdown is validated when it is written and never again.
|
||||
* Ship a change to the supported vocabulary and yesterday's valid definition
|
||||
* silently loses a capability on next load.
|
||||
*
|
||||
* Nothing is dropped that used to register, and nothing new registers. The only
|
||||
* change is that the failures now have somewhere to be read from.
|
||||
*/
|
||||
export function readSkillRegistry(customSources = []) {
|
||||
const diagnostics = [];
|
||||
const custom = [];
|
||||
|
||||
customSources.forEach((entry, i) => {
|
||||
const path = entry?.path || `custom/${i}.md`;
|
||||
let skill = null;
|
||||
try {
|
||||
skill = parseSkill(entry?.raw ?? entry, { path, custom: true });
|
||||
} catch (error) {
|
||||
diagnostics.push({
|
||||
level: 'error',
|
||||
kind: 'unreadable',
|
||||
path,
|
||||
skillId: null,
|
||||
message: `A stored skill could not be read and is not registered. ${error?.message || ''}`.trim(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!skill?.id) {
|
||||
diagnostics.push({
|
||||
level: 'error',
|
||||
kind: 'unreadable',
|
||||
path,
|
||||
skillId: null,
|
||||
message: 'A stored skill has no `id` and is not registered.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
custom.push(skill);
|
||||
});
|
||||
|
||||
const byId = new Map(SKILLS.map((s) => [s.id, s]));
|
||||
for (const skill of custom) {
|
||||
if (byId.has(skill.id) && !byId.get(skill.id).custom) {
|
||||
diagnostics.push({
|
||||
level: 'warning',
|
||||
kind: 'shadowed',
|
||||
path: skill.path,
|
||||
skillId: skill.id,
|
||||
message: `\`${skill.id}\` replaces the built-in skill of the same id. The built-in definition is not registered.`,
|
||||
});
|
||||
}
|
||||
byId.set(skill.id, skill);
|
||||
}
|
||||
|
||||
const skills = [...byId.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
/* A registered definition that lost part of itself on the way in. Reported
|
||||
for every skill, not only custom ones, so a file on disk that stops
|
||||
resolving after a vocabulary change is just as visible. */
|
||||
for (const skill of skills) {
|
||||
for (const message of [...(skill.owliverErrors || []), ...(skill.uiErrors || [])]) {
|
||||
diagnostics.push({
|
||||
level: 'error',
|
||||
kind: 'incomplete',
|
||||
path: skill.path,
|
||||
skillId: skill.id,
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Two definitions claiming one phrase on one page.
|
||||
*
|
||||
* Only the first is ever consulted, and "first" means alphabetically by name —
|
||||
* so the losing definition answers nothing, with no way to tell that from it
|
||||
* being switched off. Reported per page, because a shared trigger on two
|
||||
* different pages is not a contest.
|
||||
*/
|
||||
const claims = new Map();
|
||||
for (const skill of skills) {
|
||||
if (skill.status !== 'active' || !skill.declaredTriggers) continue;
|
||||
for (const page of skill.pages) {
|
||||
const key = canonicalPage(page) || page;
|
||||
for (const trigger of new Set(skill.triggers)) {
|
||||
const at = `${key}::${trigger}`;
|
||||
if (!claims.has(at)) claims.set(at, []);
|
||||
claims.get(at).push(skill);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [at, claimants] of claims) {
|
||||
if (claimants.length < 2) continue;
|
||||
const [page, trigger] = at.split('::');
|
||||
diagnostics.push({
|
||||
level: 'warning',
|
||||
kind: 'trigger-collision',
|
||||
path: claimants[0].path,
|
||||
skillId: claimants[0].id,
|
||||
message: `On ${page}, "${trigger}" is claimed by ${claimants.map((s) => s.id).join(' and ')}. `
|
||||
+ `Only ${claimants[0].id} is consulted.`,
|
||||
});
|
||||
}
|
||||
|
||||
return { skills, diagnostics };
|
||||
}
|
||||
|
||||
/**
|
||||
* The built-in skills plus any the account has added.
|
||||
*
|
||||
@@ -330,21 +517,17 @@ export const SKILLS = Object.entries(FILES)
|
||||
* is the same artefact a file on disk would be — nothing is half-parsed into a
|
||||
* bespoke record. A custom skill sharing an id with a built-in replaces it,
|
||||
* which is how one would be overridden without editing the repository.
|
||||
*
|
||||
* Unchanged in what it returns; `readSkillRegistry` is where the same work is
|
||||
* done with its failures kept.
|
||||
*/
|
||||
export function allSkills(customSources = []) {
|
||||
const custom = customSources
|
||||
.map((entry, i) => {
|
||||
try {
|
||||
return parseSkill(entry.raw ?? entry, { path: entry.path || `custom/${i}.md`, custom: true });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((s) => s && s.id);
|
||||
return readSkillRegistry(customSources).skills;
|
||||
}
|
||||
|
||||
const byId = new Map(SKILLS.map((s) => [s.id, s]));
|
||||
custom.forEach((s) => byId.set(s.id, s));
|
||||
return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
/** Everything that went wrong assembling the registry, for the Skills page. */
|
||||
export function skillDiagnostics(customSources = []) {
|
||||
return readSkillRegistry(customSources).diagnostics;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -472,11 +655,27 @@ function triggerMatches(trigger, question) {
|
||||
*/
|
||||
const addressable = (skill) => skill.declaredTriggers || skill.facets?.includes('owliver');
|
||||
|
||||
export function matchSkill(question, contextId, disabled = [], customSources = []) {
|
||||
/**
|
||||
* Every skill on this page whose triggers match — in registry order.
|
||||
*
|
||||
* `matchSkill` answers with the first and is what routing uses, because one
|
||||
* question gets one answer. The full list is what makes the choice *auditable*:
|
||||
* when two definitions claim the same phrase, the loser was previously
|
||||
* invisible, and the winner was decided by `allSkills`'s alphabetical sort —
|
||||
* a definition renamed from "Hiring…" to "Activity…" could take over a phrase
|
||||
* without either file's triggers changing. Nothing here changes which skill
|
||||
* answers; it makes the fact that there was a contest something a caller can
|
||||
* see and report.
|
||||
*/
|
||||
export function matchSkills(question, contextId, disabled = [], customSources = []) {
|
||||
const q = String(question).toLowerCase();
|
||||
return skillsForContext(contextId, disabled, customSources).find(
|
||||
return skillsForContext(contextId, disabled, customSources).filter(
|
||||
(s) => addressable(s) && s.triggers.length > 0 && s.triggers.some((t) => triggerMatches(t, q))
|
||||
) ?? null;
|
||||
);
|
||||
}
|
||||
|
||||
export function matchSkill(question, contextId, disabled = [], customSources = []) {
|
||||
return matchSkills(question, contextId, disabled, customSources)[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -489,3 +688,28 @@ export function matchSkill(question, contextId, disabled = [], customSources = [
|
||||
*/
|
||||
export const skillsWithFacet = (skills = [], facet) =>
|
||||
skills.filter((s) => s.facets?.includes(facet));
|
||||
|
||||
/**
|
||||
* AI agent skills — the ones Workspace & Skills governs.
|
||||
*
|
||||
* Two different things live in this registry, and they belong to two different
|
||||
* products:
|
||||
*
|
||||
* - **AI agent skills** (`kind: 'assistant'`) are capabilities Owliver gains.
|
||||
* An Owliver skill teaches it what it can be asked; a Board skill draws a
|
||||
* section on a page. Both are governed in Workspace & Skills.
|
||||
* - **Workforce training** (`kind: 'workforce'`) is what a *person* learns and
|
||||
* proves — Bartending, Food Safety, Customer Service. That belongs to KROW
|
||||
* Forge, and is read through `workforceSkillStates`.
|
||||
*
|
||||
* This exists so no surface has to reach for `allSkills()` and hope. A page that
|
||||
* calls `allSkills()` gets both kinds, which is how five training paths ended up
|
||||
* counted as Owliver capabilities on the Workspace landing page — the registry
|
||||
* was right and the reading was not. Asking for agent skills by name means a
|
||||
* training path added tomorrow cannot appear there by default.
|
||||
*/
|
||||
export const aiAgentSkills = (skills = []) => skills.filter((s) => s.kind === 'assistant');
|
||||
|
||||
/** Workforce training definitions — KROW Forge's half of the same registry. */
|
||||
export const workforceTrainingSkills = (skills = []) =>
|
||||
skills.filter((s) => s.kind === 'workforce');
|
||||
|
||||
@@ -47,7 +47,7 @@ export function endsAt(position, today = new Date()) {
|
||||
|
||||
/** Days from now until the work starts. Negative or zero means it is live. */
|
||||
export const daysUntilStart = (position, today = new Date()) =>
|
||||
Math.round((startsAt(position, today) - today) / DAY_MS);
|
||||
Math.round((startsAt(position, today).getTime() - today.getTime()) / DAY_MS);
|
||||
|
||||
/** "Immediately", "in 6 days", "in 3 weeks" — the phrasing answers use. */
|
||||
export function startLabel(position, today = new Date()) {
|
||||
@@ -160,7 +160,7 @@ export function durationFitOf(profile, position, { today = new Date() } = {}) {
|
||||
partial fit for it, and anyone without is a full one. */
|
||||
const free = profile?.available_until ? new Date(profile.available_until) : null;
|
||||
if (!free) return { covers: true, ratio: 1, note: null };
|
||||
const months = Math.max(0, (free - startsAt(position, today)) / MONTH_MS);
|
||||
const months = Math.max(0, (free.getTime() - startsAt(position, today).getTime()) / MONTH_MS);
|
||||
return {
|
||||
covers: false,
|
||||
ratio: Math.min(1, months / 12),
|
||||
@@ -171,8 +171,8 @@ export function durationFitOf(profile, position, { today = new Date() } = {}) {
|
||||
const free = profile?.available_until ? new Date(profile.available_until) : null;
|
||||
if (!free || free >= end) return { covers: true, ratio: 1, note: null };
|
||||
|
||||
const needed = end - startsAt(position, today);
|
||||
const have = Math.max(0, free - startsAt(position, today));
|
||||
const needed = end.getTime() - startsAt(position, today).getTime();
|
||||
const have = Math.max(0, free.getTime() - startsAt(position, today).getTime());
|
||||
const ratio = needed ? Math.max(0, Math.min(1, have / needed)) : 1;
|
||||
|
||||
return {
|
||||
@@ -545,7 +545,9 @@ export function prioritise(positions = [], context = {}) {
|
||||
* function existing separately from the mutation is what makes "show me first"
|
||||
* the only possible path rather than a convention someone has to remember.
|
||||
*/
|
||||
export function prepareAssignment(position, { count, ...context } = {}) {
|
||||
/** @param {any} position @param {any} [options] */
|
||||
export function prepareAssignment(position, options = {}) {
|
||||
const { count, ...context } = options || {};
|
||||
const status = workforceStatusFor(position, context);
|
||||
const take = Math.min(
|
||||
count ?? status.demand.remaining,
|
||||
|
||||
@@ -68,7 +68,7 @@ export default function AdminDashboard() {
|
||||
return { applied: apps.length, screened: apps.filter(a => a.status !== 'applied').length, hired: apps.filter(a => a.status === 'hired').length };
|
||||
};
|
||||
|
||||
const recentStaff = [...activeStaff].sort((a, b) => new Date(b.hire_date || 0) - new Date(a.hire_date || 0)).slice(0, 6);
|
||||
const recentStaff = [...activeStaff].sort((a, b) => new Date(b.hire_date || 0).getTime() - new Date(a.hire_date || 0).getTime()).slice(0, 6);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
@@ -140,7 +140,7 @@ export default function AdminDashboard() {
|
||||
<p className="text-[13px] text-[#9CA3AF]">No hired staff yet</p>
|
||||
</div>
|
||||
)}
|
||||
{recentStaff.map((s, idx) => (
|
||||
{recentStaff.map((s, _idx) => (
|
||||
<div key={s.id} className="flex items-center gap-3 p-2.5 rounded-xl hover:bg-white/50 transition-colors">
|
||||
<div className="w-9 h-9 rounded-full bg-[#333F48] flex items-center justify-center text-white text-[12px] font-semibold shrink-0">
|
||||
{s.name?.split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase()}
|
||||
|
||||
@@ -30,7 +30,8 @@ import { AXIS_PROPS, CHART_TONES } from '@/components/ds';
|
||||
* place. Not linked from the product navigation — reach it at /design-system.
|
||||
*/
|
||||
|
||||
function Spec({ title, description, children, className }) {
|
||||
/** @param {any} props */
|
||||
function Spec({ title, description, children, className = '' }) {
|
||||
return (
|
||||
<Surface variant="solid" radius="xl" padding="lg" className={className}>
|
||||
<SectionHeader title={title} subtitle={description} />
|
||||
@@ -39,7 +40,8 @@ function Spec({ title, description, children, className }) {
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, children }) {
|
||||
/** @param {any} props */
|
||||
function Row({ label = '', children }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{label && <span className="text-caption text-ink-4 w-24 shrink-0">{label}</span>}
|
||||
|
||||
@@ -66,7 +66,7 @@ export default function HiredHistory() {
|
||||
return matchesSearch && matchesTier && matchesReview;
|
||||
});
|
||||
|
||||
const sorted = [...filtered].sort((a, b) => new Date(b.created_date) - new Date(a.created_date));
|
||||
const sorted = [...filtered].sort((a, b) => new Date(b.created_date).getTime() - new Date(a.created_date).getTime());
|
||||
|
||||
const ratedCount = staff.filter((s) => (s.client_rating || 0) > 0).length;
|
||||
const avgRating = ratedCount > 0
|
||||
@@ -215,7 +215,8 @@ export default function HiredHistory() {
|
||||
);
|
||||
}
|
||||
|
||||
function StatPill({ label, value, accent }) {
|
||||
/** @param {any} props */
|
||||
function StatPill({ label, value, accent = false }) {
|
||||
return (
|
||||
<div className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-[#F0F4F8] border border-[#E5E7EB]">
|
||||
<span className="text-[11px] font-semibold text-[#1E56C1]">{label}</span>
|
||||
|
||||
@@ -175,7 +175,8 @@ export default function Owliver({ compact = false }) {
|
||||
}, [workerName, speak, language]);
|
||||
|
||||
const startListening = useCallback(() => {
|
||||
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
const win = /** @type {any} */ (window);
|
||||
const SR = win.SpeechRecognition || win.webkitSpeechRecognition;
|
||||
if (!SR) { setError('Voice input not supported here — type your answer below.'); return; }
|
||||
// Yield Owliver's voice so the mic picks up the user, not Owliver.
|
||||
if (window.speechSynthesis) window.speechSynthesis.cancel();
|
||||
@@ -541,7 +542,8 @@ export default function Owliver({ compact = false }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryField({ label, value, full, empty = '—' }) {
|
||||
/** @param {any} props */
|
||||
function SummaryField({ label, value, full = false, empty = '—' }) {
|
||||
const has = value && String(value).trim();
|
||||
return (
|
||||
<div className={cn('rounded-lg bg-[#F9FAFB] p-2.5', full && 'col-span-2')}>
|
||||
|
||||
@@ -121,7 +121,7 @@ export default function AdminActivity() {
|
||||
const recent = useMemo(
|
||||
() => events
|
||||
.slice()
|
||||
.sort((a, b) => new Date(b.created_date) - new Date(a.created_date))
|
||||
.sort((a, b) => new Date(b.created_date).getTime() - new Date(a.created_date).getTime())
|
||||
.slice(0, TIMELINE_LIMIT),
|
||||
[events]
|
||||
);
|
||||
|
||||
@@ -85,7 +85,8 @@ const PRESSABLE = 'transition-transform duration-150 hover:-translate-y-px activ
|
||||
/* ── Small shared pieces ────────────────────────────────────────────────── */
|
||||
|
||||
/** A label inside a section — a rung below the section headings the rail draws. */
|
||||
function SubLabel({ children, className }) {
|
||||
/** @param {any} props */
|
||||
function SubLabel({ children, className = '' }) {
|
||||
return (
|
||||
<p className={cn('mb-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-ink-4', className)}>
|
||||
{children}
|
||||
@@ -115,7 +116,8 @@ function Signal({ label, value }) {
|
||||
}
|
||||
|
||||
/** A reputation figure. Value large, label quiet, no card around it. */
|
||||
function Metric({ label, value, suffix, tone }) {
|
||||
/** @param {any} props */
|
||||
function Metric({ label, value, suffix = '', tone = '' }) {
|
||||
if (value == null || value === '') return null;
|
||||
return (
|
||||
<div className="min-w-0 px-4 py-3">
|
||||
@@ -163,7 +165,8 @@ function Fact({ label, children }) {
|
||||
* Staggered by index, once. The delay is capped so a candidate with twenty
|
||||
* skills does not make the reader watch a queue form.
|
||||
*/
|
||||
function Pill({ index = 0, className, children }) {
|
||||
/** @param {any} props */
|
||||
function Pill({ index = 0, className = '', children }) {
|
||||
const reduced = useReducedMotion();
|
||||
return (
|
||||
<motion.span
|
||||
|
||||
@@ -21,7 +21,7 @@ const SCORE_BANDS = {
|
||||
|
||||
const SORTS = {
|
||||
score: { label: 'Highest score', compare: (a, b) => (b.ai_score || 0) - (a.ai_score || 0) },
|
||||
recent: { label: 'Recent activity', compare: (a, b) => new Date(b.updated_date) - new Date(a.updated_date) },
|
||||
recent: { label: 'Recent activity', compare: (a, b) => new Date(b.updated_date).getTime() - new Date(a.updated_date).getTime() },
|
||||
name: { label: 'Name A–Z', compare: (a, b) => a.applicant_name.localeCompare(b.applicant_name) },
|
||||
experience: { label: 'Most experience', compare: (a, b) => (b.years_experience || 0) - (a.years_experience || 0) },
|
||||
};
|
||||
|
||||
@@ -31,7 +31,8 @@ const RANGES = {
|
||||
|
||||
const WEAK_TRANSITION = 60;
|
||||
|
||||
function Band({ id, title, meta, children, action }) {
|
||||
/** @param {any} props */
|
||||
function Band({ id, title, meta = '', children, action = null }) {
|
||||
return (
|
||||
<section aria-labelledby={id} className="space-y-3">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-2">
|
||||
|
||||
@@ -166,7 +166,7 @@ export default function AdminHiredHistory() {
|
||||
if (cutoff && new Date(h.hire_date || 0).getTime() < cutoff) return false;
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => new Date(b.hire_date || 0) - new Date(a.hire_date || 0));
|
||||
.sort((a, b) => new Date(b.hire_date || 0).getTime() - new Date(a.hire_date || 0).getTime());
|
||||
}, [hires, search, filters]);
|
||||
|
||||
const summary = useMemo(() => summarise(filtered), [filtered]);
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function AdminLogin() {
|
||||
|
||||
const [form, setForm] = useState({ email: DEMO_EMAIL, password: '', remember: true });
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [errors, setErrors] = useState({});
|
||||
const [errors, setErrors] = useState(/** @type {{ email?: string, password?: string }} */ ({}));
|
||||
const [authError, setAuthError] = useState('');
|
||||
const [status, setStatus] = useState('idle');
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Avatar, Badge, Button, Drawer, EmptyState, ProgressBar, SearchInput, SegmentedToggle,
|
||||
Avatar, Badge, Button, Drawer, EmptyState, SearchInput, SegmentedToggle,
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Skeleton, StatusBadge, toast,
|
||||
} from '@/components/ds';
|
||||
import {
|
||||
@@ -59,7 +59,7 @@ const SORTS = {
|
||||
starved: { label: 'Fewest applicants', compare: (a, b) => a.stats.applied - b.stats.applied },
|
||||
updated: {
|
||||
label: 'Recently updated',
|
||||
compare: (a, b) => new Date(b.updated_date || b.created_date) - new Date(a.updated_date || a.created_date),
|
||||
compare: (a, b) => new Date(b.updated_date || b.created_date).getTime() - new Date(a.updated_date || a.created_date).getTime(),
|
||||
},
|
||||
title: { label: 'Title A–Z', compare: (a, b) => a.title.localeCompare(b.title) },
|
||||
};
|
||||
@@ -72,7 +72,8 @@ const HEALTH_TONE = {
|
||||
};
|
||||
|
||||
/** Hiring health as a small filled chip — it is a state, so it reads as one. */
|
||||
function HealthChip({ health, className }) {
|
||||
/** @param {any} props */
|
||||
function HealthChip({ health, className = '' }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
@@ -740,43 +741,48 @@ function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition })
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Hiring status */}
|
||||
<section>
|
||||
<SectionTitle title="Hiring status" />
|
||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-3">
|
||||
{[
|
||||
{ label: 'Applicants', value: s.applied },
|
||||
{ label: 'Shortlisted', value: s.shortlisted },
|
||||
{ label: 'Interviews', value: s.interviews },
|
||||
{ label: 'Hired', value: s.hired },
|
||||
{ label: 'Qualified (70+)', value: s.qualified },
|
||||
{ label: 'Avg score', value: s.avgScore || '—' },
|
||||
].map((m) => (
|
||||
<div key={m.label}>
|
||||
<dt className="text-[10px] font-medium uppercase tracking-wide text-ink-4">{m.label}</dt>
|
||||
<dd className="font-heading text-title font-bold tabular-nums text-ink-1">{m.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="mb-1.5 flex items-baseline justify-between">
|
||||
<span className="text-caption text-ink-2">Screening progress</span>
|
||||
<span className="text-caption font-semibold tabular-nums text-ink-1">{s.coverage}%</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
value={s.coverage}
|
||||
tone={s.coverage === 100 ? 'success' : s.coverage >= 60 ? 'brand' : 'warning'}
|
||||
size="sm"
|
||||
/>
|
||||
<p className="mt-1 text-[11px] text-ink-4">
|
||||
{s.applied - s.unscreened} of {s.applied} applicants scored
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Hiring progression — the single source of truth for the pipeline.
|
||||
A separate "Hiring status" block used to sit above this one repeating
|
||||
Shortlisted, Interviews and Hired as figures, which is exactly what the
|
||||
progression already draws. Two readings of the same funnel invite the
|
||||
reader to check whether they agree, so the duplicated stage counts are
|
||||
gone and the three figures the progression does *not* carry — supply,
|
||||
quality and the screening average — lead it as one line. */}
|
||||
<section>
|
||||
<SectionTitle title="Hiring progression" />
|
||||
|
||||
<dl className="mb-3 flex flex-wrap items-baseline gap-x-2 gap-y-1 text-body-sm">
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<dt className="sr-only">Total applicants</dt>
|
||||
<dd className="font-semibold tabular-nums text-ink-1">{s.applied}</dd>
|
||||
<span className="text-ink-3">Applicant{s.applied === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
<span className="text-ink-4" aria-hidden="true">·</span>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<dt className="sr-only">Qualified, scoring 70 or above</dt>
|
||||
<dd className="font-semibold tabular-nums text-ink-1">{s.qualified}</dd>
|
||||
<span className="text-ink-3">Qualified (70+)</span>
|
||||
</div>
|
||||
<span className="text-ink-4" aria-hidden="true">·</span>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<dt className="text-ink-3">Avg Score</dt>
|
||||
<dd className="font-semibold tabular-nums text-ink-1">{s.avgScore || '—'}</dd>
|
||||
</div>
|
||||
{/* Screening coverage was the one figure in the old block that the
|
||||
progression cannot show — a stage count says how many reached
|
||||
Screened, not what share of the intake that is. */}
|
||||
{s.applied > 0 && (
|
||||
<>
|
||||
<span className="text-ink-4" aria-hidden="true">·</span>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<dt className="sr-only">Screening coverage</dt>
|
||||
<dd className="font-semibold tabular-nums text-ink-1">{s.coverage}%</dd>
|
||||
<span className="text-ink-3">screened</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
<Progression steps={p.progression} />
|
||||
</section>
|
||||
|
||||
@@ -811,22 +817,45 @@ function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition })
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-body-sm text-ink-3">
|
||||
Nobody has been screened for this role yet, so there is no ranking to show.
|
||||
</p>
|
||||
/* Two short lines, not a paragraph. The reason nobody is ranked is
|
||||
always the same — screening has not run — so it needs stating once,
|
||||
plainly, rather than explained. */
|
||||
<div className="rounded-lg border border-dashed border-border px-3 py-4">
|
||||
<p className="text-body-sm font-medium text-ink-2">No screened candidates yet</p>
|
||||
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">
|
||||
Candidates will appear here once screening is completed.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* The timeline sits beside Top candidates, so it earns a rail and nodes:
|
||||
two plain label/date lists side by side read as one table split in
|
||||
half. The dots say "this one is chronological" without a heavier
|
||||
treatment. */}
|
||||
<section>
|
||||
<SectionTitle title="Hiring timeline" />
|
||||
<ol className="space-y-2">
|
||||
<ol className="space-y-0">
|
||||
{timeline.map((e, i) => (
|
||||
<li key={i} className="flex items-baseline justify-between gap-3 text-body-sm">
|
||||
<span className="text-ink-2">{e.label}</span>
|
||||
<span className="shrink-0 text-caption text-ink-4">
|
||||
{e.date
|
||||
? new Date(e.date).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
: '—'}
|
||||
<li key={i} className="flex gap-2.5 pb-3 last:pb-0">
|
||||
<span className="flex flex-col items-center" aria-hidden="true">
|
||||
<span
|
||||
className={cn(
|
||||
'mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full',
|
||||
i === timeline.length - 1 ? 'bg-krow-blue' : 'bg-ink-4'
|
||||
)}
|
||||
/>
|
||||
{i < timeline.length - 1 && <span className="mt-1 w-px flex-1 bg-border" />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-body-sm leading-snug text-ink-2">{e.label}</span>
|
||||
<span className="block text-caption text-ink-4">
|
||||
{e.date
|
||||
? new Date(e.date).toLocaleDateString(undefined, {
|
||||
month: 'short', day: 'numeric', year: 'numeric',
|
||||
})
|
||||
: '—'}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
@@ -841,7 +870,7 @@ function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition })
|
||||
{p.applications.length ? (
|
||||
<ol className="space-y-2">
|
||||
{[...p.applications]
|
||||
.sort((a, b) => new Date(b.updated_date) - new Date(a.updated_date))
|
||||
.sort((a, b) => new Date(b.updated_date).getTime() - new Date(a.updated_date).getTime())
|
||||
.slice(0, 5)
|
||||
.map((a) => (
|
||||
<li key={a.id} className="flex items-baseline justify-between gap-3 text-body-sm">
|
||||
@@ -1161,7 +1190,8 @@ export default function AdminPositions() {
|
||||
}
|
||||
|
||||
/** A compact labelled select pill — consistent on every Admin management page. */
|
||||
export function FilterSelect({ value, onChange, label, options, className }) {
|
||||
/** @param {any} props */
|
||||
export function FilterSelect({ value, onChange, label, options, className = '' }) {
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger
|
||||
|
||||
@@ -19,6 +19,7 @@ import { cn } from '@/lib/utils';
|
||||
*/
|
||||
|
||||
/** Category to icon. Matched loosely, so a new "Head Chef" still gets the hat. */
|
||||
/** @type {[RegExp, React.ComponentType<any>][]} */
|
||||
const GLYPHS = [
|
||||
[/chef|cook|kitchen/i, ChefHat],
|
||||
[/bartend|bar|mixolog/i, Wine],
|
||||
@@ -41,7 +42,8 @@ const SIZES = {
|
||||
lg: { box: 'h-14 w-14 rounded-xl', icon: 'h-6 w-6' },
|
||||
};
|
||||
|
||||
export function RoleGlyph({ category, title, size = 'md', aiGenerated = false, className }) {
|
||||
/** @param {any} props */
|
||||
export function RoleGlyph({ category, title, size = 'md', aiGenerated = false, className = '' }) {
|
||||
const Icon = iconForRole(category, title);
|
||||
const s = SIZES[size] || SIZES.md;
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user