fix error line
This commit is contained in:
@@ -16,6 +16,6 @@
|
|||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"types": []
|
"types": []
|
||||||
},
|
},
|
||||||
"include": ["src/components/**/*.js", "src/pages/**/*.jsx", "src/layouts/**/*.jsx"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist", "src/components/ui", "src/api", "src/lib"]
|
"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,
|
type,
|
||||||
prompt: PROMPT_FOR[type](title),
|
prompt: PROMPT_FOR[type](title),
|
||||||
ai_persona: type === 'roleplay' ? 'A guest or teammate in a live service situation.' : undefined,
|
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: [] },
|
unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] },
|
||||||
quiz: [],
|
quiz: [],
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ export function ThinkingIndicator() {
|
|||||||
* with tables and KPI tiles, and a chat bubble would waste a third of a 380px
|
* with tables and KPI tiles, and a chat bubble would waste a third of a 380px
|
||||||
* column on padding around them.
|
* 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') {
|
if (role === 'user') {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-end">
|
<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
|
* It self-hides on pages with no assistant, so a page can render it
|
||||||
* unconditionally and never needs to know the placement rules.
|
* 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();
|
const { supported, isOpen, toggle, context } = useAssistantPanel();
|
||||||
|
|
||||||
if (!supported) return null;
|
if (!supported) return null;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
ArrowLeft, History, Maximize2, Minimize2, PanelRightClose, RotateCcw, Sparkles, Trash2,
|
ArrowLeft, History, Maximize2, Minimize2, PanelRightClose, RotateCcw, Trash2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Surface } from '@/components/ds/Surface';
|
import { Surface } from '@/components/ds/Surface';
|
||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
usePreferences, useRoleCategories,
|
usePreferences, useRoleCategories,
|
||||||
} from '@/lib/krowHooks';
|
} from '@/lib/krowHooks';
|
||||||
import { ROLE_CATEGORIES } from '@/lib/roleCategories';
|
import { ROLE_CATEGORIES } from '@/lib/roleCategories';
|
||||||
import { AddSkillDialog } from '@/components/skills/AddSkillDialog';
|
|
||||||
import { runAction } from '@/lib/skills/actions';
|
import { runAction } from '@/lib/skills/actions';
|
||||||
import { skillsForContext } from '@/lib/skills/registry';
|
import { skillsForContext } from '@/lib/skills/registry';
|
||||||
import { owliverSuggestions } from '@/lib/skills/owliverResolver';
|
import { owliverSuggestions } from '@/lib/skills/owliverResolver';
|
||||||
@@ -286,7 +285,6 @@ export default function KrowAssistant({
|
|||||||
activity: facts.activity || [],
|
activity: facts.activity || [],
|
||||||
trainingPaths,
|
trainingPaths,
|
||||||
}), [pageContext, facts, assignments, trainingPaths]);
|
}), [pageContext, facts, assignments, trainingPaths]);
|
||||||
const [addSkillOpen, setAddSkillOpen] = React.useState(false);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Which face of the panel the body is showing.
|
* 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 ready = declared.filter((c) => !c.deferred);
|
||||||
const asking = 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 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]
|
return [...ready, ...skillPrompts, ...buildPrompts(context.id, facts, workforce), ...asking]
|
||||||
.filter((chip) => {
|
.filter((chip) => {
|
||||||
const key = String(chip?.label ?? '').trim().toLowerCase();
|
const label = String(chip?.label ?? '').trim().toLowerCase();
|
||||||
if (!key || seen.has(key)) return false;
|
if (!label) return false;
|
||||||
seen.add(key);
|
const intent = intentOf(chip);
|
||||||
|
if (seen.has(intent) || seen.has(`label:${label}`)) return false;
|
||||||
|
seen.add(intent);
|
||||||
|
seen.add(`label:${label}`);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
/* `pageContext` decides which suggestions can answer without asking, so the
|
/* `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>
|
<p className="truncate text-caption leading-tight text-ink-3">{context.page}</p>
|
||||||
</div>
|
</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">
|
<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
|
{/* History lives with the other window controls rather than in the
|
||||||
body, so the layout of the panel is unchanged whether or not
|
body, so the layout of the panel is unchanged whether or not
|
||||||
there is anything to show. It toggles: pressing it again returns
|
there is anything to show. It toggles: pressing it again returns
|
||||||
@@ -729,8 +747,6 @@ export default function KrowAssistant({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AddSkillDialog open={addSkillOpen} onOpenChange={setAddSkillOpen} />
|
|
||||||
|
|
||||||
{/* ── Composer: fixed to the bottom in both states ───────────────── */}
|
{/* ── 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">
|
<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
|
{/* 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
|
* `max` caps how many are offered in a conversation, where vertical space belongs
|
||||||
* to the thread rather than to suggestions.
|
* 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.
|
* 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 chipRefs = React.useRef([]);
|
||||||
const visible = max ? prompts.slice(0, max) : prompts;
|
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)}
|
onKeyDown={(e) => onKeyDown(e, i)}
|
||||||
title={prompt.prompt}
|
title={prompt.prompt}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-full border border-border bg-surface px-2.5 py-1',
|
/* 14px is the radius `rounded-full` already produces on a one-line
|
||||||
'text-caption font-medium text-ink-2 shadow-xs',
|
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',
|
'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',
|
'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',
|
'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'
|
'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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { cn } from '@/lib/utils';
|
|||||||
* gain — the suggestion chips already teach the range of what can be asked, and
|
* 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.
|
* they do it without a timer or a rerender every four seconds.
|
||||||
*/
|
*/
|
||||||
|
/** @param {any} props */
|
||||||
export function PromptInput({
|
export function PromptInput({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -26,7 +27,7 @@ export function PromptInput({
|
|||||||
placeholders = ['Ask anything…'],
|
placeholders = ['Ask anything…'],
|
||||||
autoFocus = false,
|
autoFocus = false,
|
||||||
align = 'left',
|
align = 'left',
|
||||||
className,
|
className = '',
|
||||||
}) {
|
}) {
|
||||||
const textareaRef = React.useRef(null);
|
const textareaRef = React.useRef(null);
|
||||||
const [focused, setFocused] = React.useState(false);
|
const [focused, setFocused] = React.useState(false);
|
||||||
|
|||||||
@@ -62,14 +62,14 @@ const TONE_BADGE = {
|
|||||||
|
|
||||||
/* ── Individual blocks ──────────────────────────────────────────────────── */
|
/* ── 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">
|
<p className="text-body-sm leading-relaxed text-ink-2">
|
||||||
<Inline value={block.text} />
|
<Inline value={block.text} />
|
||||||
</p>
|
</p>
|
||||||
));
|
));
|
||||||
TextBlock.displayName = 'TextBlock';
|
TextBlock.displayName = 'TextBlock';
|
||||||
|
|
||||||
const HeadingBlock = React.memo(({ block }) => (
|
const HeadingBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<h4 className="font-heading text-body-sm font-semibold text-ink-1">{block.text}</h4>
|
<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>}
|
{block.sub && <p className="text-caption text-ink-3">{block.sub}</p>}
|
||||||
@@ -78,7 +78,7 @@ const HeadingBlock = React.memo(({ block }) => (
|
|||||||
HeadingBlock.displayName = 'HeadingBlock';
|
HeadingBlock.displayName = 'HeadingBlock';
|
||||||
|
|
||||||
/** Headline figures. Two columns keeps the numbers large in a 380px panel. */
|
/** 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">
|
<div className="grid grid-cols-2 gap-2 [[data-wide]_&]:grid-cols-3">
|
||||||
{block.items.map((item) => (
|
{block.items.map((item) => (
|
||||||
<div
|
<div
|
||||||
@@ -113,7 +113,7 @@ const KpisBlock = React.memo(({ block }) => (
|
|||||||
KpisBlock.displayName = 'KpisBlock';
|
KpisBlock.displayName = 'KpisBlock';
|
||||||
|
|
||||||
/** Pass/warn checks. The icon carries the state so colour is not the only cue. */
|
/** 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">
|
<ul className="space-y-1.5">
|
||||||
{block.items.map((item) => (
|
{block.items.map((item) => (
|
||||||
<li key={item.label} className="flex items-start gap-2">
|
<li key={item.label} className="flex items-start gap-2">
|
||||||
@@ -145,7 +145,7 @@ const StatusBlock = React.memo(({ block }) => (
|
|||||||
));
|
));
|
||||||
StatusBlock.displayName = 'StatusBlock';
|
StatusBlock.displayName = 'StatusBlock';
|
||||||
|
|
||||||
const MetersBlock = React.memo(({ block }) => (
|
const MetersBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
{block.items.map((item) => (
|
{block.items.map((item) => (
|
||||||
<div key={item.label} className="space-y-1">
|
<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
|
* A table. Scrolls horizontally inside its own container so a wide comparison
|
||||||
* never widens the panel.
|
* never widens the panel.
|
||||||
*/
|
*/
|
||||||
const TableBlock = React.memo(({ block }) => {
|
const TableBlock = React.memo(/** @param {any} props */ ({ block }) => {
|
||||||
const cell = (value) => {
|
const cell = (value) => {
|
||||||
if (value == null || value === '') return <span className="text-ink-4">—</span>;
|
if (value == null || value === '') return <span className="text-ink-4">—</span>;
|
||||||
if (typeof value === 'object') {
|
if (typeof value === 'object') {
|
||||||
@@ -228,7 +228,7 @@ const TableBlock = React.memo(({ block }) => {
|
|||||||
TableBlock.displayName = 'TableBlock';
|
TableBlock.displayName = 'TableBlock';
|
||||||
|
|
||||||
/** Stage progression, widths relative to the largest stage. */
|
/** 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);
|
const max = Math.max(...block.steps.map((s) => s.count), 1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -255,7 +255,7 @@ const FunnelBlock = React.memo(({ block }) => {
|
|||||||
});
|
});
|
||||||
FunnelBlock.displayName = 'FunnelBlock';
|
FunnelBlock.displayName = 'FunnelBlock';
|
||||||
|
|
||||||
const ListBlock = React.memo(({ block }) => {
|
const ListBlock = React.memo(/** @param {any} props */ ({ block }) => {
|
||||||
const Tag = block.ordered ? 'ol' : 'ul';
|
const Tag = block.ordered ? 'ol' : 'ul';
|
||||||
return (
|
return (
|
||||||
<Tag className="space-y-1.5">
|
<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
|
* clickable and doing nothing. An item may carry a hint *and* an action: the
|
||||||
* hint explains the record, the action opens it.
|
* 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">
|
<div className="space-y-2">
|
||||||
{block.items.map((item, i) => {
|
{block.items.map((item, i) => {
|
||||||
const surface = cn(
|
const surface = cn(
|
||||||
@@ -375,7 +375,7 @@ const InsightsBlock = React.memo(({ block, onPrompt }) => (
|
|||||||
InsightsBlock.displayName = 'InsightsBlock';
|
InsightsBlock.displayName = 'InsightsBlock';
|
||||||
|
|
||||||
/** Recommended steps — numbered, because order is the recommendation. */
|
/** 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">
|
<ol className="space-y-2">
|
||||||
{block.items.map((item, i) => (
|
{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">
|
<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';
|
ActionsBlock.displayName = 'ActionsBlock';
|
||||||
|
|
||||||
const BadgesBlock = React.memo(({ block }) => (
|
const BadgesBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{block.items.map((item) => (
|
{block.items.map((item) => (
|
||||||
<Badge key={item.label} variant={TONE_BADGE[item.tone] || 'soft'} size="lg">
|
<Badge key={item.label} variant={TONE_BADGE[item.tone] || 'soft'} size="lg">
|
||||||
@@ -405,7 +405,7 @@ const BadgesBlock = React.memo(({ block }) => (
|
|||||||
));
|
));
|
||||||
BadgesBlock.displayName = 'BadgesBlock';
|
BadgesBlock.displayName = 'BadgesBlock';
|
||||||
|
|
||||||
const TimelineBlock = React.memo(({ block }) => (
|
const TimelineBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||||
<ol className="relative space-y-3">
|
<ol className="relative space-y-3">
|
||||||
{block.items.map((item, i) => (
|
{block.items.map((item, i) => (
|
||||||
<li key={i} className="flex gap-3">
|
<li key={i} className="flex gap-3">
|
||||||
@@ -438,7 +438,7 @@ const TimelineBlock = React.memo(({ block }) => (
|
|||||||
));
|
));
|
||||||
TimelineBlock.displayName = 'TimelineBlock';
|
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">
|
<p className="border-l-2 border-border pl-2.5 text-caption italic leading-relaxed text-ink-3">
|
||||||
<Inline value={block.text} />
|
<Inline value={block.text} />
|
||||||
</p>
|
</p>
|
||||||
@@ -455,7 +455,7 @@ NoteBlock.displayName = 'NoteBlock';
|
|||||||
* shape the table does not carry renders nothing rather than something
|
* shape the table does not carry renders nothing rather than something
|
||||||
* improvised.
|
* improvised.
|
||||||
*/
|
*/
|
||||||
const SkillSectionBlock = React.memo(({ block }) => {
|
const SkillSectionBlock = React.memo(/** @param {any} props */ ({ block }) => {
|
||||||
const section = block.section;
|
const section = block.section;
|
||||||
const Component = SECTION_COMPONENTS[section?.shape || section?.type];
|
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
|
* blocks has consistent rhythm — a heading hugs what follows it, everything
|
||||||
* else breathes.
|
* 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">
|
<div className="space-y-3">
|
||||||
{blocks.map((block, i) => {
|
{blocks.map((block, i) => {
|
||||||
const Renderer = RENDERERS[block.type];
|
const Renderer = RENDERERS[block.type];
|
||||||
|
|||||||
@@ -459,7 +459,7 @@ const PROMPTS = {
|
|||||||
prompt: `Continue the ${drafts[0].title} draft`,
|
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?',
|
prompt: 'Which positions are still in draft?',
|
||||||
capability: 'position-drafts',
|
capability: 'position-drafts',
|
||||||
});
|
});
|
||||||
@@ -496,8 +496,9 @@ const PROMPTS = {
|
|||||||
|
|
||||||
if (withStrong.length) {
|
if (withStrong.length) {
|
||||||
const best = withStrong.sort((a, b) => b.status.strong.length - a.status.strong.length)[0];
|
const best = withStrong.sort((a, b) => b.status.strong.length - a.status.strong.length)[0];
|
||||||
|
const n = best.status.strong.length;
|
||||||
out.push({
|
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}?`,
|
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?' });
|
out.push({ label: 'Which position needs people first?', prompt: 'Which position needs people first?' });
|
||||||
}
|
}
|
||||||
if (newToday) {
|
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) {
|
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". */
|
ones — they are still the right answer to "how is hiring going". */
|
||||||
out.push(
|
out.push(
|
||||||
f.starvedPositions.length
|
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: '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' },
|
{ label: 'Which position has the strongest pipeline?', prompt: 'Which position has the strongest pipeline?', capability: 'position-strength' },
|
||||||
f.unscreened.length
|
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: '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;
|
return out;
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function readHistory() {
|
|||||||
return list
|
return list
|
||||||
.filter((r) => r && r.id && Array.isArray(r.messages) && r.messages.length)
|
.filter((r) => r && r.id && Array.isArray(r.messages) && r.messages.length)
|
||||||
.filter((r) => new Date(r.updatedAt || 0).getTime() >= cutoff)
|
.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 {
|
} catch {
|
||||||
/* Corrupt or unavailable storage is an empty history, not an error the
|
/* Corrupt or unavailable storage is an empty history, not an error the
|
||||||
reader has to deal with. */
|
reader has to deal with. */
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export function buildFacts({
|
|||||||
/* Time-to-hire — the created→updated span ImpactMetrics uses. */
|
/* Time-to-hire — the created→updated span ImpactMetrics uses. */
|
||||||
const timeToHire = hired.length
|
const timeToHire = hired.length
|
||||||
? Math.round(avg(hired.map((a) =>
|
? 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;
|
: 0;
|
||||||
|
|
||||||
/* Open roles attracting nobody. */
|
/* 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 [viewMode, setViewMode] = useState('flowchart'); // 'flowchart' | 'table'
|
||||||
const [activeDept, setActiveDept] = useState(null);
|
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
|
* `stages` and `transitions` come from the page's own fact sheet — this
|
||||||
* component derives no figures of its own.
|
* 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;
|
if (!stages.length) return null;
|
||||||
|
|
||||||
const total = stages[0]?.count || 1;
|
const total = stages[0]?.count || 1;
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import { chartSx, TONES, tooltipSx } from './muiChartTheme';
|
|||||||
*
|
*
|
||||||
* `points: [{ label, hires, cumulative }]` — supplied by the page.
|
* `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;
|
const enough = points.length >= 2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { StatusBadge } from '@/components/ds/StatusBadge';
|
|||||||
* `variant="card"`. Either way the anatomy is fixed — actor, sentence, meta,
|
* `variant="card"`. Either way the anatomy is fixed — actor, sentence, meta,
|
||||||
* trailing slot — so a feed built from mixed event types stays aligned.
|
* trailing slot — so a feed built from mixed event types stays aligned.
|
||||||
*/
|
*/
|
||||||
|
/** @param {any} props */
|
||||||
export function ActivityCard({
|
export function ActivityCard({
|
||||||
/** Person or system that acted. */
|
/** Person or system that acted. */
|
||||||
actor,
|
actor,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const ICON_COLORS = {
|
|||||||
* instead: an Alert that appears and disappears is easy to miss, and a toast
|
* instead: an Alert that appears and disappears is easy to miss, and a toast
|
||||||
* that persists is noise.
|
* that persists is noise.
|
||||||
*/
|
*/
|
||||||
|
/** @param {any} props */
|
||||||
export function Alert({ tone = 'info', title, children, icon, onDismiss, action, className, ...props }) {
|
export function Alert({ tone = 'info', title, children, icon, onDismiss, action, className, ...props }) {
|
||||||
const Icon = icon || ICONS[tone];
|
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
|
* first-class state rather than a fallback afterthought. A failed image load
|
||||||
* degrades to initials instead of a broken-image icon.
|
* degrades to initials instead of a broken-image icon.
|
||||||
*/
|
*/
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
export const Avatar = React.forwardRef(
|
export const Avatar = React.forwardRef(
|
||||||
({ name, src, size = 'default', shape, tone, status, className, ...props }, ref) => {
|
({ name, src, size = 'default', shape, tone, status, className, ...props }, ref) => {
|
||||||
const [failed, setFailed] = React.useState(false);
|
const [failed, setFailed] = React.useState(false);
|
||||||
@@ -89,6 +90,7 @@ Avatar.displayName = 'Avatar';
|
|||||||
* AvatarGroup — overlapping avatars with a "+N" remainder, for showing several
|
* AvatarGroup — overlapping avatars with a "+N" remainder, for showing several
|
||||||
* people in a tight row (applicants on a position, endorsers on a skill).
|
* 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 }) {
|
export function AvatarGroup({ people = [], max = 4, size = 'sm', className }) {
|
||||||
const visible = people.slice(0, max);
|
const visible = people.slice(0, max);
|
||||||
const overflow = people.length - visible.length;
|
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
|
* A tooltip matching the design system's surfaces. Recharts' default is a plain
|
||||||
* white box that ignores the token set.
|
* white box that ignores the token set.
|
||||||
*/
|
*/
|
||||||
|
/** @param {any} props */
|
||||||
export function ChartTooltip({ active, payload, label, valueFormatter, labelFormatter }) {
|
export function ChartTooltip({ active, payload, label, valueFormatter, labelFormatter }) {
|
||||||
if (!active || !payload?.length) return null;
|
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
|
* 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.
|
* children, so swapping a bar chart for a line chart changes nothing else.
|
||||||
*/
|
*/
|
||||||
|
/** @param {any} props */
|
||||||
export function ChartContainer({
|
export function ChartContainer({
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { usePreferences } from '@/lib/krowHooks';
|
|||||||
* `hideBelow: 'md'` drops a column on small screens, which is what keeps the
|
* `hideBelow: 'md'` drops a column on small screens, which is what keeps the
|
||||||
* table usable on mobile without a horizontal scroll for every view.
|
* table usable on mobile without a horizontal scroll for every view.
|
||||||
*/
|
*/
|
||||||
|
/** @param {any} props */
|
||||||
export function DataTable({
|
export function DataTable({
|
||||||
columns = [],
|
columns = [],
|
||||||
rows = [],
|
rows = [],
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const WIDTHS = {
|
|||||||
* a detail view can be a Drawer on desktop and a bottom sheet on mobile without
|
* a detail view can be a Drawer on desktop and a bottom sheet on mobile without
|
||||||
* its contents changing.
|
* its contents changing.
|
||||||
*/
|
*/
|
||||||
|
/** @param {any} props */
|
||||||
export function Drawer({
|
export function Drawer({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
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
|
* Pages that conflate them end up telling a user with 22 candidates that they
|
||||||
* have no candidates.
|
* have no candidates.
|
||||||
*/
|
*/
|
||||||
|
/** @param {any} props */
|
||||||
export function EmptyState({
|
export function EmptyState({
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
title,
|
title,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { cn } from '@/lib/utils';
|
|||||||
* spacing, required markers and error styling are identical across the app.
|
* spacing, required markers and error styling are identical across the app.
|
||||||
* It wires `id`, `aria-describedby` and `aria-invalid` onto its child.
|
* It wires `id`, `aria-describedby` and `aria-invalid` onto its child.
|
||||||
*/
|
*/
|
||||||
|
/** @param {any} props */
|
||||||
export function Field({
|
export function Field({
|
||||||
label,
|
label,
|
||||||
hint,
|
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,
|
* On mobile the filters collapse behind a "Filters" toggle with an active count,
|
||||||
* because four side-by-side selects are unusable at 375px.
|
* because four side-by-side selects are unusable at 375px.
|
||||||
*/
|
*/
|
||||||
|
/** @param {any} props */
|
||||||
export function FilterBar({
|
export function FilterBar({
|
||||||
search,
|
search = null,
|
||||||
onSearchChange,
|
onSearchChange = null,
|
||||||
searchPlaceholder = 'Search...',
|
searchPlaceholder = 'Search...',
|
||||||
|
|
||||||
filters = [],
|
filters = [],
|
||||||
/** `{ [key]: string | string[] }` */
|
/** `{ [key]: string | string[] }` */
|
||||||
values = {},
|
values = {},
|
||||||
onChange,
|
onChange = null,
|
||||||
|
|
||||||
/** Right-aligned actions — primary buttons, view switchers. */
|
/** Right-aligned actions — primary buttons, view switchers. */
|
||||||
actions,
|
actions = null,
|
||||||
/** Rendered under the bar, e.g. a result count. */
|
/** Rendered under the bar, e.g. a result count. */
|
||||||
meta,
|
meta = null,
|
||||||
|
|
||||||
/** Value that means "no filter" for select-type filters. */
|
/** Value that means "no filter" for select-type filters. */
|
||||||
allValue = 'all',
|
allValue = 'all',
|
||||||
className,
|
className = '',
|
||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = React.useState(false);
|
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
|
* `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.
|
* makes it impossible to omit. The label doubles as the tooltip.
|
||||||
*/
|
*/
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
export const IconButton = React.forwardRef(
|
export const IconButton = React.forwardRef(
|
||||||
({ icon: Icon, label, tooltip = true, size = 'default', side = 'top', ...props }, ref) => {
|
({ icon: Icon, label, tooltip = true, size = 'default', side = 'top', ...props }, ref) => {
|
||||||
const sizeMap = {
|
const sizeMap = {
|
||||||
|
|||||||
@@ -45,18 +45,19 @@ const SEVERITY = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** @param {any} props */
|
||||||
export function InsightRow({
|
export function InsightRow({
|
||||||
severity = 'info',
|
severity = 'info',
|
||||||
title,
|
title,
|
||||||
detail,
|
detail,
|
||||||
/** Short right-aligned figure — a count, an age, a percentage. */
|
/** Short right-aligned figure — a count, an age, a percentage. */
|
||||||
metric,
|
metric = null,
|
||||||
metricLabel,
|
metricLabel = null,
|
||||||
/** Entity the item concerns, e.g. a position or candidate name. */
|
/** Entity the item concerns, e.g. a position or candidate name. */
|
||||||
entity,
|
entity = null,
|
||||||
onClick,
|
onClick = null,
|
||||||
actionLabel = 'Review',
|
actionLabel = 'Review',
|
||||||
className,
|
className = '',
|
||||||
}) {
|
}) {
|
||||||
const s = SEVERITY[severity] || SEVERITY.info;
|
const s = SEVERITY[severity] || SEVERITY.info;
|
||||||
const Comp = onClick ? 'button' : 'div';
|
const Comp = onClick ? 'button' : 'div';
|
||||||
@@ -105,6 +106,7 @@ export function InsightRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The list container — dividers between rows, nothing else. */
|
/** 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>;
|
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
|
* (a target, a breakdown, a sparkline) use `MetricCard`; keeping the two
|
||||||
* separate stops KPI rows from drifting into inconsistent heights.
|
* separate stops KPI rows from drifting into inconsistent heights.
|
||||||
*/
|
*/
|
||||||
|
/** @param {any} props */
|
||||||
export function KpiCard({
|
export function KpiCard({
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
value,
|
value,
|
||||||
@@ -94,6 +95,7 @@ const formatDelta = (delta, suffix = '%') =>
|
|||||||
* `invertTrend` exists because "up" is not always good — time-to-hire rising is
|
* `invertTrend` exists because "up" is not always good — time-to-hire rising is
|
||||||
* bad, and colouring it green would be actively misleading.
|
* bad, and colouring it green would be actively misleading.
|
||||||
*/
|
*/
|
||||||
|
/** @param {any} props */
|
||||||
export function MetricCard({
|
export function MetricCard({
|
||||||
label,
|
label,
|
||||||
value,
|
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
|
* `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).
|
* 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 }) {
|
export function Grid({ cols = 3, gap = 'default', className, children, ...props }) {
|
||||||
const columns = {
|
const columns = {
|
||||||
1: 'grid-cols-1',
|
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. */
|
/** Stack — vertical rhythm. `space` maps to the two spacings pages should use. */
|
||||||
|
/** @param {any} props */
|
||||||
export function Stack({ space = 'default', className, children, ...props }) {
|
export function Stack({ space = 'default', className, children, ...props }) {
|
||||||
const spacing = {
|
const spacing = {
|
||||||
xs: 'space-y-2',
|
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
|
* PageSection — a titled region of a page. Pairs a SectionHeader-style label
|
||||||
* with its content at consistent spacing.
|
* with its content at consistent spacing.
|
||||||
*/
|
*/
|
||||||
|
/** @param {any} props */
|
||||||
export function PageSection({ title, subtitle, actions, level = 'overline', className, children }) {
|
export function PageSection({ title, subtitle, actions, level = 'overline', className, children }) {
|
||||||
return (
|
return (
|
||||||
<section className={cn('space-y-3', className)}>
|
<section className={cn('space-y-3', className)}>
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { cn } from '@/lib/utils';
|
|||||||
/**
|
/**
|
||||||
* Spinner — the only spinner in the system.
|
* 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' };
|
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 (
|
return (
|
||||||
<Loader2
|
<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
|
* Prefer `Skeleton*` when the shape *is* known: a skeleton that matches the
|
||||||
* final layout prevents the content jump a spinner always causes.
|
* 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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,
|
* InlineLoading — a spinner and label on one line, for inside buttons rows,
|
||||||
* card headers, or beside a control that is refreshing.
|
* 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 (
|
return (
|
||||||
<span className={cn('inline-flex items-center gap-2 text-body-sm text-ink-3', className)}>
|
<span className={cn('inline-flex items-center gap-2 text-body-sm text-ink-3', className)}>
|
||||||
<Spinner size="sm" />
|
<Spinner size="sm" />
|
||||||
@@ -55,7 +58,8 @@ export function InlineLoading({ message = 'Loading…', className }) {
|
|||||||
* LoadingOverlay — covers content that is being replaced, keeping layout
|
* LoadingOverlay — covers content that is being replaced, keeping layout
|
||||||
* height stable so the page does not jump. Used by DataTable when refetching.
|
* 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;
|
if (!show) return null;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ function Delta({ delta, label, invert }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @param {any} props */
|
||||||
export function MetricStrip({ items = [], columns, className, loading = false }) {
|
export function MetricStrip({ items = [], columns, className, loading = false }) {
|
||||||
const count = columns ?? Math.min(items.length, 6);
|
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;
|
* body scroll pushes the title and the confirm button off-screen on a laptop;
|
||||||
* this keeps both pinned and scrolls only the middle.
|
* this keeps both pinned and scrolls only the middle.
|
||||||
*/
|
*/
|
||||||
|
/** @param {any} props */
|
||||||
|
/** @param {any} props */
|
||||||
export function Modal({
|
export function Modal({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
@@ -82,6 +84,7 @@ export function Modal({
|
|||||||
* always for, and it labels the action with the verb ("Delete") rather than
|
* always for, and it labels the action with the verb ("Delete") rather than
|
||||||
* "OK", so the button says what will happen.
|
* "OK", so the button says what will happen.
|
||||||
*/
|
*/
|
||||||
|
/** @param {any} props */
|
||||||
export function ConfirmModal({
|
export function ConfirmModal({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
@@ -91,7 +94,7 @@ export function ConfirmModal({
|
|||||||
cancelLabel = 'Cancel',
|
cancelLabel = 'Cancel',
|
||||||
onConfirm,
|
onConfirm,
|
||||||
tone = 'destructive',
|
tone = 'destructive',
|
||||||
icon,
|
icon = null,
|
||||||
busy = false,
|
busy = false,
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { SearchInput } from '@/components/ds/SearchInput';
|
|||||||
* options a filter field appears, so short lists stay uncluttered and long ones
|
* options a filter field appears, so short lists stay uncluttered and long ones
|
||||||
* stay usable. Options may be strings or `{ value, label, count }`.
|
* stay usable. Options may be strings or `{ value, label, count }`.
|
||||||
*/
|
*/
|
||||||
|
/** @param {any} props */
|
||||||
export function MultiSelect({
|
export function MultiSelect({
|
||||||
options = [],
|
options = [],
|
||||||
value = [],
|
value = [],
|
||||||
@@ -20,8 +21,8 @@ export function MultiSelect({
|
|||||||
/** Chips shown before collapsing into "+N more". */
|
/** Chips shown before collapsing into "+N more". */
|
||||||
maxChips = 2,
|
maxChips = 2,
|
||||||
searchThreshold = 8,
|
searchThreshold = 8,
|
||||||
disabled,
|
disabled = false,
|
||||||
className,
|
className = '',
|
||||||
align = 'start',
|
align = 'start',
|
||||||
emptyMessage = 'No options',
|
emptyMessage = 'No options',
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { cn } from '@/lib/utils';
|
|||||||
* right-aligned action slot. Owning the entrance animation here is what keeps
|
* right-aligned action slot. Owning the entrance animation here is what keeps
|
||||||
* page transitions feeling like one product.
|
* page transitions feeling like one product.
|
||||||
*/
|
*/
|
||||||
|
/** @param {any} props */
|
||||||
export function PageHeader({
|
export function PageHeader({
|
||||||
title,
|
title,
|
||||||
subtitle,
|
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
|
* 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.
|
* 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) {
|
export function buildPageRange(current, total, siblings = 1) {
|
||||||
const totalSlots = siblings * 2 + 5;
|
const totalSlots = siblings * 2 + 5;
|
||||||
if (total <= totalSlots) return Array.from({ length: total }, (_, i) => i + 1);
|
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
|
* Pagination — page navigation with an optional result summary and page-size
|
||||||
* control. Renders nothing when there is only one page.
|
* control. Renders nothing when there is only one page.
|
||||||
*/
|
*/
|
||||||
|
/** @param {any} props */
|
||||||
export function Pagination({
|
export function Pagination({
|
||||||
page = 1,
|
page = 1,
|
||||||
pageCount = 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
|
* `tone="score"` picks the colour from the value, which is what most usages in
|
||||||
* this app want.
|
* this app want.
|
||||||
*/
|
*/
|
||||||
|
/** @param {any} props */
|
||||||
export function ProgressBar({
|
export function ProgressBar({
|
||||||
value,
|
value,
|
||||||
max = 100,
|
max = 100,
|
||||||
@@ -94,6 +95,7 @@ export function ProgressBar({
|
|||||||
* score, via `min`/`max`, so the KROW ID gauge and a completion ring are the
|
* score, via `min`/`max`, so the KROW ID gauge and a completion ring are the
|
||||||
* same component.
|
* same component.
|
||||||
*/
|
*/
|
||||||
|
/** @param {any} props */
|
||||||
export function ProgressRing({
|
export function ProgressRing({
|
||||||
value,
|
value,
|
||||||
min = 0,
|
min = 0,
|
||||||
@@ -175,7 +177,8 @@ export function ProgressRing({
|
|||||||
* Completed steps show a check rather than their number, so "done" is readable
|
* Completed steps show a check rather than their number, so "done" is readable
|
||||||
* at a glance without relying on colour.
|
* 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));
|
const normalized = steps.map((s) => (typeof s === 'string' ? { label: s } : s));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -239,6 +242,7 @@ export function ProgressSteps({ steps = [], current = 0, className, onStepClick
|
|||||||
* ScoreMeter — a labelled bar row used in score breakdowns, where many
|
* ScoreMeter — a labelled bar row used in score breakdowns, where many
|
||||||
* dimensions are listed with a weight and a value.
|
* dimensions are listed with a weight and a value.
|
||||||
*/
|
*/
|
||||||
|
/** @param {any} props */
|
||||||
export function ScoreMeter({ label, value, weight, tone = 'score', className }) {
|
export function ScoreMeter({ label, value, weight, tone = 'score', className }) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('space-y-1.5', className)}>
|
<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
|
* without re-running expensive work, while the field itself stays instantly
|
||||||
* responsive. Pass `debounce={0}` for immediate updates.
|
* responsive. Pass `debounce={0}` for immediate updates.
|
||||||
*/
|
*/
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
export const SearchInput = React.forwardRef(
|
export const SearchInput = React.forwardRef(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { cn } from '@/lib/utils';
|
|||||||
* - `card` — a card's own title row
|
* - `card` — a card's own title row
|
||||||
* - `section` — a page-level section title
|
* - `section` — a page-level section title
|
||||||
*/
|
*/
|
||||||
|
/** @param {any} props */
|
||||||
export function SectionHeader({
|
export function SectionHeader({
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import { cn } from '@/lib/utils';
|
|||||||
* progress. The shimmer is disabled automatically under
|
* progress. The shimmer is disabled automatically under
|
||||||
* `prefers-reduced-motion` (see index.css).
|
* `prefers-reduced-motion` (see index.css).
|
||||||
*/
|
*/
|
||||||
export function Skeleton({ className, radius = 'md', ...props }) {
|
/** @param {any} props */
|
||||||
|
export function Skeleton({ className = '', radius = 'md', ...props }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
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. */
|
/** 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 (
|
return (
|
||||||
<div className={cn('space-y-2', className)} role="status" aria-label="Loading content">
|
<div className={cn('space-y-2', className)} role="status" aria-label="Loading content">
|
||||||
{Array.from({ length: lines }).map((_, i) => (
|
{Array.from({ length: lines }).map((_, i) => (
|
||||||
@@ -45,7 +47,8 @@ export function SkeletonText({ lines = 3, className }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Matches KpiCard: icon tile, big number, caption. */
|
/** Matches KpiCard: icon tile, big number, caption. */
|
||||||
export function SkeletonKpi({ className }) {
|
/** @param {any} props */
|
||||||
|
export function SkeletonKpi({ className = '' }) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('glass-card border border-white/60 rounded-2xl p-4 shadow-sm', className)}>
|
<div className={cn('glass-card border border-white/60 rounded-2xl p-4 shadow-sm', className)}>
|
||||||
<Skeleton className="w-10 h-10 rounded-xl mb-3" />
|
<Skeleton className="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. */
|
/** Matches a list row: avatar, two lines of text, trailing value. */
|
||||||
export function SkeletonRow({ className }) {
|
/** @param {any} props */
|
||||||
|
export function SkeletonRow({ className = '' }) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex items-center gap-3 py-3', className)}>
|
<div className={cn('flex items-center gap-3 py-3', className)}>
|
||||||
<Skeleton className="w-9 h-9" radius="full" />
|
<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. */
|
/** Matches a card in a grid: title, meta, stat row. */
|
||||||
export function SkeletonCard({ className }) {
|
/** @param {any} props */
|
||||||
|
export function SkeletonCard({ className = '' }) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('glass-card border border-white/60 rounded-2xl p-5 shadow-sm', className)}>
|
<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">
|
<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. */
|
/** 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 (
|
return (
|
||||||
<div className={cn('overflow-hidden', className)} role="status" aria-label="Loading table">
|
<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">
|
<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. */
|
/** Matches ChartContainer: title, legend, plot area. */
|
||||||
export function SkeletonChart({ className, height = 240 }) {
|
/** @param {any} props */
|
||||||
|
export function SkeletonChart({ className = '', height = 240 }) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('glass-card border border-white/60 rounded-2xl p-6 shadow-sm', className)}>
|
<div className={cn('glass-card border border-white/60 rounded-2xl p-6 shadow-sm', className)}>
|
||||||
<Skeleton className="h-3.5 w-40 mb-2" />
|
<Skeleton className="h-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. */
|
/** 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 (
|
return (
|
||||||
<div className={cn('divide-y divide-border', className)} role="status" aria-label="Loading list">
|
<div className={cn('divide-y divide-border', className)} role="status" aria-label="Loading list">
|
||||||
{Array.from({ length: rows }).map((_, i) => (
|
{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
|
* `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.
|
* 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 }) {
|
export function StatusBadge({ status, label, tone, dot = false, size = 'default', className, ...props }) {
|
||||||
const entry = STATUS_MAP[status] || {};
|
const entry = STATUS_MAP[status] || {};
|
||||||
const resolvedTone = tone || entry.tone || 'neutral';
|
const resolvedTone = tone || entry.tone || 'neutral';
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ const surfaceVariants = cva(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const Surface = React.forwardRef(
|
const Surface = React.forwardRef(
|
||||||
({ className, variant, elevation, radius, padding, interactive, as, ...props }, ref) => {
|
({ className, variant, elevation, radius, padding, interactive, as, ...props }, ref) => {
|
||||||
const Comp = as || 'div';
|
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
|
* unique when two tab sets are on screen at once, otherwise the indicator
|
||||||
* animates between them.
|
* animates between them.
|
||||||
*/
|
*/
|
||||||
|
/** @param {any} props */
|
||||||
export function Tabs({
|
export function Tabs({
|
||||||
tabs = [],
|
tabs = [],
|
||||||
value,
|
value,
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ const TONE_DOT = {
|
|||||||
* The connecting rail is drawn per item rather than as one absolute line, so a
|
* 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.
|
* 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 (
|
return (
|
||||||
<ol className={cn('relative', className)}>
|
<ol className={cn('relative', className)}>
|
||||||
{items.map((item, i) => {
|
{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
|
* always used with, and makes the whole row a label so the hit target is the
|
||||||
* full width rather than the 36px control.
|
* full width rather than the 36px control.
|
||||||
*/
|
*/
|
||||||
|
/** @param {any} props */
|
||||||
export function Toggle({
|
export function Toggle({
|
||||||
checked,
|
checked,
|
||||||
onCheckedChange,
|
onCheckedChange,
|
||||||
@@ -97,8 +98,15 @@ const SEGMENT_VARIANTS = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** @param {any} props */
|
||||||
export function SegmentedToggle({
|
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 normalized = options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o));
|
||||||
const tone = SEGMENT_VARIANTS[variant] || SEGMENT_VARIANTS.brand;
|
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 DURATIONS = { success: 2600, error: 4200, info: 3000, loading: Infinity };
|
||||||
|
|
||||||
const withDefaults = (options = {}) => ({
|
const withDefaults = (options = {}) => ({
|
||||||
position: 'bottom-center',
|
position: /** @type {import('react-hot-toast').ToastPosition} */ ('bottom-center'),
|
||||||
...options,
|
...options,
|
||||||
style: { ...BASE_STYLE, ...(options.style || {}) },
|
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. */
|
/** 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 setAt = (i, v) => onChange(values.map((x, j) => (j === i ? v : x)));
|
||||||
const removeAt = (i) => onChange(values.length === 1 ? [''] : values.filter((_, j) => j !== i));
|
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
|
* where a worker actually is one; putting it here made the console read as
|
||||||
* somebody's personal training dashboard.
|
* somebody's personal training dashboard.
|
||||||
*/
|
*/
|
||||||
export function ForgeHeader({ onCreate }) {
|
/** @param {any} props */
|
||||||
|
export function ForgeHeader({ onCreate = null, _onBrowse = null }) {
|
||||||
return (
|
return (
|
||||||
<header className="flex flex-col gap-4 border-b border-border/60 pb-6 sm:flex-row sm:items-end sm:justify-between">
|
<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">
|
<div className="min-w-0 space-y-1.5">
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ const TIPS = [
|
|||||||
'Keep training concise.',
|
'Keep training concise.',
|
||||||
];
|
];
|
||||||
|
|
||||||
export function ForgeRail({ recent = [], onCreate, onOpen, onAskOwliver }) {
|
/** @param {any} props */
|
||||||
|
export function ForgeRail({ recent = [], onCreate, onOpen, onAskOwliver: _onAskOwliver }) {
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className="grid grid-cols-1 gap-4 sm:grid-cols-3 lg:grid-cols-3 items-stretch"
|
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. */
|
/** A titled region of the record. */
|
||||||
|
/** @param {any} props */
|
||||||
function Block({ title, meta, icon: Icon, children }) {
|
function Block({ title, meta, icon: Icon, children }) {
|
||||||
return (
|
return (
|
||||||
<section className="border-t border-border pt-4 first:border-0 first:pt-0">
|
<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
|
* 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.
|
* 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 (
|
return (
|
||||||
<ol className={cn('flex items-start', className)} aria-label={`${state.name} levels`}>
|
<ol className={cn('flex items-start', className)} aria-label={`${state.name} levels`}>
|
||||||
{state.levels.map((rung, i) => {
|
{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. */
|
/** One skill: where this person stands on it, and the next thing to do. */
|
||||||
|
/** @param {any} props */
|
||||||
export function SkillProgressCard({ state, onOpen }) {
|
export function SkillProgressCard({ state, onOpen }) {
|
||||||
const status = STATUS_META[state.status] || STATUS_META.not_started;
|
const status = STATUS_META[state.status] || STATUS_META.not_started;
|
||||||
const atTop = !state.nextLevel;
|
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
|
* hidden: the evidence behind a level is the reason to trust the level, and
|
||||||
* folding it away would leave the label standing on nothing.
|
* folding it away would leave the label standing on nothing.
|
||||||
*/
|
*/
|
||||||
|
/** @param {any} props */
|
||||||
export function SkillProgressDetail({ state, open, onOpenChange, onStartTraining }) {
|
export function SkillProgressDetail({ state, open, onOpenChange, onStartTraining }) {
|
||||||
if (!state) return null;
|
if (!state) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -95,13 +95,14 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
|
|||||||
setMessages(newMessages);
|
setMessages(newMessages);
|
||||||
lastUserMessageTime.current = Date.now();
|
lastUserMessageTime.current = Date.now();
|
||||||
await speak(question);
|
await speak(question);
|
||||||
} catch (e) {
|
} catch (_e) {
|
||||||
setError('Interview connection issue. Please retry.');
|
setError('Interview connection issue. Please retry.');
|
||||||
}
|
}
|
||||||
}, [jobTitle, speak, language]);
|
}, [jobTitle, speak, language]);
|
||||||
|
|
||||||
const startListening = useCallback(() => {
|
const startListening = useCallback(() => {
|
||||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
const win = /** @type {any} */ (window);
|
||||||
|
const SpeechRecognition = win.SpeechRecognition || win.webkitSpeechRecognition;
|
||||||
if (!SpeechRecognition) {
|
if (!SpeechRecognition) {
|
||||||
setError('Voice input not supported in this browser. Use text mode below.');
|
setError('Voice input not supported in this browser. Use text mode below.');
|
||||||
return;
|
return;
|
||||||
@@ -181,7 +182,7 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
|
|||||||
} });
|
} });
|
||||||
setEvaluation(result);
|
setEvaluation(result);
|
||||||
setPhase('done');
|
setPhase('done');
|
||||||
} catch (e) {
|
} catch (_e) {
|
||||||
setError('Evaluation failed. Please try again.');
|
setError('Evaluation failed. Please try again.');
|
||||||
setPhase('active');
|
setPhase('active');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const STATUS_STYLES = {
|
|||||||
rejected: { badge: 'bg-[#FEE2E2] text-[#991B1B]', label: 'Rejected', card: 'bg-white border-[#E5E7EB]' },
|
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() || '??';
|
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 }) {
|
export default function CandidateCard({ application, jobTitle, rank, onAction, onMessage, onCall, onSchedule, onDecline, onDelete, onHire }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const position = jobTitle || application.job_title || '';
|
const position = jobTitle || application.job_title || '';
|
||||||
@@ -116,7 +117,7 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
|
|||||||
try {
|
try {
|
||||||
await base44.entities.JobApplication.update(application.id, { status: 'rejected' });
|
await base44.entities.JobApplication.update(application.id, { status: 'rejected' });
|
||||||
toast.success(`${application.applicant_name} declined`);
|
toast.success(`${application.applicant_name} declined`);
|
||||||
} catch (err) {
|
} catch (_err) {
|
||||||
toast.error('Failed to decline candidate');
|
toast.error('Failed to decline candidate');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,7 +131,7 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
|
|||||||
try {
|
try {
|
||||||
await base44.entities.JobApplication.delete(application.id);
|
await base44.entities.JobApplication.delete(application.id);
|
||||||
toast.success(`${application.applicant_name} deleted`);
|
toast.success(`${application.applicant_name} deleted`);
|
||||||
} catch (err) {
|
} catch (_err) {
|
||||||
toast.error('Failed to delete candidate');
|
toast.error('Failed to delete candidate');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ const METRIC_CONFIG = [
|
|||||||
{ key: 'employer_requirements', label: 'Req Match', Icon: Check },
|
{ key: 'employer_requirements', label: 'Req Match', Icon: Check },
|
||||||
];
|
];
|
||||||
|
|
||||||
function MetricPill({ label, value, Icon }) {
|
/** @param {any} props */
|
||||||
|
function _MetricPill({ label, value, Icon }) {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-white border border-[#E5E7EB]">
|
<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} />
|
<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 { Link } from 'react-router-dom';
|
||||||
import { ArrowRight } from 'lucide-react';
|
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 = (
|
const body = (
|
||||||
<div className="text-center sm:text-left">
|
<div className="text-center sm:text-left">
|
||||||
<div className="text-[11px] font-medium uppercase tracking-wider text-[#94A3B8]">{label}</div>
|
<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
|
* `MatchedCandidates` rather than inside it: two audiences, two components, one
|
||||||
* scoring function that cannot drift between them.
|
* 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);
|
const match = matchPosition(job, held);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
const tone = TONE[match.tone] || TONE.warning;
|
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 }) {
|
export default function TalentDetailModal({ open, onClose, profile, onContact, onSchedule, onHire, matchScore, matchReasons }) {
|
||||||
if (!profile) return null;
|
if (!profile) return null;
|
||||||
const badges = profile.earned_badges || [];
|
const badges = profile.earned_badges || [];
|
||||||
|
|||||||
@@ -92,9 +92,17 @@ function SkillPath({ state }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @param {any} props */
|
||||||
export function VerifiedSkills({
|
export function VerifiedSkills({
|
||||||
pageId, profile, profiles = [], courses = [], positions = [], email, customSources = [],
|
pageId = '',
|
||||||
onOpenTraining, className,
|
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
|
/* 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. */
|
application, not a worker profile — can be matched to the person behind it. */
|
||||||
@@ -126,7 +134,7 @@ export function VerifiedSkills({
|
|||||||
if (!worker) return [];
|
if (!worker) return [];
|
||||||
const byId = new Map(courses.map((c) => [c.id, c]));
|
const byId = new Map(courses.map((c) => [c.id, c]));
|
||||||
return [...(worker.completed_courses || [])]
|
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)
|
.slice(0, 3)
|
||||||
.map((entry) => {
|
.map((entry) => {
|
||||||
const course = byId.get(entry.course_id);
|
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
|
* hierarchy this card is meant to establish. Values share one ink colour so no
|
||||||
* single signal shouts louder than the rest.
|
* 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 : '—';
|
const shown = value != null && value !== '' ? value : '—';
|
||||||
return (
|
return (
|
||||||
<div className="flex items-baseline justify-between gap-3">
|
<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]';
|
const valColor = tone === 'verified' ? 'text-[#10B981]' : tone === 'amber' ? 'text-[#D97706]' : 'text-[#0F172A]';
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-3 py-2 border-b border-white/60 last:border-0">
|
<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 score = getTalentScore(profile);
|
||||||
const band = getScoreBand(score);
|
const band = getScoreBand(score);
|
||||||
const growth = getGrowth(profile);
|
const _growth = getGrowth(profile);
|
||||||
const esat = getESAT(profile);
|
const esat = getESAT(profile);
|
||||||
const fico = toFICO(score);
|
const fico = toFICO(score);
|
||||||
const shortId = (profile.id || '').slice(-6).toUpperCase();
|
const shortId = (profile.id || '').slice(-6).toUpperCase();
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ function marketRange(profile = {}) {
|
|||||||
return `$${low}-$${low + 4}/hr`;
|
return `$${low}-$${low + 4}/hr`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Field({ label, value, sub, accent }) {
|
/** @param {any} props */
|
||||||
|
function Field({ label, value, sub = '', accent = false }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[11px] uppercase tracking-wide text-[#6B7280]">{label}</p>
|
<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 score = getTalentScore(profile);
|
||||||
const factors = getFactors(profile);
|
const factors = getFactors(profile);
|
||||||
const first = (profile.full_name || 'there').split(' ')[0];
|
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 sections = useSkillSections(page, placement);
|
||||||
const resolved = useSkillDataContext(context);
|
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) => (
|
const Badge = React.forwardRef(({ className, variant, size, shape, ...props }, ref) => (
|
||||||
<div ref={ref} className={cn(badgeVariants({ variant, size, shape }), className)} {...props} />
|
<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"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const Breadcrumb = React.forwardRef(
|
const Breadcrumb = React.forwardRef(
|
||||||
({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />
|
({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />
|
||||||
)
|
)
|
||||||
Breadcrumb.displayName = "Breadcrumb"
|
Breadcrumb.displayName = "Breadcrumb"
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const BreadcrumbList = React.forwardRef(({ className, ...props }, ref) => (
|
const BreadcrumbList = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<ol
|
<ol
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -20,6 +22,7 @@ const BreadcrumbList = React.forwardRef(({ className, ...props }, ref) => (
|
|||||||
))
|
))
|
||||||
BreadcrumbList.displayName = "BreadcrumbList"
|
BreadcrumbList.displayName = "BreadcrumbList"
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const BreadcrumbItem = React.forwardRef(({ className, ...props }, ref) => (
|
const BreadcrumbItem = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<li
|
<li
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -28,6 +31,7 @@ const BreadcrumbItem = React.forwardRef(({ className, ...props }, ref) => (
|
|||||||
))
|
))
|
||||||
BreadcrumbItem.displayName = "BreadcrumbItem"
|
BreadcrumbItem.displayName = "BreadcrumbItem"
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const BreadcrumbLink = React.forwardRef(({ asChild, className, ...props }, ref) => {
|
const BreadcrumbLink = React.forwardRef(({ asChild, className, ...props }, ref) => {
|
||||||
const Comp = asChild ? Slot : "a"
|
const Comp = asChild ? Slot : "a"
|
||||||
|
|
||||||
@@ -40,6 +44,7 @@ const BreadcrumbLink = React.forwardRef(({ asChild, className, ...props }, ref)
|
|||||||
})
|
})
|
||||||
BreadcrumbLink.displayName = "BreadcrumbLink"
|
BreadcrumbLink.displayName = "BreadcrumbLink"
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const BreadcrumbPage = React.forwardRef(({ className, ...props }, ref) => (
|
const BreadcrumbPage = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<span
|
<span
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -51,6 +56,7 @@ const BreadcrumbPage = React.forwardRef(({ className, ...props }, ref) => (
|
|||||||
))
|
))
|
||||||
BreadcrumbPage.displayName = "BreadcrumbPage"
|
BreadcrumbPage.displayName = "BreadcrumbPage"
|
||||||
|
|
||||||
|
/** @param {any} props */
|
||||||
const BreadcrumbSeparator = ({
|
const BreadcrumbSeparator = ({
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
@@ -66,6 +72,7 @@ const BreadcrumbSeparator = ({
|
|||||||
)
|
)
|
||||||
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
|
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
|
||||||
|
|
||||||
|
/** @param {any} props */
|
||||||
const BreadcrumbEllipsis = ({
|
const BreadcrumbEllipsis = ({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ const buttonVariants = cva(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const Button = React.forwardRef((
|
const Button = React.forwardRef((
|
||||||
{ className, variant, size, shape, block, asChild = false, loading = false, disabled, children, ...props },
|
{ className, variant, size, shape, block, asChild = false, loading = false, disabled, children, ...props },
|
||||||
ref
|
ref
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import * as React from "react"
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const Card = React.forwardRef(({ className, ...props }, ref) => (
|
const Card = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -10,6 +11,7 @@ const Card = React.forwardRef(({ className, ...props }, ref) => (
|
|||||||
))
|
))
|
||||||
Card.displayName = "Card"
|
Card.displayName = "Card"
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
|
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -18,6 +20,7 @@ const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
|
|||||||
))
|
))
|
||||||
CardHeader.displayName = "CardHeader"
|
CardHeader.displayName = "CardHeader"
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
|
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -26,6 +29,7 @@ const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
|
|||||||
))
|
))
|
||||||
CardTitle.displayName = "CardTitle"
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
|
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -34,11 +38,13 @@ const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
|
|||||||
))
|
))
|
||||||
CardDescription.displayName = "CardDescription"
|
CardDescription.displayName = "CardDescription"
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
|
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
))
|
))
|
||||||
CardContent.displayName = "CardContent"
|
CardContent.displayName = "CardContent"
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
|
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Check } from "lucide-react"
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const Checkbox = React.forwardRef(({ className, ...props }, ref) => (
|
const Checkbox = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<CheckboxPrimitive.Root
|
<CheckboxPrimitive.Root
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const DialogPortal = DialogPrimitive.Portal
|
|||||||
|
|
||||||
const DialogClose = DialogPrimitive.Close
|
const DialogClose = DialogPrimitive.Close
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const DialogOverlay = React.forwardRef(({ className, ...props }, ref) => (
|
const DialogOverlay = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -25,6 +26,7 @@ const DialogOverlay = React.forwardRef(({ className, ...props }, ref) => (
|
|||||||
))
|
))
|
||||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const DialogContent = React.forwardRef(({ className, children, ...props }, ref) => (
|
const DialogContent = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
@@ -46,8 +48,9 @@ const DialogContent = React.forwardRef(({ className, children, ...props }, ref)
|
|||||||
))
|
))
|
||||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
/** @param {any} props */
|
||||||
const DialogHeader = ({
|
const DialogHeader = ({
|
||||||
className,
|
className = '',
|
||||||
...props
|
...props
|
||||||
}) => (
|
}) => (
|
||||||
<div
|
<div
|
||||||
@@ -56,8 +59,9 @@ const DialogHeader = ({
|
|||||||
)
|
)
|
||||||
DialogHeader.displayName = "DialogHeader"
|
DialogHeader.displayName = "DialogHeader"
|
||||||
|
|
||||||
|
/** @param {any} props */
|
||||||
const DialogFooter = ({
|
const DialogFooter = ({
|
||||||
className,
|
className = '',
|
||||||
...props
|
...props
|
||||||
}) => (
|
}) => (
|
||||||
<div
|
<div
|
||||||
@@ -66,6 +70,7 @@ const DialogFooter = ({
|
|||||||
)
|
)
|
||||||
DialogFooter.displayName = "DialogFooter"
|
DialogFooter.displayName = "DialogFooter"
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const DialogTitle = React.forwardRef(({ className, ...props }, ref) => (
|
const DialogTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<DialogPrimitive.Title
|
<DialogPrimitive.Title
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -74,6 +79,7 @@ const DialogTitle = React.forwardRef(({ className, ...props }, ref) => (
|
|||||||
))
|
))
|
||||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const DialogDescription = React.forwardRef(({ className, ...props }, ref) => (
|
const DialogDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<DialogPrimitive.Description
|
<DialogPrimitive.Description
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
|||||||
|
|
||||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const DropdownMenuSubTrigger = React.forwardRef(({ className, inset, children, ...props }, ref) => (
|
const DropdownMenuSubTrigger = React.forwardRef(({ className, inset, children, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.SubTrigger
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -32,6 +33,7 @@ const DropdownMenuSubTrigger = React.forwardRef(({ className, inset, children, .
|
|||||||
DropdownMenuSubTrigger.displayName =
|
DropdownMenuSubTrigger.displayName =
|
||||||
DropdownMenuPrimitive.SubTrigger.displayName
|
DropdownMenuPrimitive.SubTrigger.displayName
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const DropdownMenuSubContent = React.forwardRef(({ className, ...props }, ref) => (
|
const DropdownMenuSubContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.SubContent
|
<DropdownMenuPrimitive.SubContent
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -44,6 +46,7 @@ const DropdownMenuSubContent = React.forwardRef(({ className, ...props }, ref) =
|
|||||||
DropdownMenuSubContent.displayName =
|
DropdownMenuSubContent.displayName =
|
||||||
DropdownMenuPrimitive.SubContent.displayName
|
DropdownMenuPrimitive.SubContent.displayName
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const DropdownMenuContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (
|
const DropdownMenuContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.Portal>
|
<DropdownMenuPrimitive.Portal>
|
||||||
<DropdownMenuPrimitive.Content
|
<DropdownMenuPrimitive.Content
|
||||||
@@ -59,6 +62,7 @@ const DropdownMenuContent = React.forwardRef(({ className, sideOffset = 4, ...pr
|
|||||||
))
|
))
|
||||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const DropdownMenuItem = React.forwardRef(({ className, inset, ...props }, ref) => (
|
const DropdownMenuItem = React.forwardRef(({ className, inset, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.Item
|
<DropdownMenuPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -71,6 +75,7 @@ const DropdownMenuItem = React.forwardRef(({ className, inset, ...props }, ref)
|
|||||||
))
|
))
|
||||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const DropdownMenuCheckboxItem = React.forwardRef(({ className, children, checked, ...props }, ref) => (
|
const DropdownMenuCheckboxItem = React.forwardRef(({ className, children, checked, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.CheckboxItem
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -91,9 +96,11 @@ const DropdownMenuCheckboxItem = React.forwardRef(({ className, children, checke
|
|||||||
DropdownMenuCheckboxItem.displayName =
|
DropdownMenuCheckboxItem.displayName =
|
||||||
DropdownMenuPrimitive.CheckboxItem.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
|
<DropdownMenuPrimitive.RadioItem
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
value={value}
|
||||||
className={cn(
|
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",
|
"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
|
className
|
||||||
@@ -109,6 +116,7 @@ const DropdownMenuRadioItem = React.forwardRef(({ className, children, ...props
|
|||||||
))
|
))
|
||||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const DropdownMenuLabel = React.forwardRef(({ className, inset, ...props }, ref) => (
|
const DropdownMenuLabel = React.forwardRef(({ className, inset, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.Label
|
<DropdownMenuPrimitive.Label
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -117,6 +125,7 @@ const DropdownMenuLabel = React.forwardRef(({ className, inset, ...props }, ref)
|
|||||||
))
|
))
|
||||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const DropdownMenuSeparator = React.forwardRef(({ className, ...props }, ref) => (
|
const DropdownMenuSeparator = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.Separator
|
<DropdownMenuPrimitive.Separator
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const HoverCard = HoverCardPrimitive.Root
|
|||||||
|
|
||||||
const HoverCardTrigger = HoverCardPrimitive.Trigger
|
const HoverCardTrigger = HoverCardPrimitive.Trigger
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const HoverCardContent = React.forwardRef(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
const HoverCardContent = React.forwardRef(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||||
<HoverCardPrimitive.Content
|
<HoverCardPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ function buildSrcSet(parsed, options) {
|
|||||||
).join(", ")
|
).join(", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const ImageWrapper = React.forwardRef(({ aspectRatio, className, style, children }, ref) => (
|
const ImageWrapper = React.forwardRef(({ aspectRatio, className, style, children }, ref) => (
|
||||||
<span
|
<span
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -83,6 +84,7 @@ const ImageWrapper = React.forwardRef(({ aspectRatio, className, style, children
|
|||||||
))
|
))
|
||||||
ImageWrapper.displayName = "ImageWrapper"
|
ImageWrapper.displayName = "ImageWrapper"
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const ResponsiveImage = React.forwardRef(
|
const ResponsiveImage = React.forwardRef(
|
||||||
({ parsed, fittingType, focalPoint, quality, className, style, aspectRatio, onLoad, ...props }, parentRef) => {
|
({ parsed, fittingType, focalPoint, quality, className, style, aspectRatio, onLoad, ...props }, parentRef) => {
|
||||||
const wrapperRef = React.useRef(null)
|
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
|
* server-side, optionally anchored at a focal point. Other URLs render as a
|
||||||
* plain <img>. Failed loads swap to a fallback image.
|
* plain <img>. Failed loads swap to a fallback image.
|
||||||
*/
|
*/
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const Image = React.forwardRef(
|
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) => (
|
const Input = React.forwardRef(({ className, type, size, shape, ...props }, ref) => (
|
||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const labelVariants = cva(
|
|||||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
"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) => (
|
const Label = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const PopoverTrigger = PopoverPrimitive.Trigger
|
|||||||
|
|
||||||
const PopoverAnchor = PopoverPrimitive.Anchor
|
const PopoverAnchor = PopoverPrimitive.Anchor
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const PopoverContent = React.forwardRef(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
const PopoverContent = React.forwardRef(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||||
<PopoverPrimitive.Portal>
|
<PopoverPrimitive.Portal>
|
||||||
<PopoverPrimitive.Content
|
<PopoverPrimitive.Content
|
||||||
|
|||||||
@@ -4,15 +4,18 @@ import { Circle } from "lucide-react"
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const RadioGroup = React.forwardRef(({ className, ...props }, ref) => {
|
const RadioGroup = React.forwardRef(({ className, ...props }, ref) => {
|
||||||
return (<RadioGroupPrimitive.Root className={cn("grid gap-2", className)} {...props} ref={ref} />);
|
return (<RadioGroupPrimitive.Root className={cn("grid gap-2", className)} {...props} ref={ref} />);
|
||||||
})
|
})
|
||||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
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 (
|
return (
|
||||||
(<RadioGroupPrimitive.Item
|
(<RadioGroupPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
value={value}
|
||||||
className={cn(
|
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",
|
"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
|
className
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const SelectGroup = SelectPrimitive.Group
|
|||||||
|
|
||||||
const SelectValue = SelectPrimitive.Value
|
const SelectValue = SelectPrimitive.Value
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const SelectTrigger = React.forwardRef(({ className, children, ...props }, ref) => (
|
const SelectTrigger = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -28,6 +29,7 @@ const SelectTrigger = React.forwardRef(({ className, children, ...props }, ref)
|
|||||||
))
|
))
|
||||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const SelectScrollUpButton = React.forwardRef(({ className, ...props }, ref) => (
|
const SelectScrollUpButton = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<SelectPrimitive.ScrollUpButton
|
<SelectPrimitive.ScrollUpButton
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -38,6 +40,7 @@ const SelectScrollUpButton = React.forwardRef(({ className, ...props }, ref) =>
|
|||||||
))
|
))
|
||||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const SelectScrollDownButton = React.forwardRef(({ className, ...props }, ref) => (
|
const SelectScrollDownButton = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<SelectPrimitive.ScrollDownButton
|
<SelectPrimitive.ScrollDownButton
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -49,6 +52,7 @@ const SelectScrollDownButton = React.forwardRef(({ className, ...props }, ref) =
|
|||||||
SelectScrollDownButton.displayName =
|
SelectScrollDownButton.displayName =
|
||||||
SelectPrimitive.ScrollDownButton.displayName
|
SelectPrimitive.ScrollDownButton.displayName
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const SelectContent = React.forwardRef(({ className, children, position = "popper", ...props }, ref) => (
|
const SelectContent = React.forwardRef(({ className, children, position = "popper", ...props }, ref) => (
|
||||||
<SelectPrimitive.Portal>
|
<SelectPrimitive.Portal>
|
||||||
<SelectPrimitive.Content
|
<SelectPrimitive.Content
|
||||||
@@ -73,6 +77,7 @@ const SelectContent = React.forwardRef(({ className, children, position = "poppe
|
|||||||
))
|
))
|
||||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const SelectLabel = React.forwardRef(({ className, ...props }, ref) => (
|
const SelectLabel = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<SelectPrimitive.Label
|
<SelectPrimitive.Label
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -81,9 +86,17 @@ const SelectLabel = React.forwardRef(({ className, ...props }, ref) => (
|
|||||||
))
|
))
|
||||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
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
|
<SelectPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
value={value}
|
||||||
className={cn(
|
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",
|
"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
|
className
|
||||||
@@ -99,6 +112,7 @@ const SelectItem = React.forwardRef(({ className, children, ...props }, ref) =>
|
|||||||
))
|
))
|
||||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const SelectSeparator = React.forwardRef(({ className, ...props }, ref) => (
|
const SelectSeparator = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<SelectPrimitive.Separator
|
<SelectPrimitive.Separator
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const SheetClose = SheetPrimitive.Close
|
|||||||
|
|
||||||
const SheetPortal = SheetPrimitive.Portal
|
const SheetPortal = SheetPrimitive.Portal
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const SheetOverlay = React.forwardRef(({ className, ...props }, ref) => (
|
const SheetOverlay = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<SheetPrimitive.Overlay
|
<SheetPrimitive.Overlay
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -44,6 +45,7 @@ const sheetVariants = cva(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const SheetContent = React.forwardRef(({ side = "right", className, children, ...props }, ref) => (
|
const SheetContent = React.forwardRef(({ side = "right", className, children, ...props }, ref) => (
|
||||||
<SheetPortal>
|
<SheetPortal>
|
||||||
<SheetOverlay />
|
<SheetOverlay />
|
||||||
@@ -79,6 +81,7 @@ const SheetFooter = ({
|
|||||||
)
|
)
|
||||||
SheetFooter.displayName = "SheetFooter"
|
SheetFooter.displayName = "SheetFooter"
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const SheetTitle = React.forwardRef(({ className, ...props }, ref) => (
|
const SheetTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<SheetPrimitive.Title
|
<SheetPrimitive.Title
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -87,6 +90,7 @@ const SheetTitle = React.forwardRef(({ className, ...props }, ref) => (
|
|||||||
))
|
))
|
||||||
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const SheetDescription = React.forwardRef(({ className, ...props }, ref) => (
|
const SheetDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<SheetPrimitive.Description
|
<SheetPrimitive.Description
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import * as SliderPrimitive from "@radix-ui/react-slider"
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const Slider = React.forwardRef(({ className, ...props }, ref) => (
|
const Slider = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<SliderPrimitive.Root
|
<SliderPrimitive.Root
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import * as SwitchPrimitives from "@radix-ui/react-switch"
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const Switch = React.forwardRef(({ className, ...props }, ref) => (
|
const Switch = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<SwitchPrimitives.Root
|
<SwitchPrimitives.Root
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { cn } from "@/lib/utils"
|
|||||||
|
|
||||||
const Tabs = TabsPrimitive.Root
|
const Tabs = TabsPrimitive.Root
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const TabsList = React.forwardRef(({ className, ...props }, ref) => (
|
const TabsList = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<TabsPrimitive.List
|
<TabsPrimitive.List
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -16,6 +17,7 @@ const TabsList = React.forwardRef(({ className, ...props }, ref) => (
|
|||||||
))
|
))
|
||||||
TabsList.displayName = TabsPrimitive.List.displayName
|
TabsList.displayName = TabsPrimitive.List.displayName
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
|
const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<TabsPrimitive.Trigger
|
<TabsPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -27,6 +29,7 @@ const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
|
|||||||
))
|
))
|
||||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
|
const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<TabsPrimitive.Content
|
<TabsPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import * as React from "react"
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const Textarea = React.forwardRef(({ className, ...props }, ref) => {
|
const Textarea = React.forwardRef(({ className, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
(<textarea
|
(<textarea
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { cva } from "class-variance-authority";
|
|||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const ToastProvider = React.forwardRef(({ ...props }, ref) => (
|
const ToastProvider = React.forwardRef(({ ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -12,6 +13,7 @@ const ToastProvider = React.forwardRef(({ ...props }, ref) => (
|
|||||||
));
|
));
|
||||||
ToastProvider.displayName = "ToastProvider";
|
ToastProvider.displayName = "ToastProvider";
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const ToastViewport = React.forwardRef(({ ...props }, ref) => (
|
const ToastViewport = React.forwardRef(({ ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -37,6 +39,7 @@ const toastVariants = cva(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const Toast = React.forwardRef(({ className, variant, ...props }, ref) => {
|
const Toast = React.forwardRef(({ className, variant, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -48,6 +51,7 @@ const Toast = React.forwardRef(({ className, variant, ...props }, ref) => {
|
|||||||
});
|
});
|
||||||
Toast.displayName = "Toast";
|
Toast.displayName = "Toast";
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const ToastAction = React.forwardRef(({ className, ...props }, ref) => (
|
const ToastAction = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -60,6 +64,7 @@ const ToastAction = React.forwardRef(({ className, ...props }, ref) => (
|
|||||||
));
|
));
|
||||||
ToastAction.displayName = "ToastAction";
|
ToastAction.displayName = "ToastAction";
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const ToastClose = React.forwardRef(({ className, ...props }, ref) => (
|
const ToastClose = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<button
|
<button
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -75,6 +80,7 @@ const ToastClose = React.forwardRef(({ className, ...props }, ref) => (
|
|||||||
));
|
));
|
||||||
ToastClose.displayName = "ToastClose";
|
ToastClose.displayName = "ToastClose";
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const ToastTitle = React.forwardRef(({ className, ...props }, ref) => (
|
const ToastTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -84,6 +90,7 @@ const ToastTitle = React.forwardRef(({ className, ...props }, ref) => (
|
|||||||
));
|
));
|
||||||
ToastTitle.displayName = "ToastTitle";
|
ToastTitle.displayName = "ToastTitle";
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const ToastDescription = React.forwardRef(({ className, ...props }, ref) => (
|
const ToastDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import {
|
|||||||
ToastViewport,
|
ToastViewport,
|
||||||
} from "@/components/ui/toast";
|
} from "@/components/ui/toast";
|
||||||
|
|
||||||
export function Toaster() {
|
/** @param {any} [props] */
|
||||||
|
export function Toaster(props) {
|
||||||
const { toasts } = useToast();
|
const { toasts } = useToast();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const Tooltip = TooltipPrimitive.Root
|
|||||||
|
|
||||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||||
|
|
||||||
|
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||||
const TooltipContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (
|
const TooltipContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
<TooltipPrimitive.Portal>
|
<TooltipPrimitive.Portal>
|
||||||
<TooltipPrimitive.Content
|
<TooltipPrimitive.Content
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ import { base44 } from '@/api/base44Client';
|
|||||||
* consumer stay unchanged.
|
* consumer stay unchanged.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const AuthContext = createContext();
|
const AuthContext = createContext(/** @type {any} */ (null));
|
||||||
|
|
||||||
|
/** @param {any} props */
|
||||||
export const AuthProvider = ({ children }) => {
|
export const AuthProvider = ({ children }) => {
|
||||||
const [user, setUser] = useState(null);
|
const [user, setUser] = useState(null);
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
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 app = applications.find((a) => a.id === s.application_id);
|
||||||
const posting = postings.find((p) => p.id === s.job_posting_id);
|
const posting = postings.find((p) => p.id === s.job_posting_id);
|
||||||
const days = app
|
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;
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function useCurrentUser() {
|
|||||||
export function useUpdateProfile() {
|
export function useUpdateProfile() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (patch) => base44.auth.updateMe(patch),
|
mutationFn: /** @param {any} patch */ (patch) => base44.auth.updateMe(patch),
|
||||||
onSuccess: (user) => {
|
onSuccess: (user) => {
|
||||||
queryClient.setQueryData(['user'], user);
|
queryClient.setQueryData(['user'], user);
|
||||||
queryClient.invalidateQueries({ queryKey: ['user'] });
|
queryClient.invalidateQueries({ queryKey: ['user'] });
|
||||||
@@ -46,7 +46,7 @@ export function usePreferences() {
|
|||||||
export function useUpdatePreferences() {
|
export function useUpdatePreferences() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (patch) => base44.auth.updatePreferences(patch),
|
mutationFn: /** @param {any} patch */ (patch) => base44.auth.updatePreferences(patch),
|
||||||
onSuccess: (user) => {
|
onSuccess: (user) => {
|
||||||
queryClient.setQueryData(['user'], user);
|
queryClient.setQueryData(['user'], user);
|
||||||
queryClient.invalidateQueries({ queryKey: ['user'] });
|
queryClient.invalidateQueries({ queryKey: ['user'] });
|
||||||
@@ -96,7 +96,7 @@ export function useStaff() {
|
|||||||
export function useUpdateStaff() {
|
export function useUpdateStaff() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ id, data }) => base44.entities.Staff.update(id, data),
|
mutationFn: /** @param {any} vars */ ({ id, data }) => base44.entities.Staff.update(id, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['staff'] });
|
queryClient.invalidateQueries({ queryKey: ['staff'] });
|
||||||
},
|
},
|
||||||
@@ -120,7 +120,7 @@ export function useCertifications() {
|
|||||||
export function useCreateCertification() {
|
export function useCreateCertification() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data) => base44.entities.Certification.create(data),
|
mutationFn: /** @param {any} data */ (data) => base44.entities.Certification.create(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['certifications'] });
|
queryClient.invalidateQueries({ queryKey: ['certifications'] });
|
||||||
},
|
},
|
||||||
@@ -130,7 +130,7 @@ export function useCreateCertification() {
|
|||||||
export function useDeleteCertification() {
|
export function useDeleteCertification() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (id) => base44.entities.Certification.delete(id),
|
mutationFn: /** @param {any} id */ (id) => base44.entities.Certification.delete(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['certifications'] });
|
queryClient.invalidateQueries({ queryKey: ['certifications'] });
|
||||||
},
|
},
|
||||||
@@ -140,7 +140,7 @@ export function useDeleteCertification() {
|
|||||||
export function useCreateRoleCategory() {
|
export function useCreateRoleCategory() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data) => base44.entities.RoleCategory.create(data),
|
mutationFn: /** @param {any} data */ (data) => base44.entities.RoleCategory.create(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['roleCategories'] });
|
queryClient.invalidateQueries({ queryKey: ['roleCategories'] });
|
||||||
},
|
},
|
||||||
@@ -157,7 +157,7 @@ export function useUserActivity() {
|
|||||||
export function useCreateJobPosting() {
|
export function useCreateJobPosting() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data) => base44.entities.JobPosting.create(data),
|
mutationFn: /** @param {any} data */ (data) => base44.entities.JobPosting.create(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['jobPostings'] });
|
queryClient.invalidateQueries({ queryKey: ['jobPostings'] });
|
||||||
logActivity('create_position');
|
logActivity('create_position');
|
||||||
@@ -168,7 +168,7 @@ export function useCreateJobPosting() {
|
|||||||
export function useUpdateJobPosting() {
|
export function useUpdateJobPosting() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
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) => {
|
onSuccess: (_data, variables) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['jobPostings'] });
|
queryClient.invalidateQueries({ queryKey: ['jobPostings'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['jobPosting', variables.id] });
|
queryClient.invalidateQueries({ queryKey: ['jobPosting', variables.id] });
|
||||||
@@ -179,7 +179,7 @@ export function useUpdateJobPosting() {
|
|||||||
export function useCreateApplication() {
|
export function useCreateApplication() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data) => base44.entities.JobApplication.create(data),
|
mutationFn: /** @param {any} data */ (data) => base44.entities.JobApplication.create(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
||||||
logActivity('apply_job');
|
logActivity('apply_job');
|
||||||
@@ -190,7 +190,7 @@ export function useCreateApplication() {
|
|||||||
export function useUpdateApplication() {
|
export function useUpdateApplication() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ id, data }) => base44.entities.JobApplication.update(id, data),
|
mutationFn: /** @param {any} vars */ ({ id, data }) => base44.entities.JobApplication.update(id, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
||||||
},
|
},
|
||||||
@@ -200,7 +200,7 @@ export function useUpdateApplication() {
|
|||||||
export function useScreenCandidate() {
|
export function useScreenCandidate() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async ({ application, job }) => {
|
mutationFn: /** @param {any} vars */ async ({ application, job }) => {
|
||||||
const result = await screenCandidate(application, job);
|
const result = await screenCandidate(application, job);
|
||||||
return base44.entities.JobApplication.update(application.id, {
|
return base44.entities.JobApplication.update(application.id, {
|
||||||
status: 'ai_screened',
|
status: 'ai_screened',
|
||||||
@@ -223,7 +223,7 @@ export function useScreenCandidate() {
|
|||||||
export function useScreenAllCandidates() {
|
export function useScreenAllCandidates() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async ({ applications, job }) => {
|
mutationFn: /** @param {any} vars */ async ({ applications, job }) => {
|
||||||
const results = [];
|
const results = [];
|
||||||
for (const app of applications) {
|
for (const app of applications) {
|
||||||
if (app.status === 'applied' || app.status === 'ai_screened') {
|
if (app.status === 'applied' || app.status === 'ai_screened') {
|
||||||
@@ -253,7 +253,7 @@ export function useScreenAllCandidates() {
|
|||||||
export function useGenerateJobDescription() {
|
export function useGenerateJobDescription() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async ({ data, draftId }) => {
|
mutationFn: /** @param {any} vars */ async ({ data, draftId }) => {
|
||||||
const result = await generateJobDescription(data);
|
const result = await generateJobDescription(data);
|
||||||
if (draftId) {
|
if (draftId) {
|
||||||
await base44.entities.JobPosting.update(draftId, {
|
await base44.entities.JobPosting.update(draftId, {
|
||||||
@@ -274,7 +274,7 @@ export function useGenerateJobDescription() {
|
|||||||
export function useHireCandidate() {
|
export function useHireCandidate() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
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 tier = application.ai_score >= 80 ? 'Skilled' : application.ai_score >= 60 ? 'Cross-Trained' : 'Beginner';
|
||||||
const updated = await base44.entities.JobApplication.update(application.id, { status: 'hired' });
|
const updated = await base44.entities.JobApplication.update(application.id, { status: 'hired' });
|
||||||
await base44.entities.Staff.create({
|
await base44.entities.Staff.create({
|
||||||
@@ -301,14 +301,14 @@ export function useHireCandidate() {
|
|||||||
|
|
||||||
export function useMatchTalentForJob() {
|
export function useMatchTalentForJob() {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async ({ job, profiles }) => matchTalentForJob(job, profiles),
|
mutationFn: /** @param {any} vars */ async ({ job, profiles }) => matchTalentForJob(job, profiles),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCreateInterview() {
|
export function useCreateInterview() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data) => base44.entities.AIInterview.create(data),
|
mutationFn: /** @param {any} data */ (data) => base44.entities.AIInterview.create(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['interviews'] });
|
queryClient.invalidateQueries({ queryKey: ['interviews'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
||||||
@@ -339,7 +339,7 @@ export function useCourse(id) {
|
|||||||
export function useCreateCourse() {
|
export function useCreateCourse() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data) => base44.entities.Course.create(data),
|
mutationFn: /** @param {any} data */ (data) => base44.entities.Course.create(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['courses'] });
|
queryClient.invalidateQueries({ queryKey: ['courses'] });
|
||||||
logActivity('create_skill_training');
|
logActivity('create_skill_training');
|
||||||
@@ -351,7 +351,7 @@ export function useCreateCourse() {
|
|||||||
export function useUpdateCourse() {
|
export function useUpdateCourse() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
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) => {
|
onSuccess: (_data, variables) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['courses'] });
|
queryClient.invalidateQueries({ queryKey: ['courses'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['course', variables.id] });
|
queryClient.invalidateQueries({ queryKey: ['course', variables.id] });
|
||||||
@@ -385,7 +385,7 @@ export function useAssignments() {
|
|||||||
export function useAssignWorkers() {
|
export function useAssignWorkers() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async ({ position, workers = [], applications = [] }) => {
|
mutationFn: /** @param {any} vars */ async ({ position, workers = [], applications = [] }) => {
|
||||||
const startsAt = position.start_date
|
const startsAt = position.start_date
|
||||||
? new Date(position.start_date).toISOString()
|
? new Date(position.start_date).toISOString()
|
||||||
: new Date().toISOString();
|
: new Date().toISOString();
|
||||||
@@ -484,7 +484,7 @@ export function useAssignWorkers() {
|
|||||||
export function useMarkInterviewReady() {
|
export function useMarkInterviewReady() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async ({ application, position }) => {
|
mutationFn: /** @param {any} vars */ async ({ application, position }) => {
|
||||||
if (!application?.id) throw new Error('No application to schedule against');
|
if (!application?.id) throw new Error('No application to schedule against');
|
||||||
|
|
||||||
const updated = await base44.entities.JobApplication.update(application.id, {
|
const updated = await base44.entities.JobApplication.update(application.id, {
|
||||||
@@ -564,7 +564,7 @@ export function useWorkerProfiles() {
|
|||||||
export function useUpdateWorkerProfile() {
|
export function useUpdateWorkerProfile() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ id, data }) => base44.entities.WorkerProfile.update(id, data),
|
mutationFn: /** @param {any} vars */ ({ id, data }) => base44.entities.WorkerProfile.update(id, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['workerProfile'] });
|
queryClient.invalidateQueries({ queryKey: ['workerProfile'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['workerProfiles'] });
|
queryClient.invalidateQueries({ queryKey: ['workerProfiles'] });
|
||||||
@@ -575,7 +575,7 @@ export function useUpdateWorkerProfile() {
|
|||||||
export function useCompleteCourse() {
|
export function useCompleteCourse() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
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 already = (profile.completed_courses || []).some(c => c.course_id === course.id);
|
||||||
const completed_courses = already ? profile.completed_courses : [
|
const completed_courses = already ? profile.completed_courses : [
|
||||||
...profile.completed_courses,
|
...profile.completed_courses,
|
||||||
@@ -616,7 +616,7 @@ export function useEvidenceList(workerEmail) {
|
|||||||
export function useSubmitChallenge() {
|
export function useSubmitChallenge() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
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 result = await evaluateChallenge(course, { type, mediaUrl, transcript, identified, workerName: profile?.full_name });
|
||||||
const verdict = result.verdict || 'needs_work';
|
const verdict = result.verdict || 'needs_work';
|
||||||
const score = Math.round(Number(result.score) || 0);
|
const score = Math.round(Number(result.score) || 0);
|
||||||
@@ -674,7 +674,7 @@ export function useSubmitChallenge() {
|
|||||||
export function useVerifyEvidence() {
|
export function useVerifyEvidence() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async ({ evidenceId }) =>
|
mutationFn: /** @param {any} vars */ async ({ evidenceId }) =>
|
||||||
base44.entities.Evidence.update(evidenceId, {
|
base44.entities.Evidence.update(evidenceId, {
|
||||||
supervisor_verified: true,
|
supervisor_verified: true,
|
||||||
supervisor_name: 'Supervisor',
|
supervisor_name: 'Supervisor',
|
||||||
|
|||||||
@@ -397,7 +397,7 @@ const RESOLVERS = {
|
|||||||
/** Everyone hired, most recent first — the record rather than the analysis. */
|
/** Everyone hired, most recent first — the record rather than the analysis. */
|
||||||
'hires.recent': ({ staff = [], applications = [], positions = [] }, section) => {
|
'hires.recent': ({ staff = [], applications = [], positions = [] }, section) => {
|
||||||
const items = [...staff]
|
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)
|
.slice(0, section.limit || 10)
|
||||||
.map((s) => {
|
.map((s) => {
|
||||||
const app = applications.find((a) => a.id === s.application_id);
|
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 hired = applications.filter((a) => a.status === 'hired');
|
||||||
const scores = applications.map((a) => a.ai_score).filter((n) => n > 0);
|
const scores = applications.map((a) => a.ai_score).filter((n) => n > 0);
|
||||||
const days = hired
|
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);
|
.filter((n) => Number.isFinite(n) && n >= 0);
|
||||||
|
|
||||||
const mean = (xs) => (xs.length ? Math.round(xs.reduce((a, b) => a + b, 0) / xs.length) : 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. */
|
/** The workspace audit trail, most recent first. */
|
||||||
'activity.events': ({ activity = [] }, section) => {
|
'activity.events': ({ activity = [] }, section) => {
|
||||||
const items = [...activity]
|
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)
|
.slice(0, section.limit || 10)
|
||||||
.map((event) => ({
|
.map((event) => ({
|
||||||
id: event.id,
|
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) {
|
function sectionBullets(body, heading) {
|
||||||
const section = new RegExp(`##\\s+${heading}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, 'i').exec(body);
|
const source = sectionSource(body, heading);
|
||||||
if (!section) return [];
|
if (source == null) return [];
|
||||||
return section[1]
|
|
||||||
.split(/\r?\n/)
|
const items = [];
|
||||||
.map((l) => /^\s*-\s+(.*)$/.exec(l)?.[1]?.trim())
|
for (const line of source.split(/\r?\n/)) {
|
||||||
.filter(Boolean);
|
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.
|
* rather than a list.
|
||||||
*/
|
*/
|
||||||
function sectionText(body, heading) {
|
function sectionText(body, heading) {
|
||||||
const section = new RegExp(`##\\s+${heading}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, 'i').exec(body);
|
const source = sectionSource(body, heading);
|
||||||
if (!section) return '';
|
if (source == null) return '';
|
||||||
return section[1]
|
return source
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
.map((l) => l.replace(/^\s*[-*]\s+/, '').trim())
|
.map((l) => l.replace(/^\s*(?:[-*]|\d+[.)])\s+/, '').trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ')
|
.join(' ')
|
||||||
.trim();
|
.trim();
|
||||||
@@ -323,6 +377,139 @@ export const SKILLS = Object.entries(FILES)
|
|||||||
.map(([path, raw]) => parseSkill(raw, { path }))
|
.map(([path, raw]) => parseSkill(raw, { path }))
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
.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.
|
* 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
|
* 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,
|
* 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.
|
* 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 = []) {
|
export function allSkills(customSources = []) {
|
||||||
const custom = customSources
|
return readSkillRegistry(customSources).skills;
|
||||||
.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);
|
|
||||||
|
|
||||||
const byId = new Map(SKILLS.map((s) => [s.id, s]));
|
/** Everything that went wrong assembling the registry, for the Skills page. */
|
||||||
custom.forEach((s) => byId.set(s.id, s));
|
export function skillDiagnostics(customSources = []) {
|
||||||
return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name));
|
return readSkillRegistry(customSources).diagnostics;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -472,11 +655,27 @@ function triggerMatches(trigger, question) {
|
|||||||
*/
|
*/
|
||||||
const addressable = (skill) => skill.declaredTriggers || skill.facets?.includes('owliver');
|
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();
|
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))
|
(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) =>
|
export const skillsWithFacet = (skills = [], facet) =>
|
||||||
skills.filter((s) => s.facets?.includes(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. */
|
/** Days from now until the work starts. Negative or zero means it is live. */
|
||||||
export const daysUntilStart = (position, today = new Date()) =>
|
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. */
|
/** "Immediately", "in 6 days", "in 3 weeks" — the phrasing answers use. */
|
||||||
export function startLabel(position, today = new Date()) {
|
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. */
|
partial fit for it, and anyone without is a full one. */
|
||||||
const free = profile?.available_until ? new Date(profile.available_until) : null;
|
const free = profile?.available_until ? new Date(profile.available_until) : null;
|
||||||
if (!free) return { covers: true, ratio: 1, note: 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 {
|
return {
|
||||||
covers: false,
|
covers: false,
|
||||||
ratio: Math.min(1, months / 12),
|
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;
|
const free = profile?.available_until ? new Date(profile.available_until) : null;
|
||||||
if (!free || free >= end) return { covers: true, ratio: 1, note: null };
|
if (!free || free >= end) return { covers: true, ratio: 1, note: null };
|
||||||
|
|
||||||
const needed = end - startsAt(position, today);
|
const needed = end.getTime() - startsAt(position, today).getTime();
|
||||||
const have = Math.max(0, free - startsAt(position, today));
|
const have = Math.max(0, free.getTime() - startsAt(position, today).getTime());
|
||||||
const ratio = needed ? Math.max(0, Math.min(1, have / needed)) : 1;
|
const ratio = needed ? Math.max(0, Math.min(1, have / needed)) : 1;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -545,7 +545,9 @@ export function prioritise(positions = [], context = {}) {
|
|||||||
* function existing separately from the mutation is what makes "show me first"
|
* function existing separately from the mutation is what makes "show me first"
|
||||||
* the only possible path rather than a convention someone has to remember.
|
* 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 status = workforceStatusFor(position, context);
|
||||||
const take = Math.min(
|
const take = Math.min(
|
||||||
count ?? status.demand.remaining,
|
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 };
|
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 (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
@@ -140,7 +140,7 @@ export default function AdminDashboard() {
|
|||||||
<p className="text-[13px] text-[#9CA3AF]">No hired staff yet</p>
|
<p className="text-[13px] text-[#9CA3AF]">No hired staff yet</p>
|
||||||
</div>
|
</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 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">
|
<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()}
|
{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.
|
* 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 (
|
return (
|
||||||
<Surface variant="solid" radius="xl" padding="lg" className={className}>
|
<Surface variant="solid" radius="xl" padding="lg" className={className}>
|
||||||
<SectionHeader title={title} subtitle={description} />
|
<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 (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
{label && <span className="text-caption text-ink-4 w-24 shrink-0">{label}</span>}
|
{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;
|
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 ratedCount = staff.filter((s) => (s.client_rating || 0) > 0).length;
|
||||||
const avgRating = ratedCount > 0
|
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 (
|
return (
|
||||||
<div className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-[#F0F4F8] border border-[#E5E7EB]">
|
<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>
|
<span className="text-[11px] font-semibold text-[#1E56C1]">{label}</span>
|
||||||
|
|||||||
@@ -175,7 +175,8 @@ export default function Owliver({ compact = false }) {
|
|||||||
}, [workerName, speak, language]);
|
}, [workerName, speak, language]);
|
||||||
|
|
||||||
const startListening = useCallback(() => {
|
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; }
|
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.
|
// Yield Owliver's voice so the mic picks up the user, not Owliver.
|
||||||
if (window.speechSynthesis) window.speechSynthesis.cancel();
|
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();
|
const has = value && String(value).trim();
|
||||||
return (
|
return (
|
||||||
<div className={cn('rounded-lg bg-[#F9FAFB] p-2.5', full && 'col-span-2')}>
|
<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(
|
const recent = useMemo(
|
||||||
() => events
|
() => events
|
||||||
.slice()
|
.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),
|
.slice(0, TIMELINE_LIMIT),
|
||||||
[events]
|
[events]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -85,7 +85,8 @@ const PRESSABLE = 'transition-transform duration-150 hover:-translate-y-px activ
|
|||||||
/* ── Small shared pieces ────────────────────────────────────────────────── */
|
/* ── Small shared pieces ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
/** A label inside a section — a rung below the section headings the rail draws. */
|
/** 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 (
|
return (
|
||||||
<p className={cn('mb-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-ink-4', className)}>
|
<p className={cn('mb-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-ink-4', className)}>
|
||||||
{children}
|
{children}
|
||||||
@@ -115,7 +116,8 @@ function Signal({ label, value }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A reputation figure. Value large, label quiet, no card around it. */
|
/** 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;
|
if (value == null || value === '') return null;
|
||||||
return (
|
return (
|
||||||
<div className="min-w-0 px-4 py-3">
|
<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
|
* Staggered by index, once. The delay is capped so a candidate with twenty
|
||||||
* skills does not make the reader watch a queue form.
|
* 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();
|
const reduced = useReducedMotion();
|
||||||
return (
|
return (
|
||||||
<motion.span
|
<motion.span
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const SCORE_BANDS = {
|
|||||||
|
|
||||||
const SORTS = {
|
const SORTS = {
|
||||||
score: { label: 'Highest score', compare: (a, b) => (b.ai_score || 0) - (a.ai_score || 0) },
|
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) },
|
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) },
|
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;
|
const WEAK_TRANSITION = 60;
|
||||||
|
|
||||||
function Band({ id, title, meta, children, action }) {
|
/** @param {any} props */
|
||||||
|
function Band({ id, title, meta = '', children, action = null }) {
|
||||||
return (
|
return (
|
||||||
<section aria-labelledby={id} className="space-y-3">
|
<section aria-labelledby={id} className="space-y-3">
|
||||||
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-2">
|
<div className="flex 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;
|
if (cutoff && new Date(h.hire_date || 0).getTime() < cutoff) return false;
|
||||||
return true;
|
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]);
|
}, [hires, search, filters]);
|
||||||
|
|
||||||
const summary = useMemo(() => summarise(filtered), [filtered]);
|
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 [form, setForm] = useState({ email: DEMO_EMAIL, password: '', remember: true });
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [errors, setErrors] = useState({});
|
const [errors, setErrors] = useState(/** @type {{ email?: string, password?: string }} */ ({}));
|
||||||
const [authError, setAuthError] = useState('');
|
const [authError, setAuthError] = useState('');
|
||||||
const [status, setStatus] = useState('idle');
|
const [status, setStatus] = useState('idle');
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
Avatar, Badge, Button, Drawer, EmptyState, ProgressBar, SearchInput, SegmentedToggle,
|
Avatar, Badge, Button, Drawer, EmptyState, SearchInput, SegmentedToggle,
|
||||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Skeleton, StatusBadge, toast,
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Skeleton, StatusBadge, toast,
|
||||||
} from '@/components/ds';
|
} from '@/components/ds';
|
||||||
import {
|
import {
|
||||||
@@ -59,7 +59,7 @@ const SORTS = {
|
|||||||
starved: { label: 'Fewest applicants', compare: (a, b) => a.stats.applied - b.stats.applied },
|
starved: { label: 'Fewest applicants', compare: (a, b) => a.stats.applied - b.stats.applied },
|
||||||
updated: {
|
updated: {
|
||||||
label: 'Recently 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) },
|
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. */
|
/** 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 (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -740,43 +741,48 @@ function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition })
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Hiring status */}
|
{/* Hiring progression — the single source of truth for the pipeline.
|
||||||
<section>
|
A separate "Hiring status" block used to sit above this one repeating
|
||||||
<SectionTitle title="Hiring status" />
|
Shortlisted, Interviews and Hired as figures, which is exactly what the
|
||||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-3">
|
progression already draws. Two readings of the same funnel invite the
|
||||||
{[
|
reader to check whether they agree, so the duplicated stage counts are
|
||||||
{ label: 'Applicants', value: s.applied },
|
gone and the three figures the progression does *not* carry — supply,
|
||||||
{ label: 'Shortlisted', value: s.shortlisted },
|
quality and the screening average — lead it as one line. */}
|
||||||
{ label: 'Interviews', value: s.interviews },
|
|
||||||
{ label: 'Hired', value: s.hired },
|
|
||||||
{ label: 'Qualified (70+)', value: s.qualified },
|
|
||||||
{ label: 'Avg score', value: s.avgScore || '—' },
|
|
||||||
].map((m) => (
|
|
||||||
<div key={m.label}>
|
|
||||||
<dt className="text-[10px] font-medium uppercase tracking-wide text-ink-4">{m.label}</dt>
|
|
||||||
<dd className="font-heading text-title font-bold tabular-nums text-ink-1">{m.value}</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
<div className="mt-4">
|
|
||||||
<div className="mb-1.5 flex items-baseline justify-between">
|
|
||||||
<span className="text-caption text-ink-2">Screening progress</span>
|
|
||||||
<span className="text-caption font-semibold tabular-nums text-ink-1">{s.coverage}%</span>
|
|
||||||
</div>
|
|
||||||
<ProgressBar
|
|
||||||
value={s.coverage}
|
|
||||||
tone={s.coverage === 100 ? 'success' : s.coverage >= 60 ? 'brand' : 'warning'}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-[11px] text-ink-4">
|
|
||||||
{s.applied - s.unscreened} of {s.applied} applicants scored
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<SectionTitle title="Hiring progression" />
|
<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} />
|
<Progression steps={p.progression} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -811,22 +817,45 @@ function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition })
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-body-sm text-ink-3">
|
/* Two short lines, not a paragraph. The reason nobody is ranked is
|
||||||
Nobody has been screened for this role yet, so there is no ranking to show.
|
always the same — screening has not run — so it needs stating once,
|
||||||
</p>
|
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>
|
</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>
|
<section>
|
||||||
<SectionTitle title="Hiring timeline" />
|
<SectionTitle title="Hiring timeline" />
|
||||||
<ol className="space-y-2">
|
<ol className="space-y-0">
|
||||||
{timeline.map((e, i) => (
|
{timeline.map((e, i) => (
|
||||||
<li key={i} className="flex items-baseline justify-between gap-3 text-body-sm">
|
<li key={i} className="flex gap-2.5 pb-3 last:pb-0">
|
||||||
<span className="text-ink-2">{e.label}</span>
|
<span className="flex flex-col items-center" aria-hidden="true">
|
||||||
<span className="shrink-0 text-caption text-ink-4">
|
<span
|
||||||
{e.date
|
className={cn(
|
||||||
? new Date(e.date).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
'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>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -841,7 +870,7 @@ function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition })
|
|||||||
{p.applications.length ? (
|
{p.applications.length ? (
|
||||||
<ol className="space-y-2">
|
<ol className="space-y-2">
|
||||||
{[...p.applications]
|
{[...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)
|
.slice(0, 5)
|
||||||
.map((a) => (
|
.map((a) => (
|
||||||
<li key={a.id} className="flex items-baseline justify-between gap-3 text-body-sm">
|
<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. */
|
/** 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 (
|
return (
|
||||||
<Select value={value} onValueChange={onChange}>
|
<Select value={value} onValueChange={onChange}>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { cn } from '@/lib/utils';
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/** Category to icon. Matched loosely, so a new "Head Chef" still gets the hat. */
|
/** Category to icon. Matched loosely, so a new "Head Chef" still gets the hat. */
|
||||||
|
/** @type {[RegExp, React.ComponentType<any>][]} */
|
||||||
const GLYPHS = [
|
const GLYPHS = [
|
||||||
[/chef|cook|kitchen/i, ChefHat],
|
[/chef|cook|kitchen/i, ChefHat],
|
||||||
[/bartend|bar|mixolog/i, Wine],
|
[/bartend|bar|mixolog/i, Wine],
|
||||||
@@ -41,7 +42,8 @@ const SIZES = {
|
|||||||
lg: { box: 'h-14 w-14 rounded-xl', icon: 'h-6 w-6' },
|
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 Icon = iconForRole(category, title);
|
||||||
const s = SIZES[size] || SIZES.md;
|
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