diff --git a/src/App.jsx b/src/App.jsx index b298517..dcbc3a7 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -40,6 +40,11 @@ import AdminTalentPool from '@/pages/admin/TalentPool'; import AdminAnalytics from '@/pages/admin/Analytics'; import AdminActivity from '@/pages/admin/Activity'; import AdminProfile from '@/pages/admin/Profile'; +import AdminSettings from '@/pages/admin/Settings'; +import AdminWorkspace from '@/pages/admin/Workspace'; +import AdminWorkspaceSkills from '@/pages/admin/WorkspaceSkills'; +import AdminSkillEditor from '@/pages/admin/SkillEditor'; +import AdminSkillDevelopment from '@/pages/admin/SkillDevelopment'; import AdminLogin from '@/pages/admin/Login'; import AdminRoute from '@/pages/admin/AdminRoute'; @@ -97,6 +102,17 @@ const AuthenticatedApp = () => { } /> } /> } /> + {/* Two centres, deliberately separate. Settings configures the + account and the system; Workspace & Skills configures the + workforce — Owliver's capabilities, training and progression — + each with its own pages underneath rather than one page holding + both subjects. */} + } /> + } /> + } /> + } /> + } /> + } /> diff --git a/src/api/seed.js b/src/api/seed.js index 484137d..07a41b3 100644 --- a/src/api/seed.js +++ b/src/api/seed.js @@ -35,6 +35,10 @@ const CERTIFICATIONS = [ const JOB_POSTINGS = [ { id: 'job_senior_server', + company: 'Fairmont San Jose', + custom_requirements: 'Fine dining or banquet service at senior level — recover a table before the client notices there was anything to recover.', + leadership_expectations: 'Set the pace for the servers on your floor', + attendance_expectations: 'Available evenings and weekends', title: 'Senior Server – Fine Dining', role_category: 'Server', description: @@ -75,6 +79,9 @@ const JOB_POSTINGS = [ }, { id: 'job_bartender', + company: 'Bay Event Staffing', + custom_requirements: 'Own your bar from setup to breakdown at high-volume private events.', + physical_requirements: 'Comfortable standing for a full shift', title: 'Bartender', role_category: 'Bartender', skill_requirements: [ @@ -109,6 +116,9 @@ const JOB_POSTINGS = [ }, { id: 'job_server_fine', + company: 'Valley Catering Co.', + custom_requirements: 'Tray service and synchronized coursing experience for seated events.', + attendance_expectations: 'Available evenings and weekends', skill_requirements: [ { skill_id: 'server', level: 'advanced', weight: 45 }, { skill_id: 'customer_service', level: 'intermediate', weight: 30 }, @@ -144,6 +154,9 @@ const JOB_POSTINGS = [ }, { id: 'job_security', + company: 'Legendary Event Staff', + custom_requirements: 'Valid California Guard Card on file before the first shift.', + physical_requirements: 'Able to stand and patrol for extended periods', title: 'Event Security Officer', role_category: 'Security', description: @@ -174,6 +187,9 @@ const JOB_POSTINGS = [ }, { id: 'job_chef', + company: 'Valley Catering Co.', + custom_requirements: 'Offsite and high-volume catering experience, with food cost ownership.', + leadership_expectations: 'Lead and schedule a rotating kitchen brigade', skill_requirements: [ { skill_id: 'food_safety', level: 'advanced', weight: 40 }, { skill_id: 'leadership', level: 'advanced', weight: 35 }, @@ -209,6 +225,9 @@ const JOB_POSTINGS = [ }, { id: 'job_bartender_corp', + company: 'Bay Event Staffing', + custom_requirements: 'Corporate or executive event experience, with batched cocktail programs.', + leadership_expectations: 'Run a bar independently at corporate functions', skill_requirements: [ { skill_id: 'bartending', level: 'advanced', weight: 50 }, { skill_id: 'customer_service', level: 'intermediate', weight: 30 }, @@ -244,6 +263,10 @@ const JOB_POSTINGS = [ }, { id: 'job_picker', + company: 'Oakland Event Logistics', + custom_requirements: 'Accuracy first — every mispick becomes a missing item at an event.', + physical_requirements: 'Able to lift 50 lbs repeatedly', + attendance_expectations: 'Reliable attendance for early shifts', title: 'Picker', role_category: 'Picker', description: @@ -274,6 +297,10 @@ const JOB_POSTINGS = [ }, { id: 'job_banquet', + company: 'Fairmont San Jose', + custom_requirements: 'Hotel banquet background and BEO fluency.', + leadership_expectations: 'Direct a floor team you have often just met that afternoon', + attendance_expectations: 'Available for early mornings and late nights', skill_requirements: [ { skill_id: 'server', level: 'advanced', weight: 35 }, { skill_id: 'leadership', level: 'intermediate', weight: 35 }, diff --git a/src/api/store.js b/src/api/store.js index 9cdf4fd..3010c0f 100644 --- a/src/api/store.js +++ b/src/api/store.js @@ -9,7 +9,7 @@ */ const STORAGE_KEY = 'krow_demo_db'; -const STORAGE_VERSION = 6; +const STORAGE_VERSION = 8; /** Simulated network latency, in ms, so loading states are real. */ const LATENCY = { read: 140, write: 220 }; diff --git a/src/components/ai-assistant/ResponseBlocks.jsx b/src/components/ai-assistant/ResponseBlocks.jsx index 01d3f7a..b55e60a 100644 --- a/src/components/ai-assistant/ResponseBlocks.jsx +++ b/src/components/ai-assistant/ResponseBlocks.jsx @@ -1,6 +1,8 @@ import * as React from 'react'; import { Link } from 'react-router-dom'; -import { ArrowDownRight, ArrowUpRight, Check, ChevronRight, TriangleAlert } from 'lucide-react'; +import { + ArrowDownRight, ArrowRight, ArrowUpRight, Check, ChevronRight, TriangleAlert, +} from 'lucide-react'; import { cn } from '@/lib/utils'; import { Badge } from '@/components/ui/badge'; import { ProgressBar, toneForScore } from '@/components/ds/Progress'; @@ -280,11 +282,17 @@ ListBlock.displayName = 'ListBlock'; * position they were working on. This is the default for a * recommended person: inspecting a candidate is part of the * conversation, not a trip to another page. - * `to` leaves for a route the app already has. Reserved for the explicit - * "open the full record" case. + * `to` leaves for a route the app already has, with the whole card as the + * target. Reserved for items whose only meaning is "open this". + * `action` a named link inside the card — `{ label, to }`. For an item that + * has *both* a conversation and a record: the card keeps asking + * Owliver, and the link is the one explicit way to leave. Reading a + * card can then never navigate by accident, which matters most where + * the body is an explanation worth reading. * * `hint` is for an item that can do neither — it says why, instead of looking - * clickable and doing nothing. + * clickable and doing nothing. An item may carry a hint *and* an action: the + * hint explains the record, the action opens it. */ const InsightsBlock = React.memo(({ block, onPrompt }) => (
@@ -298,7 +306,8 @@ const InsightsBlock = React.memo(({ block, onPrompt }) => ( 'transition-colors hover:border-krow-blue/50', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50' ); - const actionable = Boolean(item.prompt && onPrompt) || Boolean(item.to); + const asksOwliver = Boolean(item.prompt && onPrompt); + const actionable = asksOwliver || Boolean(item.to); const body = ( <> @@ -315,7 +324,37 @@ const InsightsBlock = React.memo(({ block, onPrompt }) => ( ); - if (item.prompt && onPrompt) { + /* A named link cannot live inside a button, so when an item has both, the + card is a plain container: the readable area is its own button and the + action sits beside it. One click target each, never nested. */ + if (item.action?.to) { + return ( +
+ {asksOwliver ? ( + + ) : body} + +
+ + {item.action.label} +
+
+ ); + } + + if (asksOwliver) { return ( + {onAction && canTakeAction && (
+ {/* The client this role is being staffed for, when the record says + so. Above the title, never in place of it. */} + {posting.company && ( +

{posting.company}

+ )}

{posting.title}

{posting.role_category}
@@ -49,8 +55,8 @@ export default function KROWJobPostingCard({ posting, stats }) { {posting.location && ( {posting.location} )} - {posting.pay_range_min != null && posting.pay_range_max != null && ( - ${posting.pay_range_min}–${posting.pay_range_max}/hr + {payLabel(posting) && ( + {payLabel(posting)} )} diff --git a/src/components/krow/MatchedCandidates.jsx b/src/components/krow/MatchedCandidates.jsx index 9071295..cc0dc85 100644 --- a/src/components/krow/MatchedCandidates.jsx +++ b/src/components/krow/MatchedCandidates.jsx @@ -2,8 +2,10 @@ import React, { useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { ArrowRight, Check, GraduationCap, Minus, Sparkles, X } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { LEVEL_LABEL, matchPosition, rankWorkforce, skillName } from '@/lib/skillGraph'; +import { LEVEL_LABEL, matchPosition, skillName } from '@/lib/skillGraph'; +import { poolFor } from '@/lib/workforce'; import { usePageSkills } from '@/lib/skills/usePageSkills'; +import { useApplications, useAssignments } from '@/lib/krowHooks'; /** * Who is qualified for this position, and who is one module away. @@ -51,8 +53,11 @@ function RequirementRow({ line }) { function CandidateRow({ row, job, onView, onInterview, busy, attaches }) { const [open, setOpen] = useState(false); - const { profile, match, application } = row; - const tone = TONE[match.tone] || TONE.warning; + /* Identity is the candidate record; the worker profile is enrichment that + most candidates do not have. Nothing here reads a profile field without + checking, and nothing substitutes for one when it is absent. */ + const { candidate, profile, match, application } = row; + const tone = (match && TONE[match.tone]) || TONE.warning; /* The training prompt is shown only when Forge attached that path to Positions. The score, the requirement rows and the gaps are unaffected — @@ -66,34 +71,51 @@ function CandidateRow({ row, job, onView, onInterview, busy, attaches }) { ? new Date(application.created_date).toDateString() === new Date().toDateString() ? 'Applied today' : `Applied ${new Date(application.created_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}` - : 'Not applied — eligible from verified skills'; + : candidate.job_title + ? `Applied to ${candidate.job_title}` + : 'On file as a candidate'; return (
- {profile.selfie_url - ? - :
{profile.full_name?.[0]}
} + {candidate.selfie_url + ? + :
{candidate.applicant_name?.[0]}
}
-

{profile.full_name}

+

{candidate.applicant_name}

- {profile.current_position || profile.desired_position || 'Worker'} · {applied} + {profile?.current_position || profile?.desired_position || candidate.job_title || 'Candidate'} · {applied}

-
+ {/* The score, and what it rests on. A requirement fit read from an + application is not the same claim as a verified skill match, so the + chip says which rather than letting the weaker one pass as the + stronger. */} +
- {match.score}% · {match.band} + {match ? `${match.score}% · ${match.band}` : 'Not scored'} + + + {row.basis === 'verified' ? 'Verified skills' + : row.basis === 'stated' ? 'From application' + : 'Insufficient data'}
{/* The score, as the requirements that produced it. */} -
    - {match.lines.map((line) => )} -
+ {match ? ( +
    + {match.lines.map((line) => )} +
+ ) : ( +

+ This position states no requirements that can be checked against this candidate's record. +

+ )} {/* The gap and the training that closes it — the Forge loop, stated on the recruiter's page so "not ready" comes with what to do about it. */} @@ -102,7 +124,7 @@ function CandidateRow({ row, job, onView, onInterview, busy, attaches }) {
@@ -144,13 +166,13 @@ function CandidateRow({ row, job, onView, onInterview, busy, attaches }) { {job.title} — weighted requirements

    - {match.lines.map((line) => ( + {(match?.lines || []).map((line) => (
  • - {line.name} — {LEVEL_LABEL[line.required]} required, holds {line.heldLabel} + {line.name} — {LEVEL_LABEL[line.required] || line.requiredLabel} required, holds {line.heldLabel} - {line.earned} of {line.weight} + {line.weight != null ? `${line.earned} of ${line.weight}` : line.met ? 'Met' : 'Not met'}
  • ))} @@ -174,39 +196,52 @@ export function MatchedCandidates({ job, profiles = [], courses = [], applicatio const navigate = useNavigate(); const [showAll, setShowAll] = useState(false); const positionSkills = usePageSkills('positions'); + /* The Candidates dataset in full. The `applications` prop is scoped to this + position, which is the right input for its pipeline counts and the wrong + one for asking "is this person a candidate at all" — somebody who applied + to a different role is still on the Candidates page. Same hook that page + uses, so there is one dataset behind both answers. */ + const { data: everyApplication = [] } = useApplications(); + /* Availability needs the roster; without it the pool would report people as + free who are already committed. */ + const { data: assignments = [] } = useAssignments(); + /** + * The candidates for this position — the same pool Owliver ranks. + * + * `poolFor` starts from the Candidates dataset and enriches each person with + * a worker profile where one exists, so this list and the assistant's list + * are the same people with the same scores. Reading the workforce directly + * here would put a second, disagreeing population on the same page. + */ const ranked = useMemo( - () => rankWorkforce(job, profiles, courses, applications), - [job, profiles, courses, applications] + () => poolFor(job, { profiles, applications: everyApplication, assignments, courses }), + [job, profiles, everyApplication, assignments, courses] ); /* Position-level counts, from the same scores the rows show. Internal: these are hiring metrics, and they stay on this page. */ const counts = useMemo(() => { const jobApps = applications.filter((a) => a.job_posting_id === job?.id); - const scoredApplicants = ranked.filter((r) => r.application); + /* "Strong" means verified: an application stating it holds a certification + is useful, and it is not the same evidence as completed training. The two + are counted separately rather than added together. */ + const verified = ranked.filter((r) => r.basis === 'verified'); return { + candidates: ranked.length, + verified: verified.length, applicants: jobApps.length, - strong: scoredApplicants.filter((r) => r.match.score >= 75).length, - potential: scoredApplicants.filter((r) => r.match.score >= 50 && r.match.score < 75).length, + strong: verified.filter((r) => r.match.score >= 75).length, + potential: verified.filter((r) => r.match.score >= 50 && r.match.score < 75).length, interview: jobApps.filter((a) => a.status === 'interview').length, hired: jobApps.filter((a) => a.status === 'hired').length, - eligible: ranked.filter((r) => r.match.score >= 75).length, }; }, [ranked, applications, job]); - if (!job?.skill_requirements?.length) { - return ( -
    -

    Skill matching not configured

    -

    - This position has no required skills, so candidates cannot be scored against verified - training. Add them by editing the position — every requirement is a skill, a level and a - weight. -

    -
    - ); - } + /* A position with no skill requirements can still be matched — on the + requirements it does state — so this no longer replaces the whole section. + It is said once, above the list, and the list still renders. */ + const hasSkillRequirements = Boolean(job?.skill_requirements?.length); const shown = showAll ? ranked : ranked.slice(0, 5); @@ -216,14 +251,17 @@ export function MatchedCandidates({ job, profiles = [], courses = [], applicatio

    Matched candidates

    - {counts.eligible} {counts.eligible === 1 ? 'person meets' : 'people meet'} this position at - 75% or better, scored against verified skills. Best match first. + {counts.candidates} {counts.candidates === 1 ? 'candidate' : 'candidates'} on file + {counts.verified > 0 + ? `, ${counts.verified} scored against verified skills` + : ', scored against the requirements this position states'}. Best match first.

      {[ ['Applicants', counts.applicants], + ['Verified', counts.verified], ['Strong matches', counts.strong], ['Potential', counts.potential], ['Interview', counts.interview], @@ -237,6 +275,14 @@ export function MatchedCandidates({ job, profiles = [], courses = [], applicatio
{/* What the position asks for, so the scores below have a stated basis. */} + {!hasSkillRequirements && ( +

+ This position defines no verified skill requirements, so candidates are scored against the + experience, English level and certifications it states. Add skill requirements by editing + the position to score against verified training. +

+ )} + {hasSkillRequirements && (
+ )}
{shown.map((row) => ( navigate( - r.application ? `/admin/candidates/${r.application.id}` : `/admin/talent-pool?search=${encodeURIComponent(r.profile.full_name)}` - )} + /* Every row here now carries a candidate record, so this always + opens the existing candidate profile by id. */ + onView={(r) => navigate(`/admin/candidates/${r.candidate.id}`)} onInterview={onInterview} attaches={positionSkills.attaches} /> diff --git a/src/components/krow/PositionDetails.jsx b/src/components/krow/PositionDetails.jsx new file mode 100644 index 0000000..f27eb6a --- /dev/null +++ b/src/components/krow/PositionDetails.jsx @@ -0,0 +1,156 @@ +import React from 'react'; +import { cn } from '@/lib/utils'; +import { + customRequirementsText, englishLabel, experienceLabel, rateLabel, statedRequirements, +} from '@/lib/positionModel'; + +/** + * What a position actually says about itself. + * + * The Create Position form asks twelve questions. This is where all twelve + * answers are read back — every one of them, on both detail surfaces, in the + * order the form asks them. A field an employer filled in and cannot find + * afterwards may as well not have been asked for. + * + * Three rules hold the section together: + * + * - **Universal fields always appear.** Client, title, category, location, pay, + * experience and English are questions every position answers, so a blank one + * reads "Not specified" — the reader can tell "left out" from "not shown". + * Nothing renders `undefined`, `null`, `N/A` or `NaN`. + * - **Role-specific requirements appear only when stated.** What a role demands + * physically, of a leader, or of attendance is not a universal question; a + * server role that never asked for lifting should not be shown carrying a + * lifting requirement, empty or otherwise. The section collapses to what the + * position actually says, and disappears entirely when it says nothing. + * - **Pay is two fields, because the form asks for two.** The compact + * `$24–$34/hr` range still leads the header, but Pay Min and Pay Max are + * stated separately here, as they were entered. + * + * Everything is read from the position record passed in — nothing is defaulted, + * inferred or invented. The drawer and the full page render these same + * components, so the two cannot drift apart. + */ + +/** How an unanswered field reads. Subtle, and never a stand-in for real data. */ +const NOT_SPECIFIED = 'Not specified'; + +/** One label/value pair. A `value` of `null` means the record states nothing. */ +function InfoCell({ label, value }) { + const specified = Boolean(value); + return ( +
+
{label}
+
+ {specified ? value : NOT_SPECIFIED} +
+
+ ); +} + +/** + * Role information — the eight single-value fields, as a grid. + * + * Two columns from `sm` and three from `lg`, which is what the widened drawer + * was widened for: eight short values in one column is a column of whitespace + * beside a very long scroll. + */ +export function PositionOverview({ position, className = '' }) { + /* Ordered by what each field answers, three to a row on desktop so the tiers + read as rows: who and what the role is, then where and for how much, then + what is asked of the person. */ + const rows = [ + { label: 'Client / Company', value: position.company || null }, + { label: 'Job Title', value: position.title || null }, + { label: 'Role Category', value: position.role_category || null }, + { label: 'Location', value: position.location || null }, + { label: 'Pay Min', value: rateLabel(position.pay_range_min) }, + { label: 'Pay Max', value: rateLabel(position.pay_range_max) }, + { label: 'Min. Experience', value: experienceLabel(position) }, + { label: 'English Level', value: englishLabel(position.english_required) }, + ]; + + return ( +
+ {rows.map((row) => )} +
+ ); +} + +/** + * What this particular role asks of a person — and only that. + * + * The grid is filled by whatever the position states, so a security role shows + * a physical requirement, a chef role shows a leadership one, and neither is + * padded out to match the other. Returns `null` when the position states none, + * which is what lets a caller drop the heading with it. + * + * Two columns on desktop, one on mobile; the cards reflow, so two requirements + * sit side by side rather than beside a gap where a third would have gone. + */ +export function PositionRequirements({ position, className = '' }) { + const fields = statedRequirements(position); + const certifications = position.certifications_required || []; + + if (!fields.length && !certifications.length) return null; + + return ( +
+ {fields.map((field) => ( +
+

{field.label}

+

{field.value}

+
+ ))} + + {/* Certifications are a requirement too, and they are chips rather than + prose — so they sit with the expectations rather than in the grid of + single values above. Shown only when the position asks for any. */} + {certifications.length > 0 && ( +
+

Required Certifications

+
+ {certifications.map((cert) => ( + + {cert} + + ))} +
+
+ )} +
+ ); +} + +/** + * Custom requirements — the one field that is a paragraph. + * + * Full width, wrapping naturally, never truncated and never replaced with a + * pointer to somewhere else: this is the field an employer uses to say the + * thing no other field covers, and it is usually the most specific sentence on + * the record. Absent when the employer wrote nothing, like every other + * role-specific field. + */ +export function PositionCustomRequirements({ position, className = '' }) { + const text = customRequirementsText(position); + if (!text) return null; + + return ( +
+

{text}

+
+ ); +} diff --git a/src/components/skills/AddSkillDialog.jsx b/src/components/skills/AddSkillDialog.jsx index 43bb74a..6270f1e 100644 --- a/src/components/skills/AddSkillDialog.jsx +++ b/src/components/skills/AddSkillDialog.jsx @@ -2,7 +2,8 @@ import React from 'react'; import { Sparkles, Upload } from 'lucide-react'; import { Button, Field, Input, Modal, Textarea, toast } from '@/components/ds'; import { usePreferences, useUpdatePreferences } from '@/lib/krowHooks'; -import { PAGE_KEYS, parseSkill, validateSkillSource } from '@/lib/skills/registry'; +import { PAGE_KEYS, validateSkillSource } from '@/lib/skills/registry'; +import { skillTemplate, upsertCustomSkill } from '@/lib/skills/customSkills'; /** * Add an Owliver skill. @@ -20,29 +21,6 @@ import { PAGE_KEYS, parseSkill, validateSkillSource } from '@/lib/skills/registr * then edit directly. The Markdown is always what gets saved. */ -const TEMPLATE = ({ id, name, description, pages }) => `--- -id: ${id || 'my-skill'} -name: ${name || 'My Skill'} -description: ${description || 'What this skill helps Owliver do.'} -pages: -${(pages || ['positions']).map((p) => ` - ${p}`).join('\n')} -status: active -triggers: - - ${(name || 'my skill').toLowerCase()} ---- - -# ${name || 'My Skill'} - -## Purpose - -Describe what Owliver should help with on these pages. - -## Capabilities - -- Describe one thing the skill can do. -- Add more as needed. -`; - export function AddSkillDialog({ open, onOpenChange }) { const preferences = usePreferences(); const updatePreferences = useUpdatePreferences(); @@ -68,7 +46,7 @@ export function AddSkillDialog({ open, onOpenChange }) { const syncFromMeta = (next) => { setMeta(next); if (touched) return; - setSource(TEMPLATE({ + setSource(skillTemplate({ id: next.id, name: next.name, description: next.description, @@ -96,12 +74,7 @@ export function AddSkillDialog({ open, onOpenChange }) { return; } - const skill = parseSkill(source, { custom: true }); - const existing = preferences.customSkills || []; - const next = [ - ...existing.filter((entry) => parseSkill(entry.raw, { custom: true }).id !== skill.id), - { path: `custom/${skill.id}.md`, raw: source }, - ]; + const { skill, next } = upsertCustomSkill(preferences.customSkills || [], source); updatePreferences.mutate({ customSkills: next }); toast.success(`${skill.name} added`); diff --git a/src/layouts/AdminLayout.jsx b/src/layouts/AdminLayout.jsx index c2ae281..5dd8839 100644 --- a/src/layouts/AdminLayout.jsx +++ b/src/layouts/AdminLayout.jsx @@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom'; import { motion } from 'framer-motion'; import { - Bell, ChevronDown, LogOut, Menu, Search, Shield, User as UserIcon, Users, + Bell, ChevronDown, LayoutGrid, LogOut, Menu, Search, Settings, Shield, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { base44 } from '@/api/base44Client'; @@ -248,12 +248,19 @@ export default function AdminLayout() { Platform Administrator - navigate('/admin/profile')} className="cursor-pointer"> - Profile + {/* Account configuration, then the one thing people come to + the account menu for by name, then the workforce workspace. + Profile is not dropped — it is the first entry inside + Settings, where account information belongs. */} + navigate('/admin/settings')} className="cursor-pointer"> + Settings navigate('/admin/profile#security')} className="cursor-pointer"> Security + navigate('/admin/workspace')} className="cursor-pointer"> + Workspace & Skills + ))}
- setMenuOpen(false)} - className="rounded-lg px-3 py-2.5 text-body-sm font-medium text-ink-2 hover:bg-surface-sunken" - > - Profile - + {[ + { label: 'Settings', path: '/admin/settings' }, + { label: 'Security', path: '/admin/profile#security' }, + { label: 'Workspace & Skills', path: '/admin/workspace' }, + ].map((item) => ( + setMenuOpen(false)} + className="rounded-lg px-3 py-2.5 text-body-sm font-medium text-ink-2 hover:bg-surface-sunken" + > + {item.label} + + ))} diff --git a/src/lib/positionModel.js b/src/lib/positionModel.js index e9b8303..a8ecada 100644 --- a/src/lib/positionModel.js +++ b/src/lib/positionModel.js @@ -173,3 +173,92 @@ export function payLabel({ pay_range_min: min, pay_range_max: max } = {}) { if (!high || high === low) return `$${low}/hr`; return `$${low}–$${high}/hr`; } + +/** `fluent` → `Fluent`. Returns null for a level the record does not state. */ +export function englishLabel(value) { + if (!value) return null; + return ENGLISH_LEVELS.find((l) => l.value === value)?.label + ?? String(value).replace(/\b\w/, (c) => c.toUpperCase()); +} + +/** + * "0 years", "1 year", "3 years" — the number the employer actually entered. + * + * Zero is an answer, not an absence: a position open to people with no + * experience says so, and reading it back as "No minimum" would be this code + * rewording the employer rather than reporting them. `null` is returned only + * when the field carries nothing at all. + */ +export function experienceLabel(position = {}) { + const years = position.min_experience_years; + if (years === undefined || years === null || years === '') return null; + const n = Number(years); + if (!Number.isFinite(n)) return null; + return `${n} ${n === 1 ? 'year' : 'years'}`; +} + +/** A single rate as its own value: `$24/hr`, or null when it was not set. */ +export function rateLabel(value) { + if (value === undefined || value === null || value === '') return null; + const n = Number(value); + if (!Number.isFinite(n) || n <= 0) return null; + return `$${n}/hr`; +} + +/** + * The prose expectations the employer wrote, in the order the form asks for them. + * + * `custom_requirements` is deliberately not in this list: it is a paragraph + * rather than a value, and it gets a block of its own — see + * `PositionCustomRequirements`. + */ +export const REQUIREMENT_FIELDS = [ + { key: 'physical_requirements', label: 'Physical Requirements' }, + { key: 'leadership_expectations', label: 'Leadership Expectations' }, + { key: 'attendance_expectations', label: 'Attendance Expectations' }, +]; + +/** + * The requirements this position actually states. + * + * Only the ones with something in them. A requirement is role-specific by + * nature — a warehouse role states what it needs lifted, a server role does not + * — so a field left blank is not an omission to be reported, it is that role + * saying the question does not apply to it. Rendering an empty "Physical + * requirements" card on every position turns a blank answer into a demand, and + * makes every role look like the same template. + * + * The universal fields (title, category, location, pay, experience, English) + * behave the opposite way and are handled separately: those are questions every + * position answers, so an unanswered one is worth showing as unanswered. + */ +export function statedRequirements(position = {}) { + return REQUIREMENT_FIELDS + .map((field) => ({ ...field, value: String(position[field.key] ?? '').trim() })) + .filter((field) => field.value); +} + +/** The custom requirement text, or `''` when none was written. */ +export const customRequirementsText = (position = {}) => + String(position.custom_requirements ?? '').trim(); + +/** + * Does this position state anything under Requirements at all? + * + * The gate a surface uses before drawing the heading — a section title standing + * over nothing is worse than no section. + */ +export const hasRequirements = (position = {}) => + statedRequirements(position).length > 0 || (position.certifications_required || []).length > 0; + +/** + * "Server · San Jose, CA · $24–$34/hr" — the terms of the role in one line. + * + * Company is deliberately absent: it belongs above the title, not beside the + * category. Parts the record does not carry are omitted rather than padded. + */ +export function roleMetaLine(position = {}) { + return [position.role_category, position.location, payLabel(position)] + .filter(Boolean) + .join(' · '); +} diff --git a/src/lib/skills/customSkills.js b/src/lib/skills/customSkills.js new file mode 100644 index 0000000..5718be3 --- /dev/null +++ b/src/lib/skills/customSkills.js @@ -0,0 +1,78 @@ +import { parseSkill } from './registry'; + +/** + * Account-authored skills, as stored. + * + * A custom skill is its Markdown source and nothing else — the same artefact a + * file in `src/skills/` is, read back by the same parser. These helpers exist so + * the Add Skill dialog and the Skills page write that list identically; two + * writers with two shapes would be a second skill system by accident. + */ + +/** The starting definition offered to an author, in the existing format. */ +export const skillTemplate = ({ id = '', name = '', description = '', pages = [] } = {}) => `--- +id: ${id || 'my-skill'} +name: ${name || 'My Skill'} +description: ${description || 'What this skill helps Owliver do.'} +pages: +${(pages?.length ? pages : ['positions']).map((p) => ` - ${p}`).join('\n')} +status: active +triggers: + - ${(name || 'my skill').toLowerCase()} +--- + +# ${name || 'My Skill'} + +## Purpose + +Describe what Owliver should help with on these pages. + +## Capabilities + +- Describe one thing the skill can do. +- Add more as needed. +`; + +/** Parses a stored entry, tolerating the bare-string form. */ +const sourceOf = (entry) => (typeof entry === 'string' ? entry : entry?.raw ?? ''); + +/** + * The stored list with `source` added or replaced. + * + * Matching is by skill id, so editing a skill overwrites its own entry rather + * than adding a near-duplicate beside it. + */ +export function upsertCustomSkill(existing = [], source) { + const skill = parseSkill(source, { custom: true }); + const rest = existing.filter((entry) => { + try { + return parseSkill(sourceOf(entry), { custom: true }).id !== skill.id; + } catch { + return true; + } + }); + return { skill, next: [...rest, { path: `custom/${skill.id}.md`, raw: source }] }; +} + +/** The stored list without the skill of this id. */ +export function removeCustomSkill(existing = [], id) { + return existing.filter((entry) => { + try { + return parseSkill(sourceOf(entry), { custom: true }).id !== id; + } catch { + return true; + } + }); +} + +/** The stored Markdown for one custom skill, or null if the account has none. */ +export function customSkillSource(existing = [], id) { + for (const entry of existing) { + try { + if (parseSkill(sourceOf(entry), { custom: true }).id === id) return sourceOf(entry); + } catch { + /* An unparseable entry cannot be the one being edited. */ + } + } + return null; +} diff --git a/src/lib/skills/pageSkills.js b/src/lib/skills/pageSkills.js index 1c9281d..669ab68 100644 --- a/src/lib/skills/pageSkills.js +++ b/src/lib/skills/pageSkills.js @@ -73,6 +73,32 @@ export function skillStatesForPage(pageId, courses = [], profile = null, options .filter(Boolean); } +/** + * Every training path the platform has, paired with one person's state in it. + * + * Page attachment is deliberately not consulted here. `pages:` says which + * *product surfaces* may talk about a path; a workforce management view is + * asking a different question — what training exists at all — and filtering it + * by attachment would quietly hide a path from the one screen whose job is to + * account for every one of them. + * + * Same definitions, same graph, same derivation as the page-scoped version + * above: this is a second question asked of one dataset, not a second dataset. + */ +export function workforceSkillStates(courses = [], profile = null, options = {}) { + const { customSources = [], disabled = [] } = options; + return allSkills(customSources) + .filter((s) => s.kind === 'workforce' && s.status === 'active' && !disabled.includes(s.id)) + .map((definition) => { + if (!definition.skillId) return null; + const state = skillStateFor(definition.skillId, courses, profile); + /* A definition bound to a capability the graph does not carry is an + authoring error; showing it as "Not started" would conceal that. */ + return state.ladder?.length ? { definition, state } : null; + }) + .filter(Boolean); +} + /** * The definition governing one capability, or null. * diff --git a/src/lib/skills/usePageSkills.js b/src/lib/skills/usePageSkills.js index 1be9765..a9cb4d2 100644 --- a/src/lib/skills/usePageSkills.js +++ b/src/lib/skills/usePageSkills.js @@ -1,7 +1,8 @@ import { useMemo } from 'react'; import { usePreferences } from '@/lib/krowHooks'; import { - isSkillOnPage, pageDefinitionForSkillId, skillStatesForPage, workforceSkillsForPage, + isSkillOnPage, pageDefinitionForSkillId, skillStatesForPage, workforceSkillStates, + workforceSkillsForPage, } from './pageSkills'; /** @@ -25,6 +26,33 @@ import { * already exists — a training prompt beside a position requirement, say — where * the page is not listing skills but is about to mention one. */ +/** + * Every training path, for the views that manage training rather than surface it. + * + * The same preferences plumbing as `usePageSkills`, asking `pageSkills.js` the + * other question it answers — so Workspace & Skills and Skill Development count + * paths from the registry rather than keeping a list of their own. + */ +export function useWorkforcePaths() { + const preferences = usePreferences(); + + const customKey = JSON.stringify(preferences.customSkills || []); + const disabledKey = JSON.stringify(preferences.disabledSkills || []); + + return useMemo(() => { + const options = { + customSources: JSON.parse(customKey), + disabled: JSON.parse(disabledKey), + }; + return { + /** The training paths themselves, before anyone's progress is applied. */ + paths: workforceSkillStates([], null, options).map((entry) => entry.definition), + /** Each path paired with one person's state in it. */ + statesFor: (courses, profile) => workforceSkillStates(courses, profile, options), + }; + }, [customKey, disabledKey]); +} + export function usePageSkills(pageId) { const preferences = usePreferences(); diff --git a/src/lib/skills/workforceFlow.js b/src/lib/skills/workforceFlow.js index 7282c62..6e48282 100644 --- a/src/lib/skills/workforceFlow.js +++ b/src/lib/skills/workforceFlow.js @@ -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 { diff --git a/src/lib/workforce.js b/src/lib/workforce.js index a260c78..efd2527 100644 --- a/src/lib/workforce.js +++ b/src/lib/workforce.js @@ -185,78 +185,218 @@ export function durationFitOf(profile, position, { today = new Date() } = {}) { /* ── Candidate and workforce pool ──────────────────────────────────────── */ /** - * Everyone who could do this work, ranked, with the reasoning attached. + * How well a candidate meets the requirements this position actually states. * - * Skill match is the base score; timing adjusts it. A perfect skill match who - * cannot start until after the work begins is not a better answer than a - * slightly weaker one who can, and the ranking says so — but the person is - * still returned, with the reason, because "nobody is free" is something the - * admin needs to see rather than have filtered away. + * The fallback for somebody with no worker profile — which is most candidates. + * It compares fields both records genuinely carry: the minimum experience, + * English level and certifications the position asks for, against the ones the + * application states. Nothing is inferred and nothing is invented; a + * requirement the position does not state is not scored, and an answer the + * application does not give counts as unmet rather than assumed. + * + * Returns `null` when the position states none of these, because then there is + * nothing to measure and a number would be decoration. + */ +const ENGLISH_ORDER = ['basic', 'conversational', 'fluent', 'native']; + +function statedRequirementMatch(position, application) { + const lines = []; + + const required = Number(position?.min_experience_years) || 0; + if (required > 0) { + const held = Number(application?.years_experience) || 0; + lines.push({ + skillId: 'experience', + name: 'Experience', + met: held >= required, + heldLabel: `${held} ${held === 1 ? 'year' : 'years'}`, + requiredLabel: `${required} ${required === 1 ? 'year' : 'years'}`, + }); + } + + const englishNeeded = ENGLISH_ORDER.indexOf(String(position?.english_required || '').toLowerCase()); + if (englishNeeded > 0) { + const heldIndex = ENGLISH_ORDER.indexOf(String(application?.english_level || '').toLowerCase()); + const label = (i) => (i < 0 ? 'Not stated' : ENGLISH_ORDER[i][0].toUpperCase() + ENGLISH_ORDER[i].slice(1)); + lines.push({ + skillId: 'english', + name: 'English', + met: heldIndex >= englishNeeded, + heldLabel: label(heldIndex), + requiredLabel: label(englishNeeded), + }); + } + + const certsNeeded = position?.certifications_required || []; + if (certsNeeded.length) { + const held = new Set((application?.certifications || []).map((c) => String(c).toLowerCase())); + const missing = certsNeeded.filter((c) => !held.has(String(c).toLowerCase())); + const heldList = certsNeeded.filter((c) => held.has(String(c).toLowerCase())); + lines.push({ + skillId: 'certifications', + name: 'Certifications', + met: missing.length === 0, + /* What they hold, not what they are missing — the requirement half of the + line already names what is needed, and saying it twice reads as two + different facts. */ + heldLabel: missing.length === 0 + ? 'All on file' + : heldList.length ? `Holds ${heldList.join(', ')}` : 'None on file', + requiredLabel: certsNeeded.join(', '), + }); + } + + if (!lines.length) return null; + + const met = lines.filter((l) => l.met); + return { + score: Math.round((met.length / lines.length) * 100), + lines, + met, + gaps: lines.filter((l) => !l.met), + band: met.length === lines.length ? 'Meets stated requirements' : 'Partial requirement match', + tone: met.length === lines.length ? 'success' : met.length ? 'warning' : 'risk', + }; +} + +/** + * The candidates for this position, ranked, with the reasoning attached. + * + * ── Where the pool comes from ──────────────────────────────────────────── + * + * It starts from the **candidate records** — the applications the Candidates + * page lists — and enriches each one with a worker profile *if that person has + * one*. Never the other way round. Three concepts stay separate: + * + * candidate a person on the Candidates page. The identity, and the id + * every link uses. + * application that person's application to a role. Present or not. + * worker profile verified skill levels, availability, training. Optional + * supporting intelligence, held by 9 of 24 people here. + * + * Building the pool from worker profiles inverted that: it recommended people + * the Candidates page had never heard of, and — worse — dropped real candidates + * for the crime of not having a training record. A candidate is a candidate + * whether or not the workforce system knows anything else about them. + * + * ── How a row is scored ────────────────────────────────────────────────── + * + * Two bases, and a row says which one it used, because they are not the same + * claim: + * + * `verified` the skill graph, scored on training this person actually + * completed. Timing adjusts it, since somebody who cannot start + * is not the better answer. + * `stated` the position's own stated requirements against what the + * application states. Real fields, weaker evidence. + * + * A candidate the position gives nothing to measure against is still returned, + * marked unscored, rather than being dropped or given an invented number. */ export function poolFor(position, { profiles = [], applications = [], assignments = [], courses = [], staff = [], today = new Date(), } = {}) { - const appliedBy = new Map( - applications - .filter((a) => a.job_posting_id === position?.id) - .map((a) => [String(a.email || '').toLowerCase(), a]) + const profileByEmail = new Map( + profiles.map((p) => [String(p.email || '').toLowerCase(), p]) ); - return profiles - .map((profile) => { - const held = verifiedLevels(courses, profile); - const match = matchPosition(position, held); - if (!match) return null; + /* One row per person, not per application: somebody who applied to three + roles is one candidate. Their record for this position is the application + to it when there is one, otherwise their most recent. */ + const candidates = new Map(); + for (const application of applications) { + const key = String(application.email || '').toLowerCase() || application.id; + const held = candidates.get(key); + const isForThisRole = application.job_posting_id === position?.id; + const heldIsForThisRole = held?.job_posting_id === position?.id; - const availability = availabilityOf(profile, position, { assignments, today }); - const duration = durationFitOf(profile, position, { today }); - const application = appliedBy.get(String(profile.email || '').toLowerCase()) || null; + if (!held + || (isForThisRole && !heldIsForThisRole) + || (isForThisRole === heldIsForThisRole + && new Date(application.created_date) > new Date(held.created_date))) { + candidates.set(key, application); + } + } - /* Timing multiplies the skill score rather than replacing it: the skill - match is still the thing being measured, and the reader can see both - numbers. A person who cannot start loses most of the score but keeps a - visible one, so "strong but unavailable" stays legible. */ - const timingFactor = (availability.available ? 1 : 0.25) * (0.6 + 0.4 * duration.ratio); - const score = Math.round(match.score * timingFactor); + return [...candidates.values()] + .map((candidate) => { + const email = String(candidate.email || '').toLowerCase(); + const profile = profileByEmail.get(email) || null; + const application = candidate.job_posting_id === position?.id ? candidate : null; + + /* Verified skills where the workforce system holds them; the position's + stated requirements where it does not. */ + const verified = profile ? matchPosition(position, verifiedLevels(courses, profile)) : null; + const match = verified || statedRequirementMatch(position, candidate); + const basis = verified ? 'verified' : match ? 'stated' : 'none'; + + /* Availability is a workforce fact. Without a profile it is not known, + and "not known" is said rather than assumed either way. */ + const availability = profile + ? availabilityOf(profile, position, { assignments, today }) + : { available: true, known: false, reason: 'Availability not on file' }; + const duration = profile + ? durationFitOf(profile, position, { today }) + : { covers: true, ratio: 1, note: null }; + + /* Timing multiplies the skill score rather than replacing it — but only + where timing is actually known. A stated-requirement score is not + adjusted by an availability nobody has recorded. */ + const timingFactor = profile + ? (availability.available ? 1 : 0.25) * (0.6 + 0.4 * duration.ratio) + : 1; + const score = match ? Math.round(match.score * timingFactor) : null; const reasons = [ - `${match.score}% skill match against this role's requirements`, - availability.reason, + match + ? basis === 'verified' + ? `${match.score}% skill match against this role's requirements` + : `${match.score}% of this role's stated requirements met` + : 'No requirements on this position to score against', + profile ? availability.reason : null, duration.note, application ? `Applied ${new Date(application.created_date).toLocaleDateString()}` : null, ].filter(Boolean); return { + /* Identity: always the candidate record. */ + candidate, + candidateId: candidate.id, + name: candidate.applicant_name, + email: candidate.email, + /* Enrichment: present only when this person has it. */ profile, - email: profile.email, - name: profile.full_name, + basis, + scored: Boolean(match), match, - skillScore: match.score, + skillScore: match ? match.score : null, score, availability, duration, application, applied: Boolean(application), - /* Ready to be put forward: qualified enough, and actually free. */ - strong: match.score >= 75 && availability.available, + /* Only verified evidence earns "strong". A stated-requirement match is + real information, but it is not proof the person can do the work. */ + strong: basis === 'verified' && match.score >= 75 && availability.available, reasons, - recommendation: recommendTraining(position, profile, courses), + recommendation: profile ? recommendTraining(position, profile, courses) : null, }; }) - .filter(Boolean) /** - * Equally qualified people are common and the score says so honestly — - * everyone who meets every requirement scores the same. Rather than - * manufacture variance to break the tie, the order falls through to things - * that are also true and also matter: who can cover more of the run, who - * has done this longer, and who is more reliable on the record. + * Verified matches first, then stated ones, then candidates with nothing to + * score — so the strongest evidence leads and no real candidate is buried + * under an ordering rule they had no way to satisfy. Ties fall through to + * things that are also true: who can cover more of the run, and who has + * done this longer. */ - .sort((a, b) => - b.score - a.score - || b.duration.ratio - a.duration.ratio - || (b.profile.experience_years || 0) - (a.profile.experience_years || 0) - || (b.profile.reliability_score || 0) - (a.profile.reliability_score || 0) - || String(a.name).localeCompare(String(b.name))); + .sort((a, b) => { + const rank = (row) => (row.basis === 'verified' ? 0 : row.basis === 'stated' ? 1 : 2); + return rank(a) - rank(b) + || (b.score ?? -1) - (a.score ?? -1) + || b.duration.ratio - a.duration.ratio + || (b.candidate.years_experience || 0) - (a.candidate.years_experience || 0) + || String(a.name).localeCompare(String(b.name)); + }); } /** @@ -269,18 +409,11 @@ export function workforceStatusFor(position, context = {}) { const today = context.today || new Date(); /** - * Applicants are counted from the application records, not from the matched - * pool. + * Applicants are counted from the application records for *this* position; + * the pool is every candidate, whichever role they applied to. * - * These are two different populations and conflating them under-reports the - * real one. Matching needs a worker profile — that is where verified skill - * levels live — but most applicants are external people who have applied - * without one. Counting "applied" off the pool would report a position with - * four applications as having none, because none of the four happened to - * carry a profile. - * - * So: applications answer "who applied", the pool answers "who can do the - * work", and each row is paired with its pool entry when one exists. + * Two different questions — "who applied here" and "who could do this work" — + * so each row is paired with its pool entry rather than the two being merged. */ const byEmail = new Map(pool.map((row) => [String(row.email || '').toLowerCase(), row])); @@ -303,8 +436,15 @@ export function workforceStatusFor(position, context = {}) { position, demand, pool, - /* Existing workforce free for this work and not already on it. */ - existingAvailable: pool.filter((p) => p.availability.available && !p.applied), + /* Free for this work and not already on it. Availability is a workforce + fact, so this counts only people it is actually known for — a candidate + with no workforce record is not reported as free. */ + existingAvailable: pool.filter( + (p) => p.availability.known !== false && p.availability.available && !p.applied + ), + /* Candidates whose score rests on completed training rather than on what + their application states. Reported so a surface can say which. */ + verified: pool.filter((p) => p.basis === 'verified'), applicants, newToday, strong, diff --git a/src/pages/CreatePosition.jsx b/src/pages/CreatePosition.jsx index 966e3f6..72ba23a 100644 --- a/src/pages/CreatePosition.jsx +++ b/src/pages/CreatePosition.jsx @@ -89,6 +89,7 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false, const payload = { ...form, status, + company: String(form.company || '').trim(), pay_range_min: Number(form.pay_range_min) || 0, pay_range_max: Number(form.pay_range_max) || 0, ...(aiResult ? { description: aiResult.description, responsibilities: aiResult.responsibilities, qualifications: aiResult.qualifications, nice_to_haves: aiResult.nice_to_haves, ai_generated: true } : {}), @@ -154,6 +155,20 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
+ {/* The client this role is being staffed for. Stored on the position + record (`company`), which is what every surface reads to show who the + hire is actually for — the field existed in the model and had no way + in from this form until now. */} +
+ + update('company', e.target.value)} + placeholder="e.g. Fairmont San Jose" + className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] bg-white px-3 text-[13px] focus:border-[#0838E0] focus:outline-none" + /> +
+ {/* Add new category */}
setNewCategory(e.target.value)} placeholder="Create a new role category (saved for reuse)..." className="flex-1 h-10 rounded-lg border border-[#E5E7EB] px-3 text-[13px] focus:border-[#0838E0] focus:outline-none" /> @@ -180,18 +195,31 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,