update position page design

This commit is contained in:
2026-08-13 21:41:14 +05:30
parent 6340962907
commit 1c653264b5
124 changed files with 5746 additions and 596 deletions

View File

@@ -1,6 +1,14 @@
import { doc, list, note, text } from './blocks';
import { doc, insights, list, note, text } from './blocks';
import { ASSISTANT_CONTEXTS } from './contexts';
import { poolFor } from '@/lib/workforce';
import { matchSkill } from '@/lib/skills/registry';
import {
appliedToday, askWhichPosition, assignmentPreview, assignmentToExecute, availability,
candidateDetail, candidateMatches, candidateRoute, interviewReady, matchWorkforceIntent,
namedAssignmentPreview,
interviewPreview, interviewToExecute,
namedAssignmentToExecute, positionPriority, resolveCandidate, resolvePosition,
} from '@/lib/skills/workforceFlow';
import {
buildSkillPrefill, buildTrainingPrefill, extractReviewSubject,
extractSkillName, findCourseByName,
@@ -168,6 +176,168 @@ export function outOfScopeAnswer(context) {
* { kind: 'navigate', destination, doc } — other page; go there
* { kind: 'outOfScope', doc } — nothing here can answer it
*/
/* ── Workforce ──────────────────────────────────────────────────────────── */
/** The pages that carry enough context to answer a workforce question. */
const WORKFORCE_CONTEXTS = new Set(['admin.positions']);
/**
* A workforce question, resolved against the engine.
*
* Reading questions return their answer directly. The two that lead to a write
* are shaped differently on purpose:
*
* `assign` builds a preview and returns it as a normal answer, with a
* confirmation offered as a follow-up. Nothing is written.
* `confirm_assign` re-derives the plan from live data and hands it back as
* `{ kind: 'workforce', assign }` for `useAssistant` to
* execute through the existing mutation — the same shape the
* position-creation flow already uses.
*
* A question naming no position, on a page listing many, asks which rather than
* choosing one.
*/
function resolveWorkforce(question, workforce) {
const intent = matchWorkforceIntent(question);
if (!intent) return null;
const { positions = [], context = {}, currentPositionId = null } = workforce;
const position = resolvePosition(question, positions, currentPositionId);
/* Board-wide questions answer without a position. */
if (intent === 'priority') {
return { kind: 'answer-doc', ...positionPriority(positions, context) };
}
if (intent === 'applied_today') {
return { kind: 'answer-doc', ...appliedToday(position, context, positions) };
}
if (intent === 'interview_ready') {
return { kind: 'answer-doc', ...interviewReady(position, context, positions) };
}
/* Everything below is about one role, and guessing which is not acceptable. */
if (!position) return { kind: 'answer-doc', ...askWhichPosition(positions) };
if (intent === 'matches') return { kind: 'answer-doc', ...candidateMatches(position, context) };
if (intent === 'availability') return { kind: 'answer-doc', ...availability(position, context) };
/**
* Inspecting, explaining and opening a record are all about one person, so
* they resolve the name first and say so plainly when they cannot.
*/
if (intent === 'candidate_detail' || intent === 'eligibility' || intent === 'open_profile') {
const found = resolveCandidate(question, poolFor(position, context));
if (found.ambiguous) {
return {
kind: 'answer-doc',
doc: doc(text('More than one person matches that name.'), list(found.ambiguous.map((r) => r.name))),
followUp: found.ambiguous.slice(0, 4).map((r) => ({
label: r.name, prompt: `Show ${r.name} for ${position.title}`,
})),
};
}
if (!found.row) {
return { kind: 'answer-doc', ...candidateMatches(position, context) };
}
/* The one case that deliberately leaves the panel — and only because the
admin asked for the full record by name. */
if (intent === 'open_profile') {
const to = candidateRoute(found.row, context.applications || [], position);
return {
kind: 'answer-doc',
doc: to
? doc(
text(`Opening the full record for **${found.row.name}**.`),
insights([{ tone: 'info', title: `${found.row.name} — full profile`, body: 'Match analysis, reputation, experience, endorsements and activity.', to }])
)
: doc(
text(`**${found.row.name}** has no application on file, so there is no candidate record to open.`),
note('Assigning them creates one, and the full profile becomes available after that.')
),
followUp: [{ label: '← Back to candidate', prompt: `Show ${found.row.name} for ${position.title}` }],
};
}
return { kind: 'answer-doc', ...candidateDetail(position, found.row, context) };
}
/**
* A named person is a different request from "the best available": the admin
* has already chosen, and the job is to validate that choice rather than to
* rank. Both preview and confirmation check for a name first, so
* "assign Maria" and "confirm assignment of Maria" stay one conversation.
*/
const named = (intent === 'assign' || intent === 'confirm_assign')
? resolveCandidate(question, poolFor(position, context))
: {};
if (named.ambiguous) {
return {
kind: 'answer-doc',
doc: doc(
text('More than one person matches that name.'),
list(named.ambiguous.map((r) => r.name)),
note('Give me the full name and I will check them against this role.')
),
followUp: named.ambiguous.slice(0, 4).map((r) => ({
label: r.name,
prompt: `Assign ${r.name} to ${position.title}`,
})),
};
}
if (intent === 'assign') {
return named.row
? { kind: 'answer-doc', ...namedAssignmentPreview(position, named.row, context) }
: { kind: 'answer-doc', ...assignmentPreview(position, context) };
}
/**
* Interview setup, for one named person against this role. Both steps resolve
* the name the same way assignment does, so the conversation stays on the same
* candidate without the admin repeating themselves.
*/
if (intent === 'interview_setup' || intent === 'confirm_interview') {
const found = resolveCandidate(question, poolFor(position, context));
if (!found.row) {
return { kind: 'answer-doc', ...interviewReady(position, context, positions) };
}
if (intent === 'interview_setup') {
return { kind: 'answer-doc', ...interviewPreview(position, found.row, context) };
}
const plan = interviewToExecute(position, found.row, context);
if (!plan) {
/* Re-checked at confirmation: if it can no longer proceed, say why rather
than reporting a step that did not happen. */
return { kind: 'answer-doc', ...interviewPreview(position, found.row, context) };
}
return { kind: 'workforce', interview: plan };
}
if (intent === 'confirm_assign') {
/* Re-derived from live data at the moment of confirmation, so the people
written are the people who are still eligible. */
const plan = named.row
? namedAssignmentToExecute(position, named.row, context)
: assignmentToExecute(position, context);
if (!plan) {
/* The preview no longer holds — say so rather than writing something
different from what was agreed to. */
return named.row
? { kind: 'answer-doc', ...namedAssignmentPreview(position, named.row, context) }
: { kind: 'answer-doc', ...assignmentPreview(position, context) };
}
return { kind: 'workforce', assign: plan };
}
return null;
}
/* ── Forge authoring ────────────────────────────────────────────────────── */
/** The five steps the Forge authoring flow actually walks through. */
@@ -368,7 +538,7 @@ function resolveSkill({ question, contextId, disabledSkills, customSkills, roles
export function resolveIntent({
question, contextId, disabledSkills = [], customSkills = [], roles = [], skillCategories = [],
courses = [],
courses = [], workforce = null,
}) {
const context = ASSISTANT_CONTEXTS[contextId] ?? null;
@@ -378,6 +548,18 @@ export function resolveIntent({
});
if (skill) return skill;
/**
* 3. Workforce questions, on the pages that hold workforce context.
*
* Ahead of the page responder because these are the same words it answers
* with analytics — "which position needs people" would otherwise return a
* pipeline report rather than the ranked answer the engine can give.
*/
const workforceIntent = workforce && WORKFORCE_CONTEXTS.has(contextId)
? resolveWorkforce(question, workforce)
: null;
if (workforceIntent) return workforceIntent;
/* The page you are on wins. Several subjects live on more than one page —
departments are on both Analytics and Hired History — and moving someone
off a page that can already answer them is worse than answering. Routing is