update position page design
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user