position ui and owliver update
This commit is contained in:
@@ -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 }) => (
|
||||
<div className="space-y-2">
|
||||
@@ -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 (
|
||||
<div key={i} className={surface}>
|
||||
{asksOwliver ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPrompt(item.prompt)}
|
||||
className="block w-full rounded-lg text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
{body}
|
||||
</button>
|
||||
) : body}
|
||||
|
||||
<div className="mt-2 flex justify-end">
|
||||
<Link
|
||||
to={item.action.to}
|
||||
className="inline-flex items-center gap-0.5 rounded text-caption font-semibold text-krow-blue transition-colors hover:underline
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
{item.action.label}
|
||||
<ArrowRight className="h-3 w-3" aria-hidden="true" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (asksOwliver) {
|
||||
return (
|
||||
<button key={i} type="button" onClick={() => onPrompt(item.prompt)} className={interactive}>
|
||||
{body}
|
||||
|
||||
@@ -2,11 +2,22 @@ import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
|
||||
/**
|
||||
* Panel widths.
|
||||
*
|
||||
* Each entry states its `max-w` as well as its `w`. The underlying Sheet caps a
|
||||
* side panel at `sm:max-w-sm` (24rem), so a width alone was silently ignored on
|
||||
* desktop and every "large" drawer in the app was drawn at phone width — the
|
||||
* reason detail panels wrapped and scrolled far more than their content needed.
|
||||
*/
|
||||
const WIDTHS = {
|
||||
sm: 'w-full sm:w-80',
|
||||
default: 'w-full sm:w-[28rem]',
|
||||
lg: 'w-full sm:w-[36rem]',
|
||||
xl: 'w-full sm:w-[44rem]',
|
||||
sm: 'w-full sm:w-80 sm:max-w-[20rem]',
|
||||
default: 'w-full sm:w-[28rem] sm:max-w-[28rem]',
|
||||
lg: 'w-full sm:w-[36rem] sm:max-w-[36rem]',
|
||||
/* A record with a two-column body: 704px on desktop, 768px from `2xl`
|
||||
(1536px and up), and never the whole screen — the page behind it stays
|
||||
visible, which is what keeps the drawer a drawer. */
|
||||
xl: 'w-full sm:w-[44rem] sm:max-w-[44rem] 2xl:w-[48rem] 2xl:max-w-[48rem]',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -43,7 +54,7 @@ export function Drawer({
|
||||
)}
|
||||
>
|
||||
{!bare && (
|
||||
<SheetHeader className="px-5 pt-5 pb-4 border-b border-border shrink-0 space-y-0 text-left">
|
||||
<SheetHeader className="px-5 sm:px-6 pt-5 pb-4 border-b border-border shrink-0 space-y-0 text-left">
|
||||
<div className="flex items-start gap-3">
|
||||
{Icon && (
|
||||
<span className="grid place-items-center w-9 h-9 rounded-xl bg-krow-blue-tint text-krow-blue shrink-0">
|
||||
@@ -62,10 +73,10 @@ export function Drawer({
|
||||
</SheetHeader>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-5 py-5">{children}</div>
|
||||
<div className="flex-1 overflow-y-auto px-5 py-5 sm:px-6">{children}</div>
|
||||
|
||||
{footer && (
|
||||
<div className="px-5 py-4 border-t border-border bg-surface-subtle shrink-0 flex items-center justify-end gap-2">
|
||||
<div className="px-5 sm:px-6 py-4 border-t border-border bg-surface-subtle shrink-0 flex items-center justify-end gap-2">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu';
|
||||
import { MessageSquare, Phone, Calendar, X, Trash2, ChevronDown, UserCheck, Briefcase } from 'lucide-react';
|
||||
import { MessageSquare, Phone, Calendar, X, Trash2, ChevronDown, UserCheck, Briefcase, ArrowRight } from 'lucide-react';
|
||||
import { Image } from '@/components/ui/image';
|
||||
import { getScoreBand } from '@/lib/talentHome';
|
||||
import CandidateExpandedDetails from '@/components/krow/CandidateExpandedDetails';
|
||||
@@ -205,6 +205,22 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
{/* The full record, at the route that already serves it.
|
||||
Clicking the row expands a summary in place, which is the right
|
||||
default while comparing people — but the complete profile had no
|
||||
way in from this list at all, so the same candidate opened from a
|
||||
recommendation and from here led to two different depths of the
|
||||
same person. One link, one route, one profile. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/admin/candidates/${application.id}`)}
|
||||
className="hidden sm:inline-flex items-center gap-1 rounded-lg px-2.5 py-1.5 text-[12px] font-semibold text-[#0838E0]
|
||||
transition-colors hover:bg-[#EEF3FE] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0838E0]/40"
|
||||
>
|
||||
View profile
|
||||
<ArrowRight className="w-3 h-3" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
{onAction && canTakeAction && (
|
||||
<button
|
||||
onClick={() => onAction(application)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { payLabel } from '@/lib/positionModel';
|
||||
|
||||
const STATUS_STYLES = {
|
||||
active: 'bg-[#0838E0] text-white',
|
||||
@@ -35,6 +36,11 @@ export default function KROWJobPostingCard({ posting, stats }) {
|
||||
{getCategoryAbbr(posting.role_category)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
{/* The client this role is being staffed for, when the record says
|
||||
so. Above the title, never in place of it. */}
|
||||
{posting.company && (
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-[#0838E0] truncate">{posting.company}</p>
|
||||
)}
|
||||
<h3 className="font-semibold text-[#111827] text-[15px] leading-tight truncate">{posting.title}</h3>
|
||||
<div className="text-[12px] text-[#6B7280] capitalize">{posting.role_category}</div>
|
||||
</div>
|
||||
@@ -49,8 +55,8 @@ export default function KROWJobPostingCard({ posting, stats }) {
|
||||
{posting.location && (
|
||||
<span>{posting.location}</span>
|
||||
)}
|
||||
{posting.pay_range_min != null && posting.pay_range_max != null && (
|
||||
<span className="font-medium text-[#111827]">${posting.pay_range_min}–${posting.pay_range_max}/hr</span>
|
||||
{payLabel(posting) && (
|
||||
<span className="font-medium text-[#111827]">{payLabel(posting)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className="rounded-xl border border-[#E5E7EB] p-4 transition-colors hover:border-[#0838E0]/40">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{profile.selfie_url
|
||||
? <img src={profile.selfie_url} alt="" className="h-10 w-10 shrink-0 rounded-full object-cover" />
|
||||
: <div className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-[#EEF3FE] text-[13px] font-semibold text-[#0838E0]">{profile.full_name?.[0]}</div>}
|
||||
{candidate.selfie_url
|
||||
? <img src={candidate.selfie_url} alt="" className="h-10 w-10 shrink-0 rounded-full object-cover" />
|
||||
: <div className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-[#EEF3FE] text-[13px] font-semibold text-[#0838E0]">{candidate.applicant_name?.[0]}</div>}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-[14px] font-semibold text-[#111827]">{profile.full_name}</p>
|
||||
<p className="truncate text-[14px] font-semibold text-[#111827]">{candidate.applicant_name}</p>
|
||||
<p className="truncate text-[12px] text-[#6B7280]">
|
||||
{profile.current_position || profile.desired_position || 'Worker'} · {applied}
|
||||
{profile?.current_position || profile?.desired_position || candidate.job_title || 'Candidate'} · {applied}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{/* 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. */}
|
||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||
<span className={cn('rounded-full border px-2.5 py-1 text-[12px] font-semibold', tone.chip)}>
|
||||
{match.score}% · {match.band}
|
||||
{match ? `${match.score}% · ${match.band}` : 'Not scored'}
|
||||
</span>
|
||||
<span className="text-[10px] uppercase tracking-wide text-[#9CA3AF]">
|
||||
{row.basis === 'verified' ? 'Verified skills'
|
||||
: row.basis === 'stated' ? 'From application'
|
||||
: 'Insufficient data'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* The score, as the requirements that produced it. */}
|
||||
<ul className="mt-3 space-y-1.5">
|
||||
{match.lines.map((line) => <RequirementRow key={line.skillId} line={line} />)}
|
||||
</ul>
|
||||
{match ? (
|
||||
<ul className="mt-3 space-y-1.5">
|
||||
{match.lines.map((line) => <RequirementRow key={line.skillId} line={line} />)}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="mt-3 text-[12px] text-[#6B7280]">
|
||||
This position states no requirements that can be checked against this candidate's record.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 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 }) {
|
||||
<GraduationCap className="h-3.5 w-3.5 shrink-0 text-[#0838E0]" aria-hidden="true" />
|
||||
<p className="min-w-0 flex-1 text-[12px] text-[#1E3A8A]">
|
||||
<span className="font-semibold">{recommendation.module.title}</span> would take{' '}
|
||||
{profile.full_name.split(' ')[0]} to {recommendation.unlocks} — match{' '}
|
||||
{candidate.applicant_name.split(' ')[0]} to {recommendation.unlocks} — match{' '}
|
||||
<span className="font-semibold tabular-nums">{recommendation.currentScore}% → {recommendation.projectedScore}%</span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -144,13 +166,13 @@ function CandidateRow({ row, job, onView, onInterview, busy, attaches }) {
|
||||
{job.title} — weighted requirements
|
||||
</p>
|
||||
<ul className="space-y-1 text-[12px] text-[#374151]">
|
||||
{match.lines.map((line) => (
|
||||
{(match?.lines || []).map((line) => (
|
||||
<li key={line.skillId} className="flex items-baseline justify-between gap-3">
|
||||
<span className="min-w-0 truncate">
|
||||
{line.name} — {LEVEL_LABEL[line.required]} required, holds {line.heldLabel}
|
||||
{line.name} — {LEVEL_LABEL[line.required] || line.requiredLabel} required, holds {line.heldLabel}
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums font-medium">
|
||||
{line.earned} of {line.weight}
|
||||
{line.weight != null ? `${line.earned} of ${line.weight}` : line.met ? 'Met' : 'Not met'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
@@ -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 (
|
||||
<div className="rounded-2xl border border-dashed border-[#E5E7EB] bg-white p-6">
|
||||
<h3 className="text-[15px] font-semibold text-[#111827]">Skill matching not configured</h3>
|
||||
<p className="mt-1 max-w-lg text-[13px] text-[#6B7280]">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
/* 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
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-[15px] font-semibold text-[#111827]">Matched candidates</h3>
|
||||
<p className="mt-0.5 text-[13px] text-[#6B7280]">
|
||||
{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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className="flex flex-wrap items-center gap-x-4 gap-y-1 text-[12px] text-[#6B7280]">
|
||||
{[
|
||||
['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
|
||||
</div>
|
||||
|
||||
{/* What the position asks for, so the scores below have a stated basis. */}
|
||||
{!hasSkillRequirements && (
|
||||
<p className="mt-4 rounded-lg border border-dashed border-[#E5E7EB] px-3 py-2 text-[12px] text-[#6B7280]">
|
||||
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.
|
||||
</p>
|
||||
)}
|
||||
{hasSkillRequirements && (
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2 rounded-lg bg-[#F9FAFB] px-3 py-2">
|
||||
<Sparkles className="h-3.5 w-3.5 shrink-0 text-[#0838E0]" aria-hidden="true" />
|
||||
<span className="text-[12px] text-[#6B7280]">Requires</span>
|
||||
@@ -247,17 +293,18 @@ export function MatchedCandidates({ job, profiles = [], courses = [], applicatio
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{shown.map((row) => (
|
||||
<CandidateRow
|
||||
key={row.profile.id}
|
||||
key={row.candidateId}
|
||||
row={row}
|
||||
job={job}
|
||||
busy={busy}
|
||||
onView={(r) => 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}
|
||||
/>
|
||||
|
||||
156
src/components/krow/PositionDetails.jsx
Normal file
156
src/components/krow/PositionDetails.jsx
Normal file
@@ -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 (
|
||||
<div className="min-w-0">
|
||||
<dt className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">{label}</dt>
|
||||
<dd
|
||||
className={cn(
|
||||
'mt-0.5 break-words text-body-sm',
|
||||
specified ? 'font-medium text-ink-1' : 'italic text-ink-4'
|
||||
)}
|
||||
>
|
||||
{specified ? value : NOT_SPECIFIED}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<dl
|
||||
className={cn(
|
||||
'grid grid-cols-1 gap-x-6 gap-y-4 rounded-xl border border-border bg-surface-subtle p-4 sm:grid-cols-2 lg:grid-cols-3',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{rows.map((row) => <InfoCell key={row.label} {...row} />)}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className={cn('grid grid-cols-1 gap-3 sm:grid-cols-2', className)}>
|
||||
{fields.map((field) => (
|
||||
<div key={field.key} className="rounded-xl border border-border bg-surface p-3.5">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">{field.label}</p>
|
||||
<p className="mt-1 whitespace-pre-wrap text-body-sm leading-relaxed text-ink-1">{field.value}</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 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 && (
|
||||
<div className="rounded-xl border border-border bg-surface p-3.5 sm:col-span-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Required Certifications</p>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{certifications.map((cert) => (
|
||||
<span
|
||||
key={cert}
|
||||
className="rounded-full border border-krow-blue/20 bg-krow-blue-tint px-2.5 py-0.5 text-[11px] font-medium text-krow-blue"
|
||||
>
|
||||
{cert}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className={cn('rounded-xl border border-border bg-surface p-4', className)}>
|
||||
<p className="whitespace-pre-wrap text-body-sm leading-relaxed text-ink-1">{text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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`);
|
||||
|
||||
Reference in New Issue
Block a user