position ui and owliver update
This commit is contained in:
@@ -140,13 +140,24 @@ export function resolveCandidate(question, pool = []) {
|
||||
function reasonsFor(row) {
|
||||
const out = [];
|
||||
|
||||
if (!row.match) {
|
||||
return ['This position states no requirements this candidate can be scored against'];
|
||||
}
|
||||
|
||||
for (const line of row.match.met) {
|
||||
out.push(`✓ ${line.name} — ${line.heldLabel}`);
|
||||
}
|
||||
for (const line of row.match.gaps) {
|
||||
out.push(`⚠ ${line.name} — ${line.heldLabel}, needs ${line.requiredLabel}`);
|
||||
}
|
||||
out.push(row.availability.available ? '✓ Available when this starts' : `✕ ${row.availability.reason}`);
|
||||
|
||||
/* Availability is only known for people the workforce system holds a record
|
||||
for. For everyone else it is reported as unknown rather than as free. */
|
||||
if (row.availability.known === false) {
|
||||
out.push('Availability not on file');
|
||||
} else {
|
||||
out.push(row.availability.available ? '✓ Available when this starts' : `✕ ${row.availability.reason}`);
|
||||
}
|
||||
if (row.duration.note) out.push(`⚠ ${row.duration.note}`);
|
||||
|
||||
return out;
|
||||
@@ -155,31 +166,23 @@ function reasonsFor(row) {
|
||||
/**
|
||||
* The record to open for a recommended person.
|
||||
*
|
||||
* Matching reads worker profiles, because that is where verified skill levels
|
||||
* live. The candidate profile is addressed by *application* id, because that is
|
||||
* what the page was built to show. So the link is only real when this person has
|
||||
* an application on file — and when they do not, the card says so rather than
|
||||
* pointing somewhere that is not their record.
|
||||
* One id, decided in one place. `poolFor` resolved this row against the
|
||||
* Candidates dataset before it was ever ranked, so `candidateId` is the id of
|
||||
* an application that genuinely exists — never a name, a rank, or a record
|
||||
* invented to make the link work. A row without one never reaches here, because
|
||||
* a person with no candidate record is not returned as a match at all.
|
||||
*
|
||||
* The position travels with the link so the profile knows what the person was
|
||||
* being considered for.
|
||||
* The route is the existing candidate route, opening the existing full profile.
|
||||
* The position travels with it so the profile knows what the person was being
|
||||
* considered for.
|
||||
*/
|
||||
export function candidateRoute(row, applications = [], position = null) {
|
||||
const email = String(row.email || '').toLowerCase();
|
||||
if (!email) return null;
|
||||
|
||||
const mine = applications.filter((a) => String(a.email || '').toLowerCase() === email);
|
||||
if (!mine.length) return null;
|
||||
|
||||
/* An application for *this* role is the right record to open; failing that,
|
||||
their most recent one, which is still their profile. */
|
||||
const forThis = position && mine.find((a) => a.job_posting_id === position.id);
|
||||
const chosen = forThis
|
||||
|| [...mine].sort((a, b) => new Date(b.created_date) - new Date(a.created_date))[0];
|
||||
const id = row?.candidateId;
|
||||
if (!id) return null;
|
||||
|
||||
return position
|
||||
? `/admin/candidates/${chosen.id}?for=${encodeURIComponent(position.id)}`
|
||||
: `/admin/candidates/${chosen.id}`;
|
||||
? `/admin/candidates/${id}?for=${encodeURIComponent(position.id)}`
|
||||
: `/admin/candidates/${id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -189,15 +192,53 @@ export function candidateRoute(row, applications = [], position = null) {
|
||||
* — met requirements, gaps, and the availability verdict — so a card can never
|
||||
* read as a stronger endorsement than the match actually is.
|
||||
*/
|
||||
const candidateBlock = (row, index, { position = null, inspectable = true } = {}) => insights([{
|
||||
tone: row.strong ? 'success' : row.availability.available ? 'info' : 'warning',
|
||||
title: `${index}. ${row.name} — ${row.score}% match`,
|
||||
body: reasonsFor(row).join(' · '),
|
||||
/* Clicking inspects the person *here*. Opening their full record is offered
|
||||
separately, from the detail view, because leaving the panel loses the
|
||||
position the admin is working on. */
|
||||
prompt: inspectable && position ? `Show ${row.name} for ${position.title}` : null,
|
||||
}]);
|
||||
const candidateBlock = (row, index, { position = null, applications = [], inspectable = true } = {}) => {
|
||||
/**
|
||||
* Two different things a reader wants from a match, kept apart.
|
||||
*
|
||||
* **View Profile** opens that person's record on the Candidates page. It is an
|
||||
* explicit control rather than the whole card, so reading the match reasons
|
||||
* cannot navigate away by accident — and it resolves for everyone, applied or
|
||||
* not, because the profile page reads both kinds of record.
|
||||
*
|
||||
* **The card itself** still asks Owliver about this person for this role, which
|
||||
* is the existing inspect-and-assign conversation, unchanged.
|
||||
*
|
||||
* The hint stays informational: it explains what opening the profile will and
|
||||
* will not show, and is no longer the only way in.
|
||||
*/
|
||||
const to = candidateRoute(row, applications, position);
|
||||
|
||||
/**
|
||||
* The headline says what kind of evidence the number rests on.
|
||||
*
|
||||
* A skill match is scored on training this person completed; a requirement
|
||||
* match is scored on what their application states. Printing both as "match"
|
||||
* would make the weaker claim borrow the authority of the stronger one, and a
|
||||
* candidate with nothing to score says so rather than showing a 0.
|
||||
*/
|
||||
const headline = !row.scored
|
||||
? 'not scored'
|
||||
: row.basis === 'verified'
|
||||
? `${row.score}% match`
|
||||
: `${row.score}% requirement fit`;
|
||||
|
||||
return insights([{
|
||||
tone: row.strong ? 'success' : row.scored ? 'info' : 'neutral',
|
||||
title: `${index}. ${row.name} — ${headline}`,
|
||||
body: reasonsFor(row).join(' · '),
|
||||
prompt: !inspectable || !position ? null : `Show ${row.name} for ${position.title}`,
|
||||
action: to ? { label: 'View Profile', to } : null,
|
||||
/* Two informational states, never a control: what the score rests on, and
|
||||
whether they have applied for this role. The profile opens either way. */
|
||||
hint: [
|
||||
row.basis === 'stated'
|
||||
? 'Scored from their application — no verified training on file.'
|
||||
: !row.scored ? 'Insufficient profile data to score against this role.' : null,
|
||||
row.applied ? null : 'No application for this role yet — assigning them creates one.',
|
||||
].filter(Boolean).join(' ') || null,
|
||||
}]);
|
||||
};
|
||||
|
||||
/**
|
||||
* The people who could do this work, ranked.
|
||||
@@ -213,23 +254,31 @@ export function candidateMatches(position, context) {
|
||||
if (!pool.length) {
|
||||
return {
|
||||
doc: doc(
|
||||
text(`No worker profiles can be scored against **${position.title}** — it defines no skill requirements, or no profiles carry the skills it asks for.`),
|
||||
note('Matching reads verified skill levels from worker profiles. Applicants without a profile appear in the pipeline but cannot be scored.')
|
||||
text(`There are no candidates on file to score against **${position.title}**.`),
|
||||
note('Candidates come from the Candidates page. Once somebody applies — to this role or any other — they can be scored against this position.')
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/* Committed elsewhere is a workforce fact, so it can only exclude somebody the
|
||||
workforce system holds a record for. A candidate with no such record is
|
||||
ranked below the verified ones, never filtered out by an availability
|
||||
nobody has recorded. */
|
||||
const eligible = pool.filter((r) => r.availability.available);
|
||||
const blocked = pool.filter((r) => !r.availability.available);
|
||||
const shown = eligible.slice(0, 5);
|
||||
const verified = pool.filter((r) => r.basis === 'verified').length;
|
||||
|
||||
return {
|
||||
doc: doc(
|
||||
heading(`Best matches for ${position.title}`,
|
||||
`${status.strong.length} strong · ${eligible.length} available · ${pool.length} scored`),
|
||||
`${pool.length} candidate${pool.length === 1 ? '' : 's'} · ${verified} with verified skills · ${status.strong.length} strong`),
|
||||
...(shown.length
|
||||
? shown.map((row, i) => candidateBlock(row, i + 1, { applications: context.applications, position }))
|
||||
: [text('Nobody is currently free for this role.')]),
|
||||
pool.length > shown.length + blocked.length
|
||||
? note(`${pool.length - shown.length - blocked.length} more candidate${pool.length - shown.length - blocked.length === 1 ? '' : 's'} on file — ask for the full list or open Candidates to see them all.`)
|
||||
: null,
|
||||
blocked.length
|
||||
? note(`${blocked.length} other ${blocked.length === 1 ? 'person is' : 'people are'} qualified but committed elsewhere — ask who is available next to see when they free up.`)
|
||||
: null,
|
||||
@@ -250,7 +299,7 @@ export function candidateMatches(position, context) {
|
||||
export function availability(position, context) {
|
||||
const pool = poolFor(position, context);
|
||||
if (!pool.length) {
|
||||
return { doc: doc(text(`No worker profiles can be assessed for **${position.title}**.`)) };
|
||||
return { doc: doc(text(`There are no candidates on file to assess for **${position.title}**.`)) };
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user