position ui and owliver update

This commit is contained in:
2026-08-14 01:18:59 +05:30
parent 1c653264b5
commit cccada9bd2
29 changed files with 3718 additions and 1313 deletions

View File

@@ -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}

View File

@@ -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>
)}

View File

@@ -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)}

View File

@@ -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>

View File

@@ -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&apos;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}
/>

View 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>
);
}

View File

@@ -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`);