Update Owliver language and suggestions

This commit is contained in:
2026-10-07 19:15:44 +05:30
parent 8ac4e1ee88
commit 81e5979f8f
156 changed files with 7330 additions and 1755 deletions

View File

@@ -8,8 +8,10 @@ import PipelineFunnel from '@/components/krow/PipelineFunnel';
import ScoreDistribution from '@/components/krow/ScoreDistribution';
import InterviewAnalytics from '@/components/krow/InterviewAnalytics';
import HiringAnalytics from '@/components/krow/HiringAnalytics';
import { useTranslation } from 'react-i18next';
export default function Analytics() {
const { t } = useTranslation();
const { data: applications = [], isLoading: appsLoading } = useApplications();
const { data: interviews = [], isLoading: intLoading } = useInterviews();
const { data: staff = [], isLoading: staffLoading } = useStaff();
@@ -35,8 +37,8 @@ export default function Analytics() {
<BarChart3 className="w-5 h-5 text-white" />
</div>
<div>
<h1 className="text-[22px] font-heading font-bold text-[#111827]">Hiring Analytics</h1>
<p className="text-[13px] text-[#6B7280]">Applicants, pipeline, scores, interviews & hiring insights</p>
<h1 className="text-[22px] font-heading font-bold text-[#111827]">{t('analytics.hiringAnalytics')}</h1>
<p className="text-[13px] text-[#6B7280]">{t('analytics.applicantsPipelineScoresInterviewsHiringInsights')}</p>
</div>
<div className="ml-auto">
</div>
@@ -52,7 +54,7 @@ export default function Analytics() {
</div>
<div>
<h2 className="text-[11px] font-bold text-[#9CA3AF] tracking-wider mb-3 font-heading">INTERVIEW COMPLETION</h2>
<h2 className="text-[11px] font-bold text-[#9CA3AF] tracking-wider mb-3 font-heading">{t('analytics.interviewCompletion')}</h2>
<InterviewAnalytics applications={applications} interviews={interviews} />
</div>

View File

@@ -8,8 +8,10 @@ import ApplyIdentityCard from '@/components/krow/ApplyIdentityCard';
import AIInterviewModal from '@/components/krow/AIInterviewModal';
import { useWorkerProfile } from '@/lib/krowHooks';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
export default function Apply() {
const { t } = useTranslation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const jobId = searchParams.get('job');
@@ -77,15 +79,15 @@ export default function Apply() {
{/* Mobile back link */}
{showForm && (
<button onClick={() => { setShowForm(false); setSelectedJob(null); navigate('/apply', { replace: true }); }} className="text-[13px] text-[#6B7280] hover:text-[#111827] font-medium flex items-center gap-1">
<ArrowLeft className="w-3.5 h-3.5" /> Back to positions
<ArrowLeft className="w-3.5 h-3.5" /> {t('apply.backToPositions')}
</button>
)}
{/* Hero */}
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-6 sm:p-8 text-center">
<h1 className="text-[28px] font-bold text-[#111827] mb-2">Skip the quiz. Just talk.</h1>
<h1 className="text-[28px] font-bold text-[#111827] mb-2">{t('apply.skipTheQuizJustTalk')}</h1>
<p className="text-[14px] text-[#6B7280] max-w-lg mx-auto mb-5">
KROW replaces long forms and tests with a fast 5-minute AI interview. Speak naturally — we evaluate readiness, not memory.
{t('apply.krowReplacesLongFormsAndTests')}
</p>
<div className="flex items-center justify-center gap-2 flex-wrap">
{[
@@ -100,7 +102,7 @@ export default function Apply() {
))}
</div>
<button onClick={() => navigate('/owliver')} className="mt-5 inline-flex items-center gap-2 bg-[#0838E0] hover:bg-[#062BAF] text-white text-[13px] font-semibold px-5 py-2.5 rounded-full transition-all">
Create your KROW Identity <ChevronRight className="w-4 h-4" />
{t('apply.createYourKrowIdentity')} <ChevronRight className="w-4 h-4" />
</button>
</div>
@@ -121,13 +123,13 @@ export default function Apply() {
arithmetic and stays on the admin position page. */}
<YourSkillFit job={selectedJob} held={verifiedLevels(courses, profile || {})} />
<button onClick={() => { setShowForm(false); setSelectedJob(null); navigate('/apply', { replace: true }); }} className="text-[13px] text-[#6B7280] hover:text-[#111827] font-medium flex items-center gap-1">
<ArrowLeft className="w-3.5 h-3.5" /> Back to positions
<ArrowLeft className="w-3.5 h-3.5" /> {t('apply.backToPositions')}
</button>
</div>
) : (
/* Position List */
<div>
<p className="text-[13px] font-medium text-[#374151] mb-3">Choose a position:</p>
<p className="text-[13px] font-medium text-[#374151] mb-3">{t('apply.chooseAPosition')}</p>
<div className="space-y-2">
{isLoading && [...Array(4)].map((_, i) => <div key={i} className="h-16 bg-white border border-[#E5E7EB] rounded-xl animate-pulse" />)}
{openPositions.map(job => (
@@ -146,7 +148,7 @@ export default function Apply() {
</button>
))}
{!isLoading && openPositions.length === 0 && (
<div className="text-center py-12 text-[14px] text-[#6B7280]">No open positions right now. Check back soon!</div>
<div className="text-center py-12 text-[14px] text-[#6B7280]">{t('apply.noOpenPositionsRightNowCheck')}</div>
)}
</div>
</div>

View File

@@ -6,8 +6,10 @@ import AIInterviewModal from '@/components/krow/AIInterviewModal';
import ScheduleInterviewModal from '@/components/krow/ScheduleInterviewModal';
import MessageCandidateModal from '@/components/krow/MessageCandidateModal';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
export default function Candidates() {
const { t } = useTranslation();
const { data: applications = [], isLoading } = useApplications();
const { data: postings = [] } = useJobPostings();
const screenAll = useScreenAllCandidates();
@@ -90,7 +92,7 @@ export default function Candidates() {
<div className="space-y-5">
<div className="flex items-center justify-between">
<div>
<h1 className="text-[22px] font-bold text-[#111827]">All Candidates</h1>
<h1 className="text-[22px] font-bold text-[#111827]">{t('candidates.allCandidates')}</h1>
</div>
<div className="flex items-center gap-3">
<span className="text-[13px] text-[#6B7280]">{applications.length} candidates</span>
@@ -110,26 +112,26 @@ export default function Candidates() {
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search candidates..."
placeholder={t('candidates.searchCandidates')}
className="w-full h-10 pl-4 pr-4 rounded-lg border border-[#E5E7EB] bg-white text-[13px] focus:border-[#0838E0] focus:outline-none"
/>
</div>
<select value={stageFilter} onChange={(e) => setStageFilter(e.target.value)} className="h-10 px-4 rounded-lg border border-[#E5E7EB] bg-white text-[13px] focus:border-[#0838E0] focus:outline-none">
<option value="all">All Stages</option>
<option value="applied">Applied</option>
<option value="ai_screened">AI Screened</option>
<option value="interview">Interviewing</option>
<option value="hired">Hired</option>
<option value="all">{t('candidates.allStages')}</option>
<option value="applied">{t('candidates.applied')}</option>
<option value="ai_screened">{t('candidates.aiScreened')}</option>
<option value="interview">{t('candidates.interviewing')}</option>
<option value="hired">{t('candidates.hired')}</option>
</select>
<select value={positionFilter} onChange={(e) => setPositionFilter(e.target.value)} className="h-10 px-4 rounded-lg border border-[#E5E7EB] bg-white text-[13px] focus:border-[#0838E0] focus:outline-none max-w-[200px] truncate">
<option value="all">All Positions</option>
<option value="all">{t('candidates.allPositions')}</option>
{positionOptions.map(p => <option key={p} value={p}>{p}</option>)}
</select>
<select value={scoreFilter} onChange={(e) => setScoreFilter(e.target.value)} className="h-10 px-4 rounded-lg border border-[#E5E7EB] bg-white text-[13px] focus:border-[#0838E0] focus:outline-none">
<option value="all">All Scores</option>
<option value="high">80+ (Top Talent)</option>
<option value="mid">60-79 (Strong Fit)</option>
<option value="low">0-59 (Possible/Not Fit)</option>
<option value="all">{t('candidates.allScores')}</option>
<option value="high">{t('candidates.80TopTalent')}</option>
<option value="mid">{t('candidates.6079StrongFit')}</option>
<option value="low">{t('candidates.059PossibleNotFit')}</option>
</select>
</div>
@@ -140,7 +142,7 @@ export default function Candidates() {
</div>
) : filtered.length === 0 ? (
<div className="text-center py-16">
<p className="text-[14px] text-[#6B7280]">No candidates match your filters</p>
<p className="text-[14px] text-[#6B7280]">{t('candidates.noCandidatesMatchYourFilters')}</p>
</div>
) : (
<div className="space-y-3">

View File

@@ -7,6 +7,7 @@ import UniversityCourseCard from '@/components/krow/UniversityCourseCard';
import ChallengeRunner from '@/components/krow/proving/ChallengeRunner';
import { ArrowLeft, Clock, Zap, Award, CheckCircle2, Briefcase, Loader2, MessageSquare, Camera, Video, ScanLine, BookOpen } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import { useTranslation } from 'react-i18next';
const TYPE_ICON = { roleplay: MessageSquare, photo: Camera, video: Video, photo_identify: ScanLine };
const TYPE_LABEL = { roleplay: 'Chat with Owliver', photo: 'Photo proof', video: 'Record yourself', photo_identify: 'Identify hazards' };
@@ -18,6 +19,7 @@ const TYPE_HINT = {
};
export default function CourseDetail() {
const { t } = useTranslation();
const { id } = useParams();
const navigate = useNavigate();
const { data: course, isLoading } = useCourse(id);
@@ -51,7 +53,7 @@ export default function CourseDetail() {
const levelOnArrival = useRef(undefined);
if (isLoading) return <div className="flex justify-center py-20"><Loader2 className="w-6 h-6 animate-spin text-[#0838E0]" /></div>;
if (!course) return <div className="py-20 text-center text-[#6B7280]">Challenge not found. <Link to="/university" className="text-[#0838E0] underline">Back to KROW Forge</Link></div>;
if (!course) return <div className="py-20 text-center text-[#6B7280]">{t('courseDetail.challengeNotFound')} <Link to="/university" className="text-[#0838E0] underline">{t('courseDetail.backToKrowForge')}</Link></div>;
const isVerified = (profile?.capabilities || []).some((c) => c.skill === (course.proof_skill || course.title) && c.status === 'verified');
const showRecs = isVerified || passed;
@@ -69,7 +71,7 @@ export default function CourseDetail() {
return (
<div className="max-w-3xl mx-auto space-y-5">
<button onClick={() => navigate('/university')} className="inline-flex items-center gap-1 text-[13px] text-[#6B7280] hover:text-[#111827] font-medium"><ArrowLeft className="w-3.5 h-3.5" /> Back to KROW Forge</button>
<button onClick={() => navigate('/university')} className="inline-flex items-center gap-1 text-[13px] text-[#6B7280] hover:text-[#111827] font-medium"><ArrowLeft className="w-3.5 h-3.5" /> {t('courseDetail.backToKrowForge')}</button>
{/* Header */}
<div className="rounded-3xl bg-[#0838E0] text-white p-6 relative overflow-hidden">
@@ -77,7 +79,7 @@ export default function CourseDetail() {
<div className="flex items-center gap-2 text-[11px] font-medium text-[#9DBFFF] mb-2 flex-wrap">
<span className="px-2 py-0.5 rounded-full bg-white/15">{course.category || 'Skill'}</span>
<span className="capitalize">{course.difficulty}</span>
{isVerified && <span className="inline-flex items-center gap-1"><CheckCircle2 className="w-3.5 h-3.5" /> Verified</span>}
{isVerified && <span className="inline-flex items-center gap-1"><CheckCircle2 className="w-3.5 h-3.5" /> {t('courseDetail.verified')}</span>}
</div>
<h1 className="text-[22px] sm:text-[26px] font-bold tracking-tight">{course.title}</h1>
<div className="flex items-center gap-4 mt-3 text-[12px] text-white/80">
@@ -91,7 +93,7 @@ export default function CourseDetail() {
<div className="rounded-2xl border border-[#E5E7EB] bg-white p-5 flex items-center gap-4">
<div className="w-12 h-12 rounded-2xl bg-[#EEF3FE] flex items-center justify-center shrink-0"><Icon className="w-6 h-6 text-[#0838E0]" /></div>
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-wide text-[#94A3B8]">How you’ll prove it</p>
<p className="text-[11px] font-semibold uppercase tracking-wide text-[#94A3B8]">{t('courseDetail.howYoullProveIt')}</p>
<p className="text-[15px] font-semibold text-[#0F172A]">{TYPE_LABEL[type]}</p>
<p className="text-[12px] text-[#64748B]">{TYPE_HINT[type]}</p>
</div>
@@ -100,7 +102,7 @@ export default function CourseDetail() {
{/* Lesson */}
{course.content && (
<div className="rounded-2xl border border-[#E5E7EB] bg-white p-5">
<div className="flex items-center gap-2 mb-3"><BookOpen className="w-4 h-4 text-[#0838E0]" /><p className="text-[13px] font-semibold text-[#0F172A]">Lesson · {course.estimated_minutes} min</p></div>
<div className="flex items-center gap-2 mb-3"><BookOpen className="w-4 h-4 text-[#0838E0]" /><p className="text-[13px] font-semibold text-[#0F172A]">{t('courseDetail.lesson')} {course.estimated_minutes} min</p></div>
<div className="text-[14px] text-[#374151] leading-relaxed space-y-2 [&_h3]:font-semibold [&_h3]:text-[#111827] [&_ul]:list-disc [&_ul]:pl-5 [&_strong]:text-[#111827]">
<ReactMarkdown>{course.content}</ReactMarkdown>
</div>
@@ -116,11 +118,11 @@ export default function CourseDetail() {
reason to take this module rather than another one. */}
{skillState && !isVerified && !passed && (
<div className="rounded-2xl border border-[#DBEAFE] bg-[#EEF3FE] p-5">
<p className="text-[11px] font-semibold uppercase tracking-wide text-[#1E40AF]">Unlocks</p>
<p className="text-[11px] font-semibold uppercase tracking-wide text-[#1E40AF]">{t('courseDetail.unlocks')}</p>
<p className="mt-0.5 text-[15px] font-semibold text-[#0F172A]">{unlocksLabel(course)}</p>
<p className="mt-1 text-[12px] text-[#1E3A8A]">
You hold {skillState.verifiedLabel}. Owliver checks{' '}
{(course.verification_criteria || course.challenge?.rubric || []).length} criteria before this level is verified.
{t('courseDetail.youHold')} {skillState.verifiedLabel}{t('courseDetail.owliverChecks')}{' '}
{(course.verification_criteria || course.challenge?.rubric || []).length} {t('courseDetail.criteriaBeforeThisLevelIsVerified')}
</p>
</div>
)}
@@ -128,13 +130,12 @@ export default function CourseDetail() {
{/* The loop closing: the level moved, and the matches moved with it. */}
{levelChanged && (
<div className="rounded-2xl border border-[#A7F3D0] bg-[#ECFDF5] p-5">
<p className="text-[11px] font-semibold uppercase tracking-wide text-[#047857]">Skill verified</p>
<p className="text-[11px] font-semibold uppercase tracking-wide text-[#047857]">{t('courseDetail.skillVerified')}</p>
<p className="mt-0.5 text-[15px] font-semibold text-[#065F46]">
{skillState.name}: {levelLabel(levelOnArrival.current) }{' → '}{skillState.verifiedLabel}
</p>
<p className="mt-1 text-[12px] text-[#047857]">
Owliver passed your evidence, so this level is now backed by it — and every position
match has been recalculated.
{t('courseDetail.owliverPassedYourEvidenceSoThis')}
</p>
</div>
)}
@@ -144,7 +145,7 @@ export default function CourseDetail() {
{course.badge_reward && (isVerified || passed) && (
<div className="bg-[#FEF9E7] border border-[#F9E547] rounded-2xl p-4 flex items-center gap-4">
<div className="w-11 h-11 rounded-full bg-[#F9E547] flex items-center justify-center shrink-0"><Award className="w-5 h-5 text-[#92400E]" /></div>
<div><p className="text-[14px] font-semibold text-[#92400E]">Badge earned: {course.badge_reward}</p><p className="text-[12px] text-[#92400E]/80">Backed by evidence. Added to your Career Wallet.</p></div>
<div><p className="text-[14px] font-semibold text-[#92400E]">{t('courseDetail.badgeEarned')} {course.badge_reward}</p><p className="text-[12px] text-[#92400E]/80">{t('courseDetail.backedByEvidenceAddedToYour')}</p></div>
</div>
)}
{nextCourse && (
@@ -154,13 +155,13 @@ export default function CourseDetail() {
</div>
)}
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-4">
<p className="text-[12px] font-semibold text-[#111827] mb-1 flex items-center gap-1.5"><Briefcase className="w-4 h-4 text-[#0838E0]" /> Opportunities you’re now better matched for</p>
<p className="text-[11px] text-[#6B7280] mb-3">Your verified Identity matches against open Opportunities automatically.</p>
{jobRecs.length === 0 ? <p className="text-[12px] text-[#6B7280]">No open positions match yet — keep proving skills to improve your matches.</p> : (
<p className="text-[12px] font-semibold text-[#111827] mb-1 flex items-center gap-1.5"><Briefcase className="w-4 h-4 text-[#0838E0]" /> {t('courseDetail.opportunitiesYoureNowBetterMatchedFor')}</p>
<p className="text-[11px] text-[#6B7280] mb-3">{t('courseDetail.yourVerifiedIdentityMatchesAgainstOpen')}</p>
{jobRecs.length === 0 ? <p className="text-[12px] text-[#6B7280]">{t('courseDetail.noOpenPositionsMatchYetKeep')}</p> : (
<div className="space-y-2">{jobRecs.map((r) => (
<Link key={r.job.id} to={`/positions/${r.job.id}`} className="flex items-center justify-between gap-3 p-3 rounded-lg border border-[#E5E7EB] hover:border-[#0838E0] transition-colors">
<div><p className="text-[13px] font-medium text-[#111827]">{r.job.title}</p><p className="text-[11px] text-[#6B7280]">{r.job.role_category} · {r.job.location}</p></div>
<span className="text-[13px] font-bold text-[#0838E0]">{r.match}% match</span>
<span className="text-[13px] font-bold text-[#0838E0]">{r.match}{t('courseDetail.match')}</span>
</Link>
))}</div>
)}

View File

@@ -9,6 +9,7 @@ import { SkillRequirementsField } from '@/components/krow/SkillRequirementsField
import { SkillSurface } from '@/components/skills/SkillSurface';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
import { usePublishPageActions, usePublishPageContext } from '@/components/ai-assistant';
import { useTranslation } from 'react-i18next';
/**
* Create a Position.
@@ -29,6 +30,7 @@ interface CreatePositionProps {
}
export default function CreatePosition({ prefill: prefillProp, embedded = false, onDone }: CreatePositionProps) {
const { t } = useTranslation();
const navigate = useNavigate();
/* Owliver opens this page with the fields it could infer from the request.
It is a starting point, not a submission: every field stays editable and
@@ -197,13 +199,13 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
{!embedded && (
<div className="flex items-start justify-between gap-4 mb-6">
<div className="min-w-0">
<h1 className="text-[22px] font-bold text-[#111827]">Create a Position</h1>
<p className="text-[13px] text-[#6B7280] mt-0.5">Set hiring criteria once — KROW interviews and ranks every applicant automatically.</p>
<h1 className="text-[22px] font-bold text-[#111827]">{t('createPosition.createAPosition')}</h1>
<p className="text-[13px] text-[#6B7280] mt-0.5">{t('createPosition.setHiringCriteriaOnceKrowInterviews')}</p>
</div>
{/* `shrink-0` and a real gap: without them the subtitle's intrinsic
width pushed Cancel against the edge of a phone screen with the
title wrapping underneath it. */}
<button onClick={() => navigate('/admin/positions')} className="shrink-0 pt-1 text-[13px] text-[#6B7280] hover:text-[#111827]">Cancel</button>
<button onClick={() => navigate('/admin/positions')} className="shrink-0 pt-1 text-[13px] text-[#6B7280] hover:text-[#111827]">{t('createPosition.cancel')}</button>
</div>
)}
@@ -214,7 +216,7 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
{/* Section Header */}
<div className="flex items-center gap-2">
<OwliverAvatar className="w-7 h-7" rounded="rounded-lg" />
<h2 className="text-[16px] font-semibold text-[#111827]">Create Job Posting</h2>
<h2 className="text-[16px] font-semibold text-[#111827]">{t('createPosition.createJobPosting')}</h2>
</div>
{/* Extension points. A definition naming `create-position` and one of
@@ -226,14 +228,14 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
{/* Row 1 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-[13px] font-medium text-[#374151]">Role Category *</label>
<label className="text-[13px] font-medium text-[#374151]">{t('createPosition.roleCategory')}</label>
<select value={form.role_category} onChange={(e) => update('role_category', e.target.value)} 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">
{allCategories.map(r => <option key={r} value={r}>{r}</option>)}
</select>
</div>
<div>
<label className="text-[13px] font-medium text-[#374151]">Job Title *</label>
<input value={form.title} onChange={(e) => update('title', e.target.value)} placeholder="e.g. Experienced Server" 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" />
<label className="text-[13px] font-medium text-[#374151]">{t('createPosition.jobTitle')}</label>
<input value={form.title} onChange={(e) => update('title', e.target.value)} placeholder={t('createPosition.eGExperiencedServer')} 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" />
</div>
</div>
@@ -242,18 +244,18 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
hire is actually for — the field existed in the model and had no way
in from this form until now. */}
<div>
<label className="text-[13px] font-medium text-[#374151]">Client / Company</label>
<label className="text-[13px] font-medium text-[#374151]">{t('createPosition.clientCompany')}</label>
<input
value={form.company}
onChange={(e) => update('company', e.target.value)}
placeholder="e.g. Fairmont San Jose"
placeholder={t('createPosition.eGFairmontSanJose')}
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"
/>
</div>
{/* Add new category */}
<div className="flex gap-2">
<input value={newCategory} onChange={(e) => 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" />
<input value={newCategory} onChange={(e) => setNewCategory(e.target.value)} placeholder={t('createPosition.createANewRoleCategorySaved')} className="flex-1 h-10 rounded-lg border border-[#E5E7EB] px-3 text-[13px] focus:border-[#0838E0] focus:outline-none" />
<button
onClick={async () => {
if (newCategory.trim()) {
@@ -267,14 +269,14 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
disabled={createRoleCategory.isPending || !newCategory.trim()}
className="px-4 bg-[#0838E0] text-white text-[13px] font-medium rounded-lg hover:bg-[#062BAF] disabled:opacity-50 whitespace-nowrap"
>
+ Add
{t('createPosition.add')}
</button>
</div>
{/* Requirements */}
<div>
<label className="text-[13px] font-medium text-[#374151]">Custom Requirements (AI will use this)</label>
<textarea value={form.custom_requirements} onChange={(e) => update('custom_requirements', e.target.value)} placeholder="e.g. Must have fine dining experience..." className="w-full mt-1.5 min-h-[70px] p-3 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0838E0] focus:outline-none resize-none" />
<label className="text-[13px] font-medium text-[#374151]">{t('createPosition.customRequirementsAiWillUseThis')}</label>
<textarea value={form.custom_requirements} onChange={(e) => update('custom_requirements', e.target.value)} placeholder={t('createPosition.eGMustHaveFineDining')} className="w-full mt-1.5 min-h-[70px] p-3 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0838E0] focus:outline-none resize-none" />
</div>
{/* Physical / Leadership / Attendance Expectations.
@@ -285,60 +287,60 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="text-[13px] font-medium text-[#374151]">
Physical Requirements <span className="font-normal text-[#9CA3AF]">— optional</span>
{t('createPosition.physicalRequirements')} <span className="font-normal text-[#9CA3AF]">{t('createPosition.optional')}</span>
</label>
<p className="mt-0.5 text-[11px] text-[#9CA3AF]">Only if this role genuinely has them.</p>
<input value={form.physical_requirements} onChange={(e) => update('physical_requirements', e.target.value)} placeholder="e.g. Extended standing, lifting or climbing" 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" />
<p className="mt-0.5 text-[11px] text-[#9CA3AF]">{t('createPosition.onlyIfThisRoleGenuinelyHas')}</p>
<input value={form.physical_requirements} onChange={(e) => update('physical_requirements', e.target.value)} placeholder={t('createPosition.eGExtendedStandingLiftingOr')} 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" />
</div>
<div>
<label className="text-[13px] font-medium text-[#374151]">
Leadership Expectations <span className="font-normal text-[#9CA3AF]">— optional</span>
{t('createPosition.leadershipExpectations')} <span className="font-normal text-[#9CA3AF]">{t('createPosition.optional')}</span>
</label>
<p className="mt-0.5 text-[11px] text-[#9CA3AF]">Only if this role leads people.</p>
<input value={form.leadership_expectations} onChange={(e) => update('leadership_expectations', e.target.value)} placeholder="e.g. Lead a team of 5" 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" />
<p className="mt-0.5 text-[11px] text-[#9CA3AF]">{t('createPosition.onlyIfThisRoleLeadsPeople')}</p>
<input value={form.leadership_expectations} onChange={(e) => update('leadership_expectations', e.target.value)} placeholder={t('createPosition.eGLeadATeamOf')} 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" />
</div>
<div>
<label className="text-[13px] font-medium text-[#374151]">
Attendance Expectations <span className="font-normal text-[#9CA3AF]">— optional</span>
{t('createPosition.attendanceExpectations')} <span className="font-normal text-[#9CA3AF]">{t('createPosition.optional')}</span>
</label>
<p className="mt-0.5 text-[11px] text-[#9CA3AF]">Shift pattern or availability, if it matters.</p>
<input value={form.attendance_expectations} onChange={(e) => update('attendance_expectations', e.target.value)} placeholder="e.g. On-time, no weekends off" 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" />
<p className="mt-0.5 text-[11px] text-[#9CA3AF]">{t('createPosition.shiftPatternOrAvailabilityIfIt')}</p>
<input value={form.attendance_expectations} onChange={(e) => update('attendance_expectations', e.target.value)} placeholder={t('createPosition.eGOnTimeNoWeekends')} 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" />
</div>
</div>
{/* Row 2 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="text-[13px] font-medium text-[#374151]">Min. Experience (yrs)</label>
<label className="text-[13px] font-medium text-[#374151]">{t('createPosition.minExperienceYrs')}</label>
<input type="number" min="0" value={form.min_experience_years} onChange={(e) => update('min_experience_years', parseInt(e.target.value) || 0)} className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] px-3 text-[13px] focus:border-[#0838E0] focus:outline-none" />
</div>
<div>
<label className="text-[13px] font-medium text-[#374151]">English Level</label>
<label className="text-[13px] font-medium text-[#374151]">{t('createPosition.englishLevel')}</label>
<select value={form.english_required} onChange={(e) => update('english_required', e.target.value)} 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">
{ENGLISH_LEVELS.map(l => <option key={l.value} value={l.value}>{l.label}</option>)}
</select>
</div>
<div>
<label className="text-[13px] font-medium text-[#374151]">Location</label>
<input value={form.location} onChange={(e) => update('location', e.target.value)} placeholder="Bay Area, LA..." className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] px-3 text-[13px] focus:border-[#0838E0] focus:outline-none" />
<label className="text-[13px] font-medium text-[#374151]">{t('createPosition.location')}</label>
<input value={form.location} onChange={(e) => update('location', e.target.value)} placeholder={t('createPosition.bayAreaLa')} className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] px-3 text-[13px] focus:border-[#0838E0] focus:outline-none" />
</div>
</div>
{/* Row 3 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-[13px] font-medium text-[#374151]">Pay Min ($/hr)</label>
<label className="text-[13px] font-medium text-[#374151]">{t('createPosition.payMinHr')}</label>
<input type="number" min="0" value={form.pay_range_min} onChange={(e) => update('pay_range_min', e.target.value)} placeholder="e.g. 18" className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] px-3 text-[13px] focus:border-[#0838E0] focus:outline-none" />
</div>
<div>
<label className="text-[13px] font-medium text-[#374151]">Pay Max ($/hr)</label>
<label className="text-[13px] font-medium text-[#374151]">{t('createPosition.payMaxHr')}</label>
<input type="number" min="0" value={form.pay_range_max} onChange={(e) => update('pay_range_max', e.target.value)} placeholder="e.g. 28" className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] px-3 text-[13px] focus:border-[#0838E0] focus:outline-none" />
</div>
</div>
{/* Certifications */}
<div>
<label className="text-[13px] font-medium text-[#374151]">Required Certifications</label>
<label className="text-[13px] font-medium text-[#374151]">{t('createPosition.requiredCertifications')}</label>
<div className="flex flex-wrap gap-2 mt-1.5">
{CERT_OPTIONS.map(cert => (
<button
@@ -370,9 +372,9 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
className="w-full bg-[#0838E0] hover:bg-[#062BAF] disabled:opacity-60 text-white font-medium py-3 rounded-xl text-[14px] flex items-center justify-center gap-2 transition-colors"
>
{generateDesc.isPending || createJob.isPending ? (
<><Loader2 className="w-4 h-4 animate-spin" /> Generating...</>
<><Loader2 className="w-4 h-4 animate-spin" /> {t('createPosition.generating')}</>
) : (
<><Star className="w-4 h-4" /> Generate Job Description with AI</>
<><Star className="w-4 h-4" /> {t('createPosition.generateJobDescriptionWithAi')}</>
)}
</button>
@@ -380,12 +382,12 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
{aiResult && (
<div className="rounded-xl border border-[#DBEAFE] bg-[#EEF3FE] p-4 space-y-3">
<div className="flex items-center gap-1.5 text-[#1E40AF] text-[13px] font-medium">
<Sparkles className="w-3.5 h-3.5" /> AI-Generated Description
<Sparkles className="w-3.5 h-3.5" /> {t('createPosition.aiGeneratedDescription')}
</div>
<p className="text-[13px] text-[#374151] whitespace-pre-wrap">{aiResult.description}</p>
{aiResult.responsibilities?.length > 0 && (
<div>
<p className="text-[12px] font-semibold text-[#374151] mb-1">Key Responsibilities</p>
<p className="text-[12px] font-semibold text-[#374151] mb-1">{t('createPosition.keyResponsibilities')}</p>
<ul className="text-[12px] text-[#6B7280] list-disc list-inside space-y-0.5">
{aiResult.responsibilities.map((r, i) => <li key={i}>{r}</li>)}
</ul>
@@ -405,7 +407,7 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
<button onClick={() => setShowWeights(!showWeights)} className="w-full flex items-center justify-between">
<span className="flex items-center gap-2">
<Sliders className="w-4 h-4 text-[#059669]" />
<span className="text-[14px] font-medium text-[#059669]">AI Vetting Weights (Total: {totalWeight}% — must = 100%)</span>
<span className="text-[14px] font-medium text-[#059669]">{t('createPosition.aiVettingWeightsTotal')} {totalWeight}{t('createPosition.must100')}</span>
</span>
{showWeights ? <ChevronUp className="w-4 h-4 text-[#9CA3AF]" /> : <ChevronDown className="w-4 h-4 text-[#9CA3AF]" />}
</button>
@@ -421,7 +423,7 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
</div>
))}
<div className="md:col-span-2 text-[12px] text-[#6B7280]">
Total: <span className={totalWeight === 100 ? 'text-[#059669] font-medium' : 'text-[#F59E0B] font-medium'}>{totalWeight}%</span>
{t('createPosition.total')} <span className={totalWeight === 100 ? 'text-[#059669] font-medium' : 'text-[#F59E0B] font-medium'}>{totalWeight}%</span>
{totalWeight !== 100 && ' — adjust to reach 100%'}
</div>
</div>
@@ -443,10 +445,10 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
{/* Bottom Actions */}
<div className="grid grid-cols-2 gap-3 pt-2">
<button onClick={() => handleSave('draft')} disabled={createJob.isPending || updateJob.isPending} className="border border-[#E5E7EB] hover:bg-[#F9FAFB] text-[#374151] font-medium py-3 rounded-xl text-[14px] transition-colors disabled:opacity-50">
Save as Draft
{t('createPosition.saveAsDraft')}
</button>
<button onClick={() => handleSave('active')} disabled={createJob.isPending || updateJob.isPending} className="bg-[#0838E0] hover:bg-[#062BAF] text-white font-medium py-3 rounded-xl text-[14px] transition-colors disabled:opacity-50">
Publish Job Posting
{t('createPosition.publishJobPosting')}
</button>
</div>
</div>

View File

@@ -20,6 +20,7 @@ import {
TooltipProvider, TooltipTrigger, toast,
} from '@/components/ds';
import { AXIS_PROPS, CHART_TONES } from '@/components/ds';
import { useTranslation } from 'react-i18next';
/**
* Design system reference.
@@ -77,6 +78,7 @@ const CHART_DATA = [
];
export default function DesignSystem() {
const { t } = useTranslation();
const [tab, setTab] = useState('foundation');
const [search, setSearch] = useState('');
const [multi, setMulti] = useState(['Bartender']);
@@ -100,20 +102,20 @@ export default function DesignSystem() {
return (
<Stack space="xl">
<PageHeader
title="Design System"
subtitle="Every shared component, variant and state — the foundation all pages build on."
title={t('designSystem.designSystem')}
subtitle={t('designSystem.everySharedComponentVariantAndState')}
icon={Award}
size="display"
breadcrumb={
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem><BreadcrumbLink href="/">KROW Forge</BreadcrumbLink></BreadcrumbItem>
<BreadcrumbItem><BreadcrumbLink href="/">{t('designSystem.krowForge')}</BreadcrumbLink></BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem><BreadcrumbPage>Design System</BreadcrumbPage></BreadcrumbItem>
<BreadcrumbItem><BreadcrumbPage>{t('designSystem.designSystem')}</BreadcrumbPage></BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
}
actions={<Button variant="outline" size="sm"><Download />Export tokens</Button>}
actions={<Button variant="outline" size="sm"><Download />{t('designSystem.exportTokens')}</Button>}
>
<Tabs tabs={TABS} value={tab} onChange={setTab} layoutGroupId="ds" />
</PageHeader>
@@ -121,9 +123,9 @@ export default function DesignSystem() {
{/* ── Foundation ──────────────────────────────────────────────────── */}
{tab === 'foundation' && (
<Stack space="lg">
<Spec title="Colour" description="Brand, semantic and ink scales. All resolve to CSS variables.">
<Spec title={t('designSystem.colour')} description={t('designSystem.brandSemanticAndInkScalesAll')}>
<div className="space-y-4">
<Row label="Brand">
<Row label={t('designSystem.brand')}>
{[
['krow-blue', '#0838E0'], ['krow-blue-dark', '#062BAF'], ['krow-yellow', '#F9E547'],
['krow-yellow-pale', '#F8E08E'], ['krow-navy', '#333F48'], ['krow-mint', '#D1E0D7'],
@@ -135,7 +137,7 @@ export default function DesignSystem() {
</div>
))}
</Row>
<Row label="Semantic">
<Row label={t('designSystem.semantic')}>
{['bg-success', 'bg-warning', 'bg-destructive', 'bg-info', 'bg-neutral'].map((c) => (
<div key={c} className="text-center">
<div className={`w-16 h-12 rounded-lg border border-border ${c}`} />
@@ -143,7 +145,7 @@ export default function DesignSystem() {
</div>
))}
</Row>
<Row label="Ink">
<Row label={t('designSystem.ink')}>
{['bg-ink-1', 'bg-ink-2', 'bg-ink-3', 'bg-ink-4'].map((c) => (
<div key={c} className="text-center">
<div className={`w-16 h-12 rounded-lg ${c}`} />
@@ -154,7 +156,7 @@ export default function DesignSystem() {
</div>
</Spec>
<Spec title="Typography" description="The named scale. Pages use these instead of arbitrary pixel values.">
<Spec title={t('designSystem.typography')} description={t('designSystem.theNamedScalePagesUseThese')}>
<div className="space-y-2">
{[
['text-display-lg', 'Display Large'], ['text-display', 'Display'],
@@ -171,7 +173,7 @@ export default function DesignSystem() {
</div>
</Spec>
<Spec title="Elevation & radius" description="A four-step shadow scale plus the brand and panel treatments.">
<Spec title={t('designSystem.elevationRadius')} description={t('designSystem.aFourStepShadowScalePlus')}>
<Row>
{['shadow-xs', 'shadow-sm', 'shadow-md', 'shadow-lg', 'shadow-xl', 'shadow-brand'].map((s) => (
<div key={s} className={`w-28 h-20 rounded-xl bg-surface border border-border grid place-items-center ${s}`}>
@@ -179,7 +181,7 @@ export default function DesignSystem() {
</div>
))}
</Row>
<Row label="Radius">
<Row label={t('designSystem.radius')}>
{['rounded-sm', 'rounded-md', 'rounded-lg', 'rounded-xl', 'rounded-2xl', 'rounded-full'].map((r) => (
<div key={r} className={`w-20 h-14 bg-krow-blue-tint border border-krow-blue/20 grid place-items-center ${r}`}>
<span className="text-[9px] text-krow-blue font-mono">{r.replace('rounded-', '')}</span>
@@ -188,7 +190,7 @@ export default function DesignSystem() {
</Row>
</Spec>
<Spec title="Surface" description="The one container. Every card in the app is one of these.">
<Spec title={t('designSystem.surface')} description={t('designSystem.theOneContainerEveryCardIn')}>
<Grid cols={3}>
{['glass', 'solid', 'subtle', 'sunken', 'outline', 'panel'].map((v) => (
<Surface key={v} variant={v} radius="xl" padding="default">
@@ -201,10 +203,10 @@ export default function DesignSystem() {
</Grid>
</Spec>
<Spec title="Layout" description="Grid presets that avoid orphan cards at every breakpoint.">
<Spec title={t('designSystem.layout')} description={t('designSystem.gridPresetsThatAvoidOrphanCards')}>
{[6, 4, 3, 2].map((cols) => (
<div key={cols}>
<p className="text-caption text-ink-4 mb-2">cols={cols}</p>
<p className="text-caption text-ink-4 mb-2">{t('designSystem.cols')}{cols}</p>
<Grid cols={cols}>
{Array.from({ length: cols }).map((_, i) => (
<div key={i} className="h-12 rounded-lg bg-krow-blue-tint border border-krow-blue/20" />
@@ -219,93 +221,93 @@ export default function DesignSystem() {
{/* ── Controls ────────────────────────────────────────────────────── */}
{tab === 'controls' && (
<Stack space="lg">
<Spec title="Button" description="One implementation. Variant, size, shape and loading are props.">
<Row label="Variant">
<Button>Primary</Button>
<Button variant="navy">Navy</Button>
<Button variant="accent">Accent</Button>
<Button variant="outline">Outline</Button>
<Button variant="soft">Soft</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="destructive">Destructive</Button>
<Button variant="glass">Glass</Button>
<Spec title={t('designSystem.button')} description={t('designSystem.oneImplementationVariantSizeShapeAnd')}>
<Row label={t('designSystem.variant2')}>
<Button>{t('designSystem.primary')}</Button>
<Button variant="navy">{t('designSystem.navy')}</Button>
<Button variant="accent">{t('designSystem.accent')}</Button>
<Button variant="outline">{t('designSystem.outline')}</Button>
<Button variant="soft">{t('designSystem.soft')}</Button>
<Button variant="ghost">{t('designSystem.ghost')}</Button>
<Button variant="destructive">{t('designSystem.destructive')}</Button>
<Button variant="glass">{t('designSystem.glass')}</Button>
</Row>
<Row label="Size">
<Row label={t('designSystem.size')}>
<Button size="xs">XS</Button>
<Button size="sm">Small</Button>
<Button>Default</Button>
<Button size="lg">Large</Button>
<Button size="sm">{t('designSystem.small')}</Button>
<Button>{t('designSystem.default')}</Button>
<Button size="lg">{t('designSystem.large')}</Button>
<Button size="xl">XL</Button>
</Row>
<Row label="Shape">
<Button shape="pill">Pill</Button>
<Button shape="rounded">Rounded</Button>
<Button shape="square">Square</Button>
<Row label={t('designSystem.shape')}>
<Button shape="pill">{t('designSystem.pill')}</Button>
<Button shape="rounded">{t('designSystem.rounded')}</Button>
<Button shape="square">{t('designSystem.square')}</Button>
</Row>
<Row label="State">
<Button loading>Loading</Button>
<Button disabled>Disabled</Button>
<Button><Plus />With icon</Button>
<Button variant="link">Link style</Button>
<Row label={t('designSystem.state')}>
<Button loading>{t('designSystem.loading')}</Button>
<Button disabled>{t('designSystem.disabled')}</Button>
<Button><Plus />{t('designSystem.withIcon')}</Button>
<Button variant="link">{t('designSystem.linkStyle')}</Button>
</Row>
<Row label="Icon only">
<IconButton icon={Pencil} label="Edit" variant="outline" />
<IconButton icon={Trash2} label="Delete" variant="destructive-ghost" />
<IconButton icon={MoreHorizontal} label="More" variant="ghost" />
<IconButton icon={Plus} label="Add" size="sm" />
<IconButton icon={Download} label="Download" size="lg" variant="soft" />
<Row label={t('designSystem.iconOnly')}>
<IconButton icon={Pencil} label={t('designSystem.edit')} variant="outline" />
<IconButton icon={Trash2} label={t('designSystem.delete')} variant="destructive-ghost" />
<IconButton icon={MoreHorizontal} label={t('designSystem.more')} variant="ghost" />
<IconButton icon={Plus} label={t('designSystem.add')} size="sm" />
<IconButton icon={Download} label={t('designSystem.download')} size="lg" variant="soft" />
</Row>
</Spec>
<Spec title="Text input" description="Field owns the label, hint and error wiring.">
<Spec title={t('designSystem.textInput')} description={t('designSystem.fieldOwnsTheLabelHintAnd')}>
<Grid cols={2}>
<Field label="Full name" required hint="As it appears on their ID.">
<Input placeholder="Chef Antoine Dubois" />
<Field label={t('designSystem.fullName')} required hint={t('designSystem.asItAppearsOnTheirId')}>
<Input placeholder={t('designSystem.chefAntoineDubois')} />
</Field>
<Field label="Email" error="That email is already on file.">
<Field label={t('designSystem.email')} error="That email is already on file.">
<Input defaultValue="antoine@" />
</Field>
<Field label="Search">
<SearchInput value={search} onChange={setSearch} placeholder="Search candidates…" />
<Field label={t('designSystem.search')}>
<SearchInput value={search} onChange={setSearch} placeholder={t('designSystem.searchCandidates')} />
</Field>
<Field label="Disabled">
<Input disabled placeholder="Not editable" />
<Field label={t('designSystem.disabled')}>
<Input disabled placeholder={t('designSystem.notEditable')} />
</Field>
<Field label="Notes" className="sm:col-span-2">
<Textarea placeholder="Anything the hiring manager should know…" rows={3} />
<Field label={t('designSystem.notes')} className="sm:col-span-2">
<Textarea placeholder={t('designSystem.anythingTheHiringManagerShouldKnow')} rows={3} />
</Field>
</Grid>
</Spec>
<Spec title="Selection" description="Single select, multi select, and the boolean controls.">
<Spec title={t('designSystem.selection')} description={t('designSystem.singleSelectMultiSelectAndThe')}>
<Grid cols={2}>
<Field label="Role category">
<Field label={t('designSystem.roleCategory')}>
<Select defaultValue="bartender">
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="bartender">Bartender</SelectItem>
<SelectItem value="server">Server</SelectItem>
<SelectItem value="chef">Chef</SelectItem>
<SelectItem value="security">Security</SelectItem>
<SelectItem value="bartender">{t('designSystem.bartender')}</SelectItem>
<SelectItem value="server">{t('designSystem.server')}</SelectItem>
<SelectItem value="chef">{t('designSystem.chef')}</SelectItem>
<SelectItem value="security">{t('designSystem.security')}</SelectItem>
</SelectContent>
</Select>
</Field>
<Field label="Required certifications" hint="Chips are removable inline.">
<Field label={t('designSystem.requiredCertifications')} hint={t('designSystem.chipsAreRemovableInline')}>
<MultiSelect
options={['Bartender', 'ServSafe', 'TIPS Certified', 'Guard Card', 'CPR / First Aid', 'Food Handler Card', 'OSHA 10', 'Forklift Operator', 'RBS Alcohol Server']}
value={multi}
onChange={setMulti}
placeholder="Select certifications"
placeholder={t('designSystem.selectCertifications')}
/>
</Field>
</Grid>
<div className="grid sm:grid-cols-2 gap-6 pt-2">
<div className="space-y-3">
<Field label="Accept applications" inline hint="Candidates can apply immediately.">
<Field label={t('designSystem.acceptApplications')} inline hint={t('designSystem.candidatesCanApplyImmediately')}>
<Checkbox defaultChecked />
</Field>
<Field label="Require certification upload" inline>
<Field label={t('designSystem.requireCertificationUpload')} inline>
<Checkbox />
</Field>
<RadioGroup defaultValue="fluent" className="space-y-2 pt-2">
@@ -322,15 +324,15 @@ export default function DesignSystem() {
<div className="space-y-4">
<Toggle
label="Auto-screen new applicants"
description="Score every application the moment it arrives."
label={t('designSystem.autoScreenNewApplicants')}
description={t('designSystem.scoreEveryApplicationTheMomentIt')}
checked={toggled}
onCheckedChange={setToggled}
/>
<Toggle label="Email me a daily digest" checked={false} onCheckedChange={() => {}} />
<Toggle label="Disabled setting" checked={false} onCheckedChange={() => {}} disabled />
<Toggle label={t('designSystem.emailMeADailyDigest')} checked={false} onCheckedChange={() => {}} />
<Toggle label={t('designSystem.disabledSetting')} checked={false} onCheckedChange={() => {}} disabled />
<div className="pt-1">
<p className="text-caption text-ink-4 mb-2">Segmented</p>
<p className="text-caption text-ink-4 mb-2">{t('designSystem.segmented')}</p>
<SegmentedToggle
options={[{ value: '7d', label: '7 days' }, { value: '30d', label: '30 days' }, { value: '90d', label: '90 days' }]}
value={segment}
@@ -342,11 +344,11 @@ export default function DesignSystem() {
</div>
</Spec>
<Spec title="Navigation" description="Tabs, breadcrumbs, pagination and step progress.">
<Row label="Pill tabs">
<Spec title={t('designSystem.navigation')} description={t('designSystem.tabsBreadcrumbsPaginationAndStepProgress')}>
<Row label={t('designSystem.pillTabs')}>
<Tabs tabs={TABS} value={tab} onChange={setTab} layoutGroupId="ds-demo-pill" />
</Row>
<Row label="Underline">
<Row label={t('designSystem.underline')}>
<Tabs
variant="underline"
tabs={[{ value: 'all', label: 'All', count: 22 }, { value: 'screened', label: 'Screened', count: 14 }, { value: 'hired', label: 'Hired', count: 3 }]}
@@ -356,15 +358,15 @@ export default function DesignSystem() {
/>
</Row>
<div className="pt-2">
<p className="text-caption text-ink-4 mb-3">Steps</p>
<p className="text-caption text-ink-4 mb-3">{t('designSystem.steps')}</p>
<ProgressSteps
steps={['Basics', 'Requirements', 'Vetting', 'Review']}
current={step}
onStepClick={setStep}
/>
<div className="flex gap-2 mt-4">
<Button size="sm" variant="outline" onClick={() => setStep(Math.max(0, step - 1))}>Back</Button>
<Button size="sm" onClick={() => setStep(Math.min(3, step + 1))}>Next</Button>
<Button size="sm" variant="outline" onClick={() => setStep(Math.max(0, step - 1))}>{t('designSystem.back')}</Button>
<Button size="sm" onClick={() => setStep(Math.min(3, step + 1))}>{t('designSystem.next')}</Button>
</div>
</div>
<div className="pt-2">
@@ -372,35 +374,35 @@ export default function DesignSystem() {
</div>
</Spec>
<Spec title="Overlays" description="Modal, drawer, dropdown, popover and tooltip.">
<Spec title={t('designSystem.overlays')} description={t('designSystem.modalDrawerDropdownPopoverAndTooltip')}>
<Row>
<Button variant="outline" onClick={() => setModalOpen(true)}>Open modal</Button>
<Button variant="outline" onClick={() => setDrawerOpen(true)}>Open drawer</Button>
<Button variant="destructive" onClick={() => setConfirmOpen(true)}>Confirm dialog</Button>
<Button variant="outline" onClick={() => setModalOpen(true)}>{t('designSystem.openModal')}</Button>
<Button variant="outline" onClick={() => setDrawerOpen(true)}>{t('designSystem.openDrawer')}</Button>
<Button variant="destructive" onClick={() => setConfirmOpen(true)}>{t('designSystem.confirmDialog')}</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild><Button variant="outline">Dropdown</Button></DropdownMenuTrigger>
<DropdownMenuTrigger asChild><Button variant="outline">{t('designSystem.dropdown')}</Button></DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem><Pencil className="w-4 h-4 mr-2" />Edit</DropdownMenuItem>
<DropdownMenuItem><Download className="w-4 h-4 mr-2" />Export</DropdownMenuItem>
<DropdownMenuItem><Pencil className="w-4 h-4 mr-2" />{t('designSystem.edit')}</DropdownMenuItem>
<DropdownMenuItem><Download className="w-4 h-4 mr-2" />{t('designSystem.export')}</DropdownMenuItem>
<DropdownMenuItem className="text-destructive focus:text-destructive">
<Trash2 className="w-4 h-4 mr-2" />Delete
<Trash2 className="w-4 h-4 mr-2" />{t('designSystem.delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Popover>
<PopoverTrigger asChild><Button variant="outline">Popover</Button></PopoverTrigger>
<PopoverTrigger asChild><Button variant="outline">{t('designSystem.popover')}</Button></PopoverTrigger>
<PopoverContent className="w-64">
<p className="text-body-sm font-semibold text-ink-1">Score breakdown</p>
<p className="text-caption text-ink-3 mt-1">Weighted across five dimensions.</p>
<p className="text-body-sm font-semibold text-ink-1">{t('designSystem.scoreBreakdown')}</p>
<p className="text-caption text-ink-3 mt-1">{t('designSystem.weightedAcrossFiveDimensions')}</p>
</PopoverContent>
</Popover>
<TooltipProvider>
<DsTooltip>
<TooltipTrigger asChild><Button variant="ghost">Hover me</Button></TooltipTrigger>
<TooltipContent>Tooltips use the same radius and shadow</TooltipContent>
<TooltipTrigger asChild><Button variant="ghost">{t('designSystem.hoverMe')}</Button></TooltipTrigger>
<TooltipContent>{t('designSystem.tooltipsUseTheSameRadiusAnd')}</TooltipContent>
</DsTooltip>
</TooltipProvider>
</Row>
@@ -411,45 +413,45 @@ export default function DesignSystem() {
{/* ── Feedback ────────────────────────────────────────────────────── */}
{tab === 'feedback' && (
<Stack space="lg">
<Spec title="Badge & status" description="StatusBadge maps a domain state to one canonical colour and label.">
<Row label="Badge">
<Spec title={t('designSystem.badgeStatus')} description={t('designSystem.statusbadgeMapsADomainStateTo')}>
<Row label={t('designSystem.badge')}>
{['default', 'soft', 'accent', 'navy', 'success', 'warning', 'destructive', 'info', 'neutral', 'outline'].map((v) => (
<Badge key={v} variant={v}>{v}</Badge>
))}
</Row>
<Row label="Application">
<Row label={t('designSystem.application')}>
{['applied', 'ai_screened', 'shortlisted', 'interview', 'hired', 'rejected'].map((s) => (
<StatusBadge key={s} status={s} dot />
))}
</Row>
<Row label="Posting">
<Row label={t('designSystem.posting')}>
{['active', 'draft', 'paused', 'closed'].map((s) => <StatusBadge key={s} status={s} />)}
</Row>
<Row label="Bands">
<Row label={t('designSystem.bands')}>
{['elite', 'excellent', 'solid', 'building', 'new', 'no_score'].map((s) => (
<StatusBadge key={s} status={s} />
))}
</Row>
</Spec>
<Spec title="Alert" description="Persistent, inline conditions. Transient confirmations use toast.">
<Alert tone="info" title="Screening is recommendation only">
Owliver ranks and recommends. Every hiring decision stays with a human recruiter.
<Spec title={t('designSystem.alert')} description={t('designSystem.persistentInlineConditionsTransientConfirmationsUse')}>
<Alert tone="info" title={t('designSystem.screeningIsRecommendationOnly')}>
{t('designSystem.owliverRanksAndRecommendsEveryHiring')}
</Alert>
<Alert tone="success" title="9 candidates screened">All applications now carry a comparable score.</Alert>
<Alert tone="warning" title="8 applicants unscored" onDismiss={() => {}}>
Those decisions are being made without a standard.
<Alert tone="success" title={t('designSystem.9CandidatesScreened')}>{t('designSystem.allApplicationsNowCarryAComparable')}</Alert>
<Alert tone="warning" title={t('designSystem.8ApplicantsUnscored')} onDismiss={() => {}}>
{t('designSystem.thoseDecisionsAreBeingMadeWithout')}
</Alert>
<Alert tone="destructive" title="Certification expired" action={<Button size="sm" variant="destructive">Request update</Button>}>
Marcus Williams&apos; Guard Card lapsed on 12 July.
<Alert tone="destructive" title={t('designSystem.certificationExpired')} action={<Button size="sm" variant="destructive">{t('designSystem.requestUpdate')}</Button>}>
{t('designSystem.marcusWilliamsGuardCardLapsedOn')}
</Alert>
</Spec>
<Spec title="Toast" description="Wraps react-hot-toast so styling and duration live in one place.">
<Spec title={t('designSystem.toast')} description={t('designSystem.wrapsReactHotToastSoStyling')}>
<Row>
<Button variant="outline" size="sm" onClick={() => toast.success('Candidate shortlisted')}>Success</Button>
<Button variant="outline" size="sm" onClick={() => toast.error('Screening failed — try again')}>Error</Button>
<Button variant="outline" size="sm" onClick={() => toast.info('All candidates already screened')}>Info</Button>
<Button variant="outline" size="sm" onClick={() => toast.success('Candidate shortlisted')}>{t('designSystem.success')}</Button>
<Button variant="outline" size="sm" onClick={() => toast.error('Screening failed — try again')}>{t('designSystem.error')}</Button>
<Button variant="outline" size="sm" onClick={() => toast.info('All candidates already screened')}>{t('designSystem.info')}</Button>
<Button
variant="outline"
size="sm"
@@ -458,18 +460,18 @@ export default function DesignSystem() {
{ loading: 'Screening 8 candidates…', success: 'Screened 8 candidates', error: 'Screening failed' }
)}
>
Promise
{t('designSystem.promise')}
</Button>
</Row>
</Spec>
<Spec title="Empty state" description="Distinguishes nothing-yet from nothing-matching.">
<Spec title={t('designSystem.emptyState')} description={t('designSystem.distinguishesNothingYetFromNothingMatching')}>
<Grid cols={2}>
<Surface variant="subtle" padding="none" radius="xl">
<EmptyState
icon={Inbox}
title="No candidates yet"
description="Share the application link and applicants will appear here automatically."
title={t('designSystem.noCandidatesYet')}
description={t('designSystem.shareTheApplicationLinkAndApplicants')}
action={{ label: 'Copy apply link', onClick: () => toast.success('Link copied') }}
size="sm"
/>
@@ -478,8 +480,8 @@ export default function DesignSystem() {
<EmptyState
icon={Inbox}
variant="filtered"
title="No matching candidates"
description="No one matches the current filters. Try widening the score range."
title={t('designSystem.noMatchingCandidates')}
description={t('designSystem.noOneMatchesTheCurrentFilters')}
action={{ label: 'Clear filters', onClick: () => {} }}
size="sm"
/>
@@ -487,8 +489,8 @@ export default function DesignSystem() {
</Grid>
</Spec>
<Spec title="Loading" description="Skeletons mirror final geometry; spinners are for unknown shapes.">
<Row label="Spinner">
<Spec title={t('designSystem.loading')} description={t('designSystem.skeletonsMirrorFinalGeometrySpinnersAre')}>
<Row label={t('designSystem.spinner')}>
<Spinner size="xs" /><Spinner size="sm" /><Spinner /><Spinner size="lg" />
<InlineLoading message="Screening candidates…" />
</Row>
@@ -505,7 +507,7 @@ export default function DesignSystem() {
<Surface variant="solid" radius="xl" padding="default">
<SkeletonText lines={3} />
</Surface>
<Row label="Region"><LoadingState message="Loading analytics…" size="sm" /></Row>
<Row label={t('designSystem.region')}><LoadingState message="Loading analytics…" size="sm" /></Row>
</Spec>
</Stack>
)}
@@ -513,56 +515,56 @@ export default function DesignSystem() {
{/* ── Data ────────────────────────────────────────────────────────── */}
{tab === 'data' && (
<Stack space="lg">
<PageSection title="KPI cards">
<PageSection title={t('designSystem.kpiCards')}>
<Grid cols={6}>
<KpiCard icon={Briefcase} value={5} label="Open Positions" tone="brand" index={0} />
<KpiCard icon={Users} value={22} label="Total Candidates" tone="navy" index={1} />
<KpiCard icon={Brain} value={14} label="AI Screened" tone="brand" index={2} />
<KpiCard icon={Clock} value={8} label="Pending Screen" tone="accent" index={3} />
<KpiCard icon={TrendingUp} value={76} label="Avg AI Score" tone="navy" index={4} />
<KpiCard icon={Award} value={3} label="Hired" tone="brand" index={5} />
<KpiCard icon={Briefcase} value={5} label={t('designSystem.openPositions')} tone="brand" index={0} />
<KpiCard icon={Users} value={22} label={t('designSystem.totalCandidates')} tone="navy" index={1} />
<KpiCard icon={Brain} value={14} label={t('designSystem.aiScreened')} tone="brand" index={2} />
<KpiCard icon={Clock} value={8} label={t('designSystem.pendingScreen')} tone="accent" index={3} />
<KpiCard icon={TrendingUp} value={76} label={t('designSystem.avgAiScore')} tone="navy" index={4} />
<KpiCard icon={Award} value={3} label={t('designSystem.hired')} tone="brand" index={5} />
</Grid>
</PageSection>
<PageSection title="Metric cards">
<PageSection title={t('designSystem.metricCards')}>
<Grid cols={3}>
<MetricCard label="Time to hire" value="1d" delta={-38} invertTrend deltaLabel="vs. 5-day industry average" icon={Clock} tone="success" />
<MetricCard label="Quality of hire" value="94" delta={12} deltaLabel="Average score of hires" icon={Award} tone="brand" />
<MetricCard label="Cost saved" value="$725" delta={0} sub="9 screens · 4 interviews automated" icon={TrendingUp} tone="neutral" />
<MetricCard label={t('designSystem.timeToHire')} value="1d" delta={-38} invertTrend deltaLabel="vs. 5-day industry average" icon={Clock} tone="success" />
<MetricCard label={t('designSystem.qualityOfHire')} value="94" delta={12} deltaLabel="Average score of hires" icon={Award} tone="brand" />
<MetricCard label={t('designSystem.costSaved')} value="$725" delta={0} sub="9 screens · 4 interviews automated" icon={TrendingUp} tone="neutral" />
</Grid>
</PageSection>
<Spec title="Progress" description="Bars, rings and score meters share the same band thresholds.">
<Spec title={t('designSystem.progress')} description={t('designSystem.barsRingsAndScoreMetersShare')}>
<Grid cols={2}>
<div className="space-y-4">
<ProgressBar label="Profile completion" value={62} showValue valueSuffix="%" />
<ProgressBar label="Score 97" value={97} tone="score" showValue />
<ProgressBar label="Score 72" value={72} tone="score" showValue />
<ProgressBar label="Score 28" value={28} tone="score" showValue />
<ProgressBar label={t('designSystem.profileCompletion')} value={62} showValue valueSuffix="%" />
<ProgressBar label={t('designSystem.score97')} value={97} tone="score" showValue />
<ProgressBar label={t('designSystem.score72')} value={72} tone="score" showValue />
<ProgressBar label={t('designSystem.score28')} value={28} tone="score" showValue />
</div>
<div className="space-y-3">
<p className="text-caption text-ink-4">Score meters</p>
<ScoreMeter label="Reliability" value={96} weight={25} />
<ScoreMeter label="Attendance" value={98} weight={20} />
<ScoreMeter label="Communication" value={72} weight={15} />
<ScoreMeter label="Leadership" value={41} weight={15} />
<p className="text-caption text-ink-4">{t('designSystem.scoreMeters')}</p>
<ScoreMeter label={t('designSystem.reliability')} value={96} weight={25} />
<ScoreMeter label={t('designSystem.attendance')} value={98} weight={20} />
<ScoreMeter label={t('designSystem.communication')} value={72} weight={15} />
<ScoreMeter label={t('designSystem.leadership')} value={41} weight={15} />
</div>
</Grid>
<Row label="Rings">
<ProgressRing value={97} caption="Score" size={88} />
<Row label={t('designSystem.rings')}>
<ProgressRing value={97} caption={t('designSystem.score')} size={88} />
<ProgressRing value={72} tone="score" size={88} />
<ProgressRing value={817} min={300} max={850} label="817" caption="Career" footnote="300-850" size={104} />
<ProgressRing value={817} min={300} max={850} label="817" caption={t('designSystem.career')} footnote="300-850" size={104} />
<ProgressRing value={28} tone="score" size={72} />
</Row>
</Spec>
<Spec title="Avatar" description="Photo when available, initials otherwise. Never a broken image.">
<Row label="Sizes">
<Spec title={t('designSystem.avatar')} description={t('designSystem.photoWhenAvailableInitialsOtherwiseNever')}>
<Row label={t('designSystem.sizes')}>
{['xs', 'sm', 'default', 'lg', 'xl'].map((s) => (
<Avatar key={s} name="Chef Antoine Dubois" size={s} />
))}
</Row>
<Row label="Tones">
<Row label={t('designSystem.tones')}>
<Avatar name="Marcus Williams" tone="brand" />
<Avatar name="Sofia Mendez" tone="navy" />
<Avatar name="Tanya Cruz" tone="soft" />
@@ -570,12 +572,12 @@ export default function DesignSystem() {
<Avatar name="Maria Gonzalez" src="https://i.pravatar.cc/160?img=31" />
<Avatar name="Broken Image" src="https://example.invalid/nope.jpg" />
</Row>
<Row label="Status">
<Row label={t('designSystem.status')}>
<Avatar name="Online Person" status="online" />
<Avatar name="Busy Person" status="busy" />
<Avatar name="Away Person" status="offline" />
</Row>
<Row label="Group">
<Row label={t('designSystem.group')}>
<AvatarGroup
people={[
{ name: 'Chef Antoine Dubois' }, { name: 'Marcus Williams' },
@@ -587,7 +589,7 @@ export default function DesignSystem() {
</Row>
</Spec>
<Spec title="Filter bar" description="Declarative filters with active chips and a clear-all.">
<Spec title={t('designSystem.filterBar')} description={t('designSystem.declarativeFiltersWithActiveChipsAnd')}>
<FilterBar
search={search}
onSearchChange={setSearch}
@@ -604,12 +606,12 @@ export default function DesignSystem() {
},
{ key: 'position', label: 'Position', type: 'multi', options: ['Bartender', 'Event Server', 'Executive Chef', 'Security'] },
]}
meta="6 of 22 candidates"
actions={<Button size="sm"><Brain />AI Screen All</Button>}
meta={t('designSystem.6Of22Candidates')}
actions={<Button size="sm"><Brain />{t('designSystem.aiScreenAll')}</Button>}
/>
</Spec>
<PageSection title="Data table">
<PageSection title={t('designSystem.dataTable')}>
<DataTable
columns={[
{ key: 'name', header: 'Candidate', sortable: true, cell: (r) => (
@@ -630,16 +632,16 @@ export default function DesignSystem() {
selectedIds={selected}
onSelectionChange={setSelected}
pageSize={5}
caption="Candidates by AI score"
caption={t('designSystem.candidatesByAiScore')}
onRowClick={(r) => toast.info(`Opened ${r.name}`)}
/>
</PageSection>
<Spec title="Chart container" description="Owns the title, height, legend, loading and empty states.">
<Spec title={t('designSystem.chartContainer')} description={t('designSystem.ownsTheTitleHeightLegendLoading')}>
<Grid cols={2}>
<ChartContainer
title="Candidate Scores"
subtitle="9 candidates screened"
title={t('designSystem.candidateScores')}
subtitle={t('designSystem.9CandidatesScreened')}
headline={76}
headlineLabel="Avg score"
height={220}
@@ -658,14 +660,14 @@ export default function DesignSystem() {
</BarChart>
</ChartContainer>
<ChartContainer title="Interview Volume" subtitle="No interviews in this window" height={220} isEmpty />
<ChartContainer title={t('designSystem.interviewVolume')} subtitle={t('designSystem.noInterviewsInThisWindow')} height={220} isEmpty />
</Grid>
</Spec>
<Spec title="Timeline & activity" description="Event sequences and feed rows.">
<Spec title={t('designSystem.timelineActivity')} description={t('designSystem.eventSequencesAndFeedRows')}>
<Grid cols={2}>
<div>
<p className="text-caption text-ink-4 mb-3">Timeline</p>
<p className="text-caption text-ink-4 mb-3">{t('designSystem.timeline')}</p>
<Timeline
items={[
{ id: 1, title: 'Applied', description: 'Executive Chef – Catering', timestamp: 'Jul 24', icon: Calendar, tone: 'neutral' },
@@ -676,18 +678,18 @@ export default function DesignSystem() {
/>
</div>
<div>
<p className="text-caption text-ink-4 mb-3">Activity</p>
<p className="text-caption text-ink-4 mb-3">{t('designSystem.activity')}</p>
<div className="space-y-1">
<ActivityCard actor="Alex Rivera" title="hired Chef Antoine Dubois" timestamp="Jul 25, 9:00 AM" status="hired" onClick={() => {}} />
<ActivityCard actor="Alex Rivera" title="screened 3 candidates" timestamp="Jul 24, 2:30 PM" onClick={() => {}} />
<ActivityCard icon={Activity} iconTone="soft" title="Position created" description="Banquet Captain – Hotel Events" timestamp="Jul 18" />
<ActivityCard actor="Alex Rivera" title={t('designSystem.hiredChefAntoineDubois')} timestamp="Jul 25, 9:00 AM" status="hired" onClick={() => {}} />
<ActivityCard actor="Alex Rivera" title={t('designSystem.screened3Candidates')} timestamp="Jul 24, 2:30 PM" onClick={() => {}} />
<ActivityCard icon={Activity} iconTone="soft" title={t('designSystem.positionCreated')} description={t('designSystem.banquetCaptainHotelEvents')} timestamp="Jul 18" />
<ActivityCard
icon={AlertCircle}
iconTone="warning"
title="Interview flagged"
description="Three responses returned in under 8 seconds."
title={t('designSystem.interviewFlagged')}
description={t('designSystem.threeResponsesReturnedInUnder8')}
timestamp="Aug 3"
trailing={<Badge variant="warning" size="sm">Review</Badge>}
trailing={<Badge variant="warning" size="sm">{t('designSystem.review')}</Badge>}
variant="card"
/>
</div>
@@ -701,29 +703,29 @@ export default function DesignSystem() {
<Modal
open={modalOpen}
onOpenChange={setModalOpen}
title="Schedule interview"
description="Owliver will run the interview and score it automatically."
title={t('designSystem.scheduleInterview')}
description={t('designSystem.owliverWillRunTheInterviewAnd')}
icon={Calendar}
footer={
<>
<Button variant="outline" onClick={() => setModalOpen(false)}>Cancel</Button>
<Button onClick={() => { setModalOpen(false); toast.success('Interview scheduled'); }}>Schedule</Button>
<Button variant="outline" onClick={() => setModalOpen(false)}>{t('designSystem.cancel')}</Button>
<Button onClick={() => { setModalOpen(false); toast.success('Interview scheduled'); }}>{t('designSystem.schedule')}</Button>
</>
}
>
<div className="space-y-4">
<Field label="Candidate"><Input defaultValue="Sofia Mendez" readOnly /></Field>
<Field label="Date" hint="Candidates get a link immediately."><Input type="date" defaultValue="2026-08-12" /></Field>
<Field label="Notes"><Textarea rows={3} placeholder="Anything to probe specifically…" /></Field>
<Field label={t('designSystem.candidate')}><Input defaultValue="Sofia Mendez" readOnly /></Field>
<Field label={t('designSystem.date')} hint={t('designSystem.candidatesGetALinkImmediately')}><Input type="date" defaultValue="2026-08-12" /></Field>
<Field label={t('designSystem.notes')}><Textarea rows={3} placeholder={t('designSystem.anythingToProbeSpecifically')} /></Field>
</div>
</Modal>
<ConfirmModal
open={confirmOpen}
onOpenChange={setConfirmOpen}
title="Decline Sam Okafor?"
description="They will be notified and moved out of the active pipeline. This cannot be undone."
confirmLabel="Decline candidate"
title={t('designSystem.declineSamOkafor')}
description={t('designSystem.theyWillBeNotifiedAndMoved')}
confirmLabel={t('designSystem.declineCandidate')}
icon={AlertCircle}
onConfirm={() => { setConfirmOpen(false); toast.success('Candidate declined'); }}
/>
@@ -731,13 +733,13 @@ export default function DesignSystem() {
<Drawer
open={drawerOpen}
onOpenChange={setDrawerOpen}
title="Chef Antoine Dubois"
description="Executive Chef – Catering · Scored 97"
title={t('designSystem.chefAntoineDubois')}
description={t('designSystem.executiveChefCateringScored97')}
icon={Star}
footer={
<>
<Button variant="outline" onClick={() => setDrawerOpen(false)}>Close</Button>
<Button onClick={() => { setDrawerOpen(false); toast.success('Shortlisted'); }}>Shortlist</Button>
<Button variant="outline" onClick={() => setDrawerOpen(false)}>{t('designSystem.close')}</Button>
<Button onClick={() => { setDrawerOpen(false); toast.success('Shortlisted'); }}>{t('designSystem.shortlist')}</Button>
</>
}
>
@@ -745,8 +747,8 @@ export default function DesignSystem() {
<div className="flex items-center gap-3">
<Avatar name="Chef Antoine Dubois" size="xl" />
<div>
<p className="text-title font-heading text-ink-1">Chef Antoine Dubois</p>
<p className="text-body-sm text-ink-3">12 years · ServSafe · Bay Area</p>
<p className="text-title font-heading text-ink-1">{t('designSystem.chefAntoineDubois')}</p>
<p className="text-body-sm text-ink-3">{t('designSystem.12YearsServsafeBayArea')}</p>
<div className="flex gap-1.5 mt-2">
<StatusBadge status="hired" size="sm" />
<StatusBadge status="elite" size="sm" />
@@ -754,10 +756,10 @@ export default function DesignSystem() {
</div>
</div>
<div className="space-y-3">
<ScoreMeter label="Experience" value={99} />
<ScoreMeter label="Certifications" value={98} />
<ScoreMeter label="Reliability" value={96} />
<ScoreMeter label="Availability" value={94} />
<ScoreMeter label={t('designSystem.experience')} value={99} />
<ScoreMeter label={t('designSystem.certifications')} value={98} />
<ScoreMeter label={t('designSystem.reliability')} value={96} />
<ScoreMeter label={t('designSystem.availability')} value={94} />
</div>
</div>
</Drawer>

View File

@@ -4,6 +4,7 @@ import { base44 } from '@/api/base44Client';
import { Star, Briefcase, Mail, Phone, BadgeCheck, Award, UserCheck, Search, Calendar } from 'lucide-react';
import RateEndorseModal from '@/components/krow/RateEndorseModal';
import RetentionMetrics from '@/components/krow/RetentionMetrics';
import { useTranslation } from 'react-i18next';
const TIER_STYLES = {
Skilled: 'bg-[#EEF3FE] text-[#1E40AF]',
@@ -43,6 +44,7 @@ function Stars({ count, size = 14 }: StarsProps) {
}
export default function HiredHistory() {
const { t } = useTranslation();
const { data: staff = [], isLoading } = useStaff();
const { data: postings = [] } = useJobPostings();
const [search, setSearch] = useState('');
@@ -83,13 +85,13 @@ export default function HiredHistory() {
<div className="space-y-5">
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-3">
<div>
<h1 className="text-[22px] font-bold text-[#111827]">Hired History</h1>
<p className="text-[13px] text-[#6B7280] mt-0.5">Everyone you've hired — rate their work and endorse their skills.</p>
<h1 className="text-[22px] font-bold text-[#111827]">{t('hiredHistory.hiredHistory')}</h1>
<p className="text-[13px] text-[#6B7280] mt-0.5">{t('hiredHistory.everyoneYouveHiredRateTheirWork')}</p>
</div>
<div className="flex items-center gap-2">
<StatPill label="Total Hires" value={staff.length} />
<StatPill label="Avg Rating" value={avgRating} accent />
<StatPill label="Endorsed" value={endorsedCount} />
<StatPill label={t('hiredHistory.totalHires')} value={staff.length} />
<StatPill label={t('hiredHistory.avgRating')} value={avgRating} accent />
<StatPill label={t('hiredHistory.endorsed')} value={endorsedCount} />
</div>
</div>
@@ -103,20 +105,20 @@ export default function HiredHistory() {
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search hires by name, email, or role..."
placeholder={t('hiredHistory.searchHiresByNameEmailOr')}
className="w-full h-10 pl-9 pr-4 rounded-lg border border-[#E5E7EB] bg-white text-[13px] focus:border-[#0838E0] focus:outline-none"
/>
</div>
<select value={tierFilter} onChange={(e) => setTierFilter(e.target.value)} className="h-10 px-4 rounded-lg border border-[#E5E7EB] bg-white text-[13px] focus:border-[#0838E0] focus:outline-none">
<option value="all">All Tiers</option>
<option value="Skilled">Skilled</option>
<option value="Cross-Trained">Cross-Trained</option>
<option value="Beginner">Beginner</option>
<option value="all">{t('hiredHistory.allTiers')}</option>
<option value="Skilled">{t('hiredHistory.skilled')}</option>
<option value="Cross-Trained">{t('hiredHistory.crossTrained')}</option>
<option value="Beginner">{t('hiredHistory.beginner')}</option>
</select>
<select value={reviewFilter} onChange={(e) => setReviewFilter(e.target.value)} className="h-10 px-4 rounded-lg border border-[#E5E7EB] bg-white text-[13px] focus:border-[#0838E0] focus:outline-none">
<option value="all">All Reviews</option>
<option value="rated">Rated</option>
<option value="pending">Pending Review</option>
<option value="all">{t('hiredHistory.allReviews')}</option>
<option value="rated">{t('hiredHistory.rated')}</option>
<option value="pending">{t('hiredHistory.pendingReview')}</option>
</select>
</div>
@@ -128,8 +130,8 @@ export default function HiredHistory() {
) : sorted.length === 0 ? (
<div className="text-center py-16 bg-white border border-[#E5E7EB] rounded-xl">
<UserCheck className="w-8 h-8 text-[#D1D5DB] mx-auto mb-3" />
<p className="text-[14px] text-[#6B7280] font-medium">No hires yet</p>
<p className="text-[12px] text-[#9CA3AF] mt-1">Hired candidates will appear here so you can rate and endorse them.</p>
<p className="text-[14px] text-[#6B7280] font-medium">{t('hiredHistory.noHiresYet')}</p>
<p className="text-[12px] text-[#9CA3AF] mt-1">{t('hiredHistory.hiredCandidatesWillAppearHereSo')}</p>
</div>
) : (
<div className="space-y-3">
@@ -154,7 +156,7 @@ export default function HiredHistory() {
</div>
<div className="flex items-center gap-3 mt-1 text-[12px] text-[#6B7280] flex-wrap">
<span className="inline-flex items-center gap-1"><Briefcase className="w-3.5 h-3.5" />{jobTitle}</span>
{s.hire_date && <span className="inline-flex items-center gap-1"><Calendar className="w-3.5 h-3.5" />Hired {formatDate(s.hire_date)}</span>}
{s.hire_date && <span className="inline-flex items-center gap-1"><Calendar className="w-3.5 h-3.5" />{t('hiredHistory.hired')} {formatDate(s.hire_date)}</span>}
{s.email && <span className="inline-flex items-center gap-1"><Mail className="w-3.5 h-3.5" />{s.email}</span>}
{s.phone && <span className="inline-flex items-center gap-1"><Phone className="w-3.5 h-3.5" />{s.phone}</span>}
</div>
@@ -168,9 +170,9 @@ export default function HiredHistory() {
<span className="text-[13px] font-bold text-[#F59E0B]">{rating.toFixed(1)}</span>
</div>
) : (
<span className="text-[11px] text-[#9CA3AF] italic">Not yet rated</span>
<span className="text-[11px] text-[#9CA3AF] italic">{t('hiredHistory.notYetRated')}</span>
)}
{s.review_date && <p className="text-[10px] text-[#9CA3AF] mt-0.5">Reviewed {formatDate(s.review_date)}</p>}
{s.review_date && <p className="text-[10px] text-[#9CA3AF] mt-0.5">{t('hiredHistory.reviewed')} {formatDate(s.review_date)}</p>}
</div>
<button
onClick={() => setRateStaff(s)}

View File

@@ -10,11 +10,13 @@ import { toast } from 'react-hot-toast';
import { Camera, Loader2, RefreshCw, X, Sparkles, ArrowRight } from 'lucide-react';
import KrowIdentityCard from '@/components/krow/KrowIdentityCard';
import CareerPassport from '@/components/krow/talent/CareerPassport';
import { useTranslation } from 'react-i18next';
const AVAIL_OPTIONS = ['Weekdays', 'Weekends', 'Evenings', 'Mornings', 'Overnight', 'On-Call'];
const DEFAULT_CERTS = ['Food Handler Card', 'ServSafe', 'ABC License', 'TIPS Certified', 'CPR/First Aid', 'Background Check Cleared'];
export default function KrowIdentity() {
const { t } = useTranslation();
const navigate = useNavigate();
const { data: profile, isLoading } = useWorkerProfile();
const update = useUpdateWorkerProfile();
@@ -88,18 +90,18 @@ export default function KrowIdentity() {
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-5 sm:p-6 space-y-4">
<div className="flex items-center gap-4">
{form.selfie_url ? (
<img src={form.selfie_url} alt="Selfie" className="w-20 h-20 rounded-2xl object-cover border border-[#E5E7EB]" />
<img src={form.selfie_url} alt={t('krowIdentity.selfie')} className="w-20 h-20 rounded-2xl object-cover border border-[#E5E7EB]" />
) : (
<div className="w-20 h-20 rounded-2xl bg-[#F3F4F6] flex items-center justify-center">
<Camera className="w-7 h-7 text-[#9CA3AF]" />
</div>
)}
<div className="flex-1">
<Label className="text-[13px] text-[#374151]">Your face *</Label>
<p className="text-[11px] text-[#6B7280] mb-1.5">A quick selfie so employers recognize you. No resume, just you.</p>
<Label className="text-[13px] text-[#374151]">{t('krowIdentity.yourFace')}</Label>
<p className="text-[11px] text-[#6B7280] mb-1.5">{t('krowIdentity.aQuickSelfieSoEmployersRecognize')}</p>
{form.selfie_url ? (
<button type="button" onClick={() => set('selfie_url', '')} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-[#F3F4F6] hover:bg-[#E5E7EB] text-[#374151] text-[12px] font-medium">
<RefreshCw className="w-3.5 h-3.5" /> Retake
<RefreshCw className="w-3.5 h-3.5" /> {t('krowIdentity.retake')}
</button>
) : (
<label className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-[#0838E0] hover:bg-[#062BAF] text-white text-[12px] font-medium cursor-pointer">
@@ -111,47 +113,47 @@ export default function KrowIdentity() {
</div>
</div>
<div>
<Label className="text-[13px] text-[#374151]">Full Name *</Label>
<Label className="text-[13px] text-[#374151]">{t('krowIdentity.fullName')}</Label>
<Input value={form.full_name} onChange={e => set('full_name', e.target.value)} className="mt-1.5" />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div><Label className="text-[13px] text-[#374151]">Phone</Label><Input value={form.phone} onChange={e => set('phone', e.target.value)} className="mt-1.5" /></div>
<div><Label className="text-[13px] text-[#374151]">Location</Label><Input value={form.address} onChange={e => set('address', e.target.value)} placeholder="City, State" className="mt-1.5" /></div>
<div><Label className="text-[13px] text-[#374151]">{t('krowIdentity.phone')}</Label><Input value={form.phone} onChange={e => set('phone', e.target.value)} className="mt-1.5" /></div>
<div><Label className="text-[13px] text-[#374151]">{t('krowIdentity.location')}</Label><Input value={form.address} onChange={e => set('address', e.target.value)} placeholder={t('krowIdentity.cityState')} className="mt-1.5" /></div>
</div>
</div>
{/* About */}
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-5 sm:p-6 space-y-2">
<Label className="text-[13px] text-[#374151]">About you</Label>
<p className="text-[11px] text-[#6B7280]">One or two lines. What you do and what you're good at. This replaces the resume summary.</p>
<textarea value={form.career_goals} onChange={e => set('career_goals', e.target.value)} className="w-full min-h-[80px] p-3 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0838E0] focus:outline-none resize-none" placeholder="e.g. Line cook with 4 years in fast-casual kitchens. Fast, clean, reliable." />
<Label className="text-[13px] text-[#374151]">{t('krowIdentity.aboutYou')}</Label>
<p className="text-[11px] text-[#6B7280]">{t('krowIdentity.oneOrTwoLinesWhatYou')}</p>
<textarea value={form.career_goals} onChange={e => set('career_goals', e.target.value)} className="w-full min-h-[80px] p-3 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0838E0] focus:outline-none resize-none" placeholder={t('krowIdentity.eGLineCookWith4')} />
</div>
{/* Skills */}
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-5 sm:p-6">
<Label className="text-[13px] text-[#374151]">Top Skills</Label>
<Label className="text-[13px] text-[#374151]">{t('krowIdentity.topSkills')}</Label>
<div className="flex gap-2 mt-1.5">
<Input value={skillInput} onChange={e => setSkillInput(e.target.value)} placeholder="Add a skill..." className="flex-1"
<Input value={skillInput} onChange={e => setSkillInput(e.target.value)} placeholder={t('krowIdentity.addASkill')} className="flex-1"
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); if (skillInput.trim()) { set('skills', [...form.skills, skillInput.trim()]); setSkillInput(''); } } }} />
<Button type="button" variant="outline" onClick={() => { if (skillInput.trim()) { set('skills', [...form.skills, skillInput.trim()]); setSkillInput(''); } }}>Add</Button>
<Button type="button" variant="outline" onClick={() => { if (skillInput.trim()) { set('skills', [...form.skills, skillInput.trim()]); setSkillInput(''); } }}>{t('krowIdentity.add')}</Button>
</div>
<Chips items={form.skills} onRemove={(i) => set('skills', form.skills.filter((_, idx) => idx !== i))} />
</div>
{/* Languages */}
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-5 sm:p-6">
<Label className="text-[13px] text-[#374151]">Languages</Label>
<Label className="text-[13px] text-[#374151]">{t('krowIdentity.languages')}</Label>
<div className="flex gap-2 mt-1.5">
<Input value={langInput} onChange={e => setLangInput(e.target.value)} placeholder="e.g. English, Spanish" className="flex-1"
<Input value={langInput} onChange={e => setLangInput(e.target.value)} placeholder={t('krowIdentity.eGEnglishSpanish')} className="flex-1"
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); if (langInput.trim()) { set('languages', [...form.languages, langInput.trim()]); setLangInput(''); } } }} />
<Button type="button" variant="outline" onClick={() => { if (langInput.trim()) { set('languages', [...form.languages, langInput.trim()]); setLangInput(''); } }}>Add</Button>
<Button type="button" variant="outline" onClick={() => { if (langInput.trim()) { set('languages', [...form.languages, langInput.trim()]); setLangInput(''); } }}>{t('krowIdentity.add')}</Button>
</div>
<Chips items={form.languages} onRemove={(i) => set('languages', form.languages.filter((_, idx) => idx !== i))} tone="blue" />
</div>
{/* Availability */}
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-5 sm:p-6">
<Label className="text-[13px] text-[#374151]">Availability</Label>
<Label className="text-[13px] text-[#374151]">{t('krowIdentity.availability')}</Label>
<div className="flex flex-wrap gap-2 mt-1.5">
{AVAIL_OPTIONS.map(a => (
<button key={a} type="button" onClick={() => toggleAvail(a)} className={`px-3 py-1.5 rounded-full text-[12px] font-medium border transition-all ${form.availability.includes(a) ? 'bg-[#059669] border-[#059669] text-white' : 'border-[#E5E7EB] text-[#6B7280] hover:border-[#D1D5DB]'}`}>{a}</button>
@@ -161,7 +163,7 @@ export default function KrowIdentity() {
{/* Certifications */}
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-5 sm:p-6">
<Label className="text-[13px] text-[#374151]">Certifications</Label>
<Label className="text-[13px] text-[#374151]">{t('krowIdentity.certifications')}</Label>
<div className="flex flex-wrap gap-2 mt-1.5">
{certOptions.map(c => (
<button key={c} type="button" onClick={() => toggleCert(c)} className={`px-3 py-1.5 rounded-full text-[12px] font-medium border transition-all ${form.certifications.includes(c) ? 'bg-[#0838E0] border-[#0838E0] text-white' : 'border-[#E5E7EB] text-[#6B7280] hover:border-[#D1D5DB]'}`}>{c}</button>
@@ -171,7 +173,7 @@ export default function KrowIdentity() {
<Button onClick={save} disabled={update.isPending} className="w-full h-11 text-[14px] bg-[#0838E0] hover:bg-[#062BAF]">
{update.isPending ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Sparkles className="w-4 h-4 mr-2" />}
Create Identity <ArrowRight className="w-4 h-4 ml-1" />
{t('krowIdentity.createIdentity')} <ArrowRight className="w-4 h-4 ml-1" />
</Button>
</div>
);

View File

@@ -6,6 +6,7 @@ import {
} from 'lucide-react';
import { useWorkerProfile, useJobPostings } from '@/lib/krowHooks';
import { computeJobMatch } from '@/lib/krowScore';
import { useTranslation } from 'react-i18next';
/**
* OpportunityDetail — the full-page Employee job detail.
@@ -57,6 +58,7 @@ function Section({ title, children }: SectionProps) {
}
export default function OpportunityDetail() {
const { t } = useTranslation();
const { id } = useParams();
const navigate = useNavigate();
const { data: profile, isLoading: loadingProfile } = useWorkerProfile();
@@ -74,12 +76,12 @@ export default function OpportunityDetail() {
<div className="w-12 h-12 rounded-2xl bg-[#F9FAFB] text-[#9CA3AF] flex items-center justify-center mx-auto mb-3">
<Compass className="w-6 h-6" />
</div>
<p className="text-[15px] font-semibold text-[#0F172A]">This opportunity is no longer available.</p>
<p className="text-[15px] font-semibold text-[#0F172A]">{t('opportunityDetail.thisOpportunityIsNoLongerAvailable')}</p>
<button
onClick={() => navigate('/employee')}
className="mt-5 inline-flex items-center gap-1.5 text-[13px] font-semibold text-[#0838E0] hover:text-[#062BAF]"
>
<ArrowLeft className="w-4 h-4" /> Back to Opportunities
<ArrowLeft className="w-4 h-4" /> {t('opportunityDetail.backToOpportunities')}
</button>
</div>
);
@@ -100,7 +102,7 @@ export default function OpportunityDetail() {
onClick={() => navigate('/employee')}
className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[#6B7280] hover:text-[#111827]"
>
<ArrowLeft className="w-4 h-4" /> Back to Opportunities
<ArrowLeft className="w-4 h-4" /> {t('opportunityDetail.backToOpportunities')}
</button>
{/* Header — title-first identity, no company name. */}
@@ -113,14 +115,14 @@ export default function OpportunityDetail() {
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-[22px] font-bold text-[#0F172A] leading-tight">{job.title}</h1>
{isHiring && (
<span className="inline-flex items-center text-[11px] font-semibold text-white bg-[#0838E0] px-2.5 py-1 rounded-full">Hiring</span>
<span className="inline-flex items-center text-[11px] font-semibold text-white bg-[#0838E0] px-2.5 py-1 rounded-full">{t('opportunityDetail.hiring')}</span>
)}
</div>
{job.role_category && <p className="mt-1 text-[14px] text-[#6B7280] capitalize">{job.role_category}</p>}
<div className="mt-4 flex flex-wrap items-center gap-x-5 gap-y-2">
<span className="inline-flex items-center gap-1.5 text-[14px] font-bold text-[#0838E0]">
<Sparkles className="w-4 h-4" /> {match}% match
<Sparkles className="w-4 h-4" /> {match}{t('opportunityDetail.match')}
</span>
<span className="inline-flex items-center gap-1.5 text-[13px] text-[#6B7280]">
<MapPin className="w-4 h-4 text-[#9CA3AF]" /> {job.location || 'Remote'}
@@ -136,15 +138,15 @@ export default function OpportunityDetail() {
</div>
{/* About this opportunity */}
<Section title="About this opportunity">
<Section title={t('opportunityDetail.aboutThisOpportunity')}>
{job.description ? (
<p className="text-[13px] text-[#6B7280] whitespace-pre-wrap leading-relaxed">{job.description}</p>
) : (
<p className="text-[13px] text-[#9CA3AF]">No description provided for this opportunity yet.</p>
<p className="text-[13px] text-[#9CA3AF]">{t('opportunityDetail.noDescriptionProvidedForThisOpportunity')}</p>
)}
{job.responsibilities?.length > 0 && (
<div className="mt-4">
<p className="text-[13px] font-semibold text-[#0F172A] mb-1.5">What you'll do</p>
<p className="text-[13px] font-semibold text-[#0F172A] mb-1.5">{t('opportunityDetail.whatYoullDo')}</p>
<ul className="list-disc pl-5 space-y-1 text-[13px] text-[#6B7280]">
{job.responsibilities.map((r, i) => <li key={i}>{r}</li>)}
</ul>
@@ -152,7 +154,7 @@ export default function OpportunityDetail() {
)}
{job.qualifications?.length > 0 && (
<div className="mt-4">
<p className="text-[13px] font-semibold text-[#0F172A] mb-1.5">What they're looking for</p>
<p className="text-[13px] font-semibold text-[#0F172A] mb-1.5">{t('opportunityDetail.whatTheyreLookingFor')}</p>
<ul className="list-disc pl-5 space-y-1 text-[13px] text-[#6B7280]">
{job.qualifications.map((q, i) => <li key={i}>{q}</li>)}
</ul>
@@ -161,7 +163,7 @@ export default function OpportunityDetail() {
</Section>
{/* Why you're a match */}
<Section title="Why you're a match">
<Section title={t('opportunityDetail.whyYoureAMatch')}>
<div className="flex items-center gap-3 mb-4">
<div className="flex-1 h-2 rounded-full bg-[#F3F4F6] overflow-hidden">
<div className="h-full rounded-full bg-[#0838E0]" style={{ width: `${match}%` }} />
@@ -173,26 +175,26 @@ export default function OpportunityDetail() {
<li className="flex items-start gap-2 text-[13px] text-[#374151]">
<Clock className="w-4 h-4 text-[#0838E0] mt-0.5 shrink-0" />
<span>
{exp} yr{exp === 1 ? '' : 's'} of experience
{reqExp > 0 && <> · role asks for {reqExp}+ {meetsExp ? '— you qualify' : ''}</>}
{exp} yr{exp === 1 ? '' : 's'} {t('opportunityDetail.ofExperience')}
{reqExp > 0 && <> {t('opportunityDetail.roleAsksFor')} {reqExp}+ {meetsExp ? '— you qualify' : ''}</>}
</span>
</li>
{skills.length > 0 && (
<li className="flex items-start gap-2 text-[13px] text-[#374151]">
<CheckCircle2 className="w-4 h-4 text-[#16A34A] mt-0.5 shrink-0" />
<span>Your skills match: {skills.join(', ')}</span>
<span>{t('opportunityDetail.yourSkillsMatch')} {skills.join(', ')}</span>
</li>
)}
{certs.length > 0 && (
<li className="flex items-start gap-2 text-[13px] text-[#374151]">
<Award className="w-4 h-4 text-[#0838E0] mt-0.5 shrink-0" />
<span>Certifications you hold: {certs.join(', ')}</span>
<span>{t('opportunityDetail.certificationsYouHold')} {certs.join(', ')}</span>
</li>
)}
{skills.length === 0 && certs.length === 0 && (
<li className="flex items-start gap-2 text-[13px] text-[#6B7280]">
<Sparkles className="w-4 h-4 text-[#0838E0] mt-0.5 shrink-0" />
<span>You showed up here because your profile aligns with this role. Add skills to strengthen the match.</span>
<span>{t('opportunityDetail.youShowedUpHereBecauseYour')}</span>
</li>
)}
</ul>
@@ -205,7 +207,7 @@ export default function OpportunityDetail() {
className="inline-flex items-center justify-center gap-2 h-11 px-8 rounded-full bg-[#0838E0] text-white text-[14px] font-semibold hover:bg-[#062BAF] transition-colors
focus:outline-none focus-visible:ring-2 focus-visible:ring-[#0838E0]/40"
>
Apply <Sparkles className="w-4 h-4" />
{t('opportunityDetail.apply')} <Sparkles className="w-4 h-4" />
</button>
</div>
</div>

View File

@@ -4,6 +4,7 @@ import { motion } from 'framer-motion';
import { useJobPostings, useApplications } from '@/lib/krowHooks';
import KROWCandidateLeaderboardReal from '@/components/krow/KROWCandidateLeaderboard';
import { Briefcase, Users, Brain, Clock, TrendingUp, Award, ChevronRight } from 'lucide-react';
import { useTranslation } from 'react-i18next';
const METRICS = [
{ icon: Briefcase, label: 'Open Positions', bg: 'bg-[#0838E0]', glow: 'shadow-blue-600/20' },
@@ -53,6 +54,7 @@ function MetricCard({ icon: Icon, value, label, bg, glow, index }: MetricCardPro
}
export default function Overview() {
const { t } = useTranslation();
const navigate = useNavigate();
const { data: postings = [] } = useJobPostings();
const { data: applications = [] } = useApplications();
@@ -88,9 +90,9 @@ export default function Overview() {
>
<div className="min-w-0">
<h1 className="text-2xl sm:text-3xl font-heading font-bold text-[#111827]">
Good day. <span className="gradient-text">Let's hire smarter.</span>
{t('overview.goodDay')} <span className="gradient-text">{t('overview.letsHireSmarter')}</span>
</h1>
<p className="text-[13px] sm:text-[14px] text-[#6B7280] mt-1">AI-powered hiring · No quizzes · Real readiness</p>
<p className="text-[13px] sm:text-[14px] text-[#6B7280] mt-1">{t('overview.aiPoweredHiringNoQuizzesReal')}</p>
</div>
</motion.div>
@@ -111,9 +113,9 @@ export default function Overview() {
className="glass-card border border-white/60 rounded-2xl p-6 shadow-sm"
>
<div className="flex items-center justify-between mb-5">
<h3 className="font-heading font-semibold text-[#111827] text-[16px]">Top Candidates</h3>
<h3 className="font-heading font-semibold text-[#111827] text-[16px]">{t('overview.topCandidates')}</h3>
<button onClick={() => navigate('/candidates')} className="text-[12px] text-[#0838E0] hover:underline inline-flex items-center gap-1 font-medium">
View all <ChevronRight className="w-3 h-3" />
{t('overview.viewAll')} <ChevronRight className="w-3 h-3" />
</button>
</div>
<div className="space-y-1">
@@ -122,7 +124,7 @@ export default function Overview() {
<div className="w-12 h-12 rounded-full bg-white/50 flex items-center justify-center mx-auto mb-3">
<Users className="w-5 h-5 text-[#9CA3AF]" />
</div>
<p className="text-[13px] text-[#9CA3AF]">No screened candidates yet</p>
<p className="text-[13px] text-[#9CA3AF]">{t('overview.noScreenedCandidatesYet')}</p>
</div>
)}
{topCandidates.map((app, idx) => (
@@ -161,9 +163,9 @@ export default function Overview() {
className="glass-card border border-white/60 rounded-2xl p-6 shadow-sm"
>
<div className="flex items-center justify-between mb-5">
<h3 className="font-heading font-semibold text-[#111827] text-[16px]">Open Positions</h3>
<h3 className="font-heading font-semibold text-[#111827] text-[16px]">{t('overview.openPositions')}</h3>
<button onClick={() => navigate('/positions')} className="text-[12px] text-[#0838E0] hover:underline inline-flex items-center gap-1 font-medium">
View all <ChevronRight className="w-3 h-3" />
{t('overview.viewAll')} <ChevronRight className="w-3 h-3" />
</button>
</div>
<div className="space-y-1">
@@ -172,7 +174,7 @@ export default function Overview() {
<div className="w-12 h-12 rounded-full bg-white/50 flex items-center justify-center mx-auto mb-3">
<Briefcase className="w-5 h-5 text-[#9CA3AF]" />
</div>
<p className="text-[13px] text-[#9CA3AF]">No open positions</p>
<p className="text-[13px] text-[#9CA3AF]">{t('overview.noOpenPositions')}</p>
</div>
)}
{activePostings.map((posting, idx) => {
@@ -190,7 +192,7 @@ export default function Overview() {
<div className="w-1 h-9 rounded-full bg-[#0838E0] shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-[13px] font-medium text-[#111827] truncate">{posting.title}</div>
<div className="text-[11px] text-[#6B7280]">{stats.applied} applicants · {stats.screened} screened</div>
<div className="text-[11px] text-[#6B7280]">{stats.applied} {t('overview.applicants')} {stats.screened} screened</div>
</div>
<ChevronRight className="w-4 h-4 text-[#D1D5DB] group-hover:text-[#0838E0] transition-colors" />
</motion.div>

View File

@@ -21,6 +21,7 @@ import SuggestedTalent from '@/components/krow/SuggestedTalent';
import { toast } from 'react-hot-toast';
import { cn } from '@/lib/utils';
import { UserPlus } from 'lucide-react';
import { useTranslation } from 'react-i18next';
const STATUS_ACTIONS = [
{ status: 'active', label: 'Hiring', color: 'bg-[#0838E0] text-white shadow-sm' },
@@ -42,6 +43,7 @@ interface WorkforceStatusProps {
}
function WorkforceStatus({ workforce, job }: WorkforceStatusProps) {
const { t } = useTranslation();
const { demand, existingAvailable, applicants, newToday, strong, interviewReady } = workforce;
/* Coverage figures depend on a declared headcount; the rest are facts about
@@ -67,7 +69,7 @@ function WorkforceStatus({ workforce, job }: WorkforceStatusProps) {
return (
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-6 space-y-5">
<div>
<h2 className="text-[15px] font-bold text-[#111827]">Workforce status</h2>
<h2 className="text-[15px] font-bold text-[#111827]">{t('positionDetail.workforceStatus')}</h2>
{/* `demandLabel` already states the start, so it is not repeated. */}
<p className="text-[12px] text-[#6B7280] mt-0.5">{demandLabel(job)}</p>
</div>
@@ -84,7 +86,7 @@ function WorkforceStatus({ workforce, job }: WorkforceStatusProps) {
<div className="grid gap-5 lg:grid-cols-2">
<section>
<h3 className="text-[11px] font-bold uppercase tracking-wide text-[#9CA3AF] mb-2">New today</h3>
<h3 className="text-[11px] font-bold uppercase tracking-wide text-[#9CA3AF] mb-2">{t('positionDetail.newToday')}</h3>
{newToday.length ? (
<ul className="divide-y divide-[#F3F4F6] rounded-xl border border-[#E5E7EB]">
{newToday.slice(0, 5).map((row) => (
@@ -92,7 +94,7 @@ function WorkforceStatus({ workforce, job }: WorkforceStatusProps) {
<div className="min-w-0">
<p className="truncate text-[13px] font-medium text-[#111827]">{row.name}</p>
<p className="truncate text-[11px] text-[#9CA3AF]">
Applied {new Date(row.application.created_date).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })}
{t('positionDetail.applied')} {new Date(row.application.created_date).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })}
{/* Availability is only known for people we hold a worker
profile for; an external applicant has none, and the
line simply stops rather than guessing at one. */}
@@ -108,12 +110,12 @@ function WorkforceStatus({ workforce, job }: WorkforceStatusProps) {
))}
</ul>
) : (
<p className="text-[13px] text-[#6B7280]">No applications today.</p>
<p className="text-[13px] text-[#6B7280]">{t('positionDetail.noApplicationsToday')}</p>
)}
</section>
<section>
<h3 className="text-[11px] font-bold uppercase tracking-wide text-[#9CA3AF] mb-2">Hiring pipeline</h3>
<h3 className="text-[11px] font-bold uppercase tracking-wide text-[#9CA3AF] mb-2">{t('positionDetail.hiringPipeline')}</h3>
<ul className="grid grid-cols-4 gap-2">
{pipeline.map((stage) => (
<li key={stage.label} className="rounded-xl border border-[#E5E7EB] px-2 py-2 text-center">
@@ -129,6 +131,7 @@ function WorkforceStatus({ workforce, job }: WorkforceStatusProps) {
}
export default function PositionDetail() {
const { t } = useTranslation();
const { id } = useParams();
const navigate = useNavigate();
const { data: job, isLoading } = useJobPosting(id);
@@ -291,14 +294,14 @@ export default function PositionDetail() {
}
if (!job) {
return <div className="text-center py-20"><p className="text-[14px] text-[#6B7280]">Position not found</p></div>;
return <div className="text-center py-20"><p className="text-[14px] text-[#6B7280]">{t('positionDetail.positionNotFound')}</p></div>;
}
return (
<div className="space-y-5">
{/* Back */}
<button onClick={() => navigate('/positions')} className="text-[13px] text-[#6B7280] hover:text-[#111827] font-medium">
‹ Back to Positions
{t('positionDetail.backToPositions')}
</button>
<SkillSurface page="positions" placement="after-header" context={{ position: job }} />
@@ -328,7 +331,7 @@ export default function PositionDetail() {
)}
{job.ai_generated && (
<span className="px-2 py-0.5 rounded-full bg-[#EEF3FE] text-[#0838E0] text-[11px] font-medium">
AI Built
{t('positionDetail.aiBuilt')}
</span>
)}
</div>
@@ -346,7 +349,7 @@ export default function PositionDetail() {
className="inline-flex items-center gap-1.5 bg-[#F9E547] text-[#0838E0] text-[12px] font-bold px-4 py-1.5 rounded-full hover:scale-[1.03] transition-transform"
>
<UserPlus className="w-4 h-4" />
Invite Applicants
{t('positionDetail.inviteApplicants')}
</button>
{STATUS_ACTIONS.map(s => (
<button
@@ -395,7 +398,7 @@ export default function PositionDetail() {
</div>
<div>
<div className="text-2xl font-bold text-[#111827]">{workforce.strong.length}</div>
<div className="text-[11px] text-[#9CA3AF] tracking-wide">STRONG MATCHES</div>
<div className="text-[11px] text-[#9CA3AF] tracking-wide">{t('positionDetail.strongMatches')}</div>
</div>
<div>
<div className="text-2xl font-bold text-[#0838E0]">{workforce.hired.length}</div>
@@ -410,20 +413,20 @@ export default function PositionDetail() {
state the record identically. */}
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-6 space-y-5">
<div>
<h2 className="text-[15px] font-bold text-[#111827]">Position details</h2>
<p className="text-[12px] text-[#6B7280] mt-0.5">The role as it was defined when this position was created.</p>
<h2 className="text-[15px] font-bold text-[#111827]">{t('positionDetail.positionDetails')}</h2>
<p className="text-[12px] text-[#6B7280] mt-0.5">{t('positionDetail.theRoleAsItWasDefined')}</p>
</div>
<PositionOverview position={job} />
{/* Role-specific, so present only when this role states them. */}
{hasRequirements(job) && (
<div>
<h3 className="text-[11px] font-bold uppercase tracking-wide text-[#9CA3AF] mb-2">Requirements</h3>
<h3 className="text-[11px] font-bold uppercase tracking-wide text-[#9CA3AF] mb-2">{t('positionDetail.requirements')}</h3>
<PositionRequirements position={job} />
</div>
)}
{customRequirementsText(job) && (
<div>
<h3 className="text-[11px] font-bold uppercase tracking-wide text-[#9CA3AF] mb-2">Custom requirements</h3>
<h3 className="text-[11px] font-bold uppercase tracking-wide text-[#9CA3AF] mb-2">{t('positionDetail.customRequirements')}</h3>
<PositionCustomRequirements position={job} />
</div>
)}
@@ -471,12 +474,12 @@ export default function PositionDetail() {
{job.description && (
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-6">
<h3 className="text-[14px] font-semibold text-[#111827] mb-2">
Job Description
{t('positionDetail.jobDescription')}
</h3>
<p className="text-[13px] text-[#6B7280] whitespace-pre-wrap leading-relaxed">{job.description}</p>
{job.responsibilities?.length > 0 && (
<div className="mt-4">
<p className="text-[12px] font-semibold text-[#374151] mb-1.5">Responsibilities</p>
<p className="text-[12px] font-semibold text-[#374151] mb-1.5">{t('positionDetail.responsibilities')}</p>
<ul className="text-[13px] text-[#6B7280] list-disc list-inside space-y-1">
{job.responsibilities.map((r, i) => <li key={i}>{r}</li>)}
</ul>
@@ -490,7 +493,7 @@ export default function PositionDetail() {
{/* Applicants */}
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-[15px] font-semibold text-[#111827]">Applicants ({jobApps.length})</h3>
<h3 className="text-[15px] font-semibold text-[#111827]">{t('positionDetail.applicants')}{jobApps.length})</h3>
<button
onClick={handleScreenAll}
disabled={screenAll.isPending}
@@ -502,8 +505,8 @@ export default function PositionDetail() {
{sortedApps.length === 0 ? (
<div className="text-center py-12">
<p className="text-[14px] text-[#6B7280] mb-1">No applicants yet</p>
<p className="text-[12px] text-[#9CA3AF]">Candidates can apply from the Apply page</p>
<p className="text-[14px] text-[#6B7280] mb-1">{t('positionDetail.noApplicantsYet')}</p>
<p className="text-[12px] text-[#9CA3AF]">{t('positionDetail.candidatesCanApplyFromTheApply')}</p>
</div>
) : (
<div className="space-y-3">

View File

@@ -5,8 +5,10 @@ import { useJobPostings, useApplications } from '@/lib/krowHooks';
import { base44 } from '@/api/base44Client';
import KROWJobPostingCard from '@/components/krow/KROWJobPostingCard';
import CertificationManager from '@/components/krow/CertificationManager';
import { useTranslation } from 'react-i18next';
export default function Positions() {
const { t } = useTranslation();
const navigate = useNavigate();
const { data: postings = [], isLoading } = useJobPostings();
const { data: applications = [] } = useApplications();
@@ -36,8 +38,8 @@ export default function Positions() {
return (
<div className="space-y-5">
<div>
<h1 className="text-[22px] font-bold text-[#111827]">Positions</h1>
<p className="text-[13px] text-[#6B7280] mt-0.5">{postings.length} job postings</p>
<h1 className="text-[22px] font-bold text-[#111827]">{t('positions.positions')}</h1>
<p className="text-[13px] text-[#6B7280] mt-0.5">{postings.length} {t('positions.jobPostings')}</p>
</div>
{/* Action Bar */}
@@ -47,7 +49,7 @@ export default function Positions() {
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search positions..."
placeholder={t('positions.searchPositions')}
className="w-full h-9 pl-10 pr-4 rounded-full border border-[#E5E7EB] bg-white text-[13px] text-[#111827] shadow-2xs hover:border-[#D1D5DB] focus:border-[#2563EB] focus:ring-2 focus:ring-[#2563EB]/20 focus:outline-none transition-all"
/>
</div>
@@ -56,18 +58,18 @@ export default function Positions() {
onChange={(e) => setStatusFilter(e.target.value)}
className="h-9 px-3.5 rounded-full border border-[#E5E7EB] bg-white text-[13px] text-[#1F2937] shadow-2xs hover:border-[#D1D5DB] focus:border-[#2563EB] focus:outline-none transition-all cursor-pointer"
>
<option value="all">All</option>
<option value="active">Hiring</option>
<option value="draft">Draft</option>
<option value="paused">Paused</option>
<option value="closed">Closed</option>
<option value="all">{t('positions.all')}</option>
<option value="active">{t('positions.hiring')}</option>
<option value="draft">{t('positions.draft')}</option>
<option value="paused">{t('positions.paused')}</option>
<option value="closed">{t('positions.closed')}</option>
</select>
{isAdmin && <CertificationManager />}
<button
onClick={() => navigate('/positions/new')}
className="inline-flex items-center justify-center gap-1.5 bg-[#111827] hover:bg-[#1F2937] text-white text-[13px] font-medium px-4 h-9 rounded-full shadow-xs transition-colors whitespace-nowrap"
>
<Plus className="w-4 h-4" /> New Position
<Plus className="w-4 h-4" /> {t('positions.newPosition')}
</button>
</div>
@@ -78,9 +80,9 @@ export default function Positions() {
</div>
) : filtered.length === 0 ? (
<div className="text-center py-16">
<p className="text-[14px] text-[#6B7280] mb-3">No positions found</p>
<p className="text-[14px] text-[#6B7280] mb-3">{t('positions.noPositionsFound')}</p>
<button onClick={() => navigate('/positions/new')} className="inline-flex items-center gap-1.5 bg-[#2563EB] text-white text-[13px] font-medium px-4 py-2 rounded-lg">
<Plus className="w-4 h-4" /> Create your first position
<Plus className="w-4 h-4" /> {t('positions.createYourFirstPosition')}
</button>
</div>
) : (

View File

@@ -3,6 +3,7 @@ import { base44 } from '@/api/base44Client';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Shield, Calendar, Briefcase, LogOut, Activity as ActivityIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
const EVENT_LABELS = {
signup: 'Signed up',
@@ -17,6 +18,7 @@ const EVENT_LABELS = {
};
export default function Profile() {
const { t } = useTranslation();
const [user, setUser] = useState(null);
const [activities, setActivities] = useState([]);
const [loading, setLoading] = useState(true);
@@ -46,7 +48,7 @@ export default function Profile() {
}
if (!user) {
return <div className="text-center py-20 text-[#6B7280]">Unable to load profile.</div>;
return <div className="text-center py-20 text-[#6B7280]">{t('profile.unableToLoadProfile')}</div>;
}
const initials = (user.full_name || user.email || 'U')
@@ -73,19 +75,19 @@ export default function Profile() {
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
<Card className="p-4">
<div className="flex items-center gap-2 text-[#6B7280] text-xs font-medium uppercase mb-1">
<Shield className="w-3.5 h-3.5" /> Role
<Shield className="w-3.5 h-3.5" /> {t('profile.role')}
</div>
<div className="text-[#111827] font-semibold capitalize">{user.role || 'user'}</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-2 text-[#6B7280] text-xs font-medium uppercase mb-1">
<Briefcase className="w-3.5 h-3.5" /> Account Type
<Briefcase className="w-3.5 h-3.5" /> {t('profile.accountType')}
</div>
<div className="text-[#111827] font-semibold capitalize">{user.account_type || 'applicant'}</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-2 text-[#6B7280] text-xs font-medium uppercase mb-1">
<Calendar className="w-3.5 h-3.5" /> Member Since
<Calendar className="w-3.5 h-3.5" /> {t('profile.memberSince')}
</div>
<div className="text-[#111827] font-semibold">
{user.created_date ? new Date(user.created_date).toLocaleDateString() : '—'}
@@ -96,10 +98,10 @@ export default function Profile() {
{/* Recent activity */}
<div className="mb-8">
<h2 className="text-sm font-semibold text-[#374151] uppercase mb-3 flex items-center gap-2">
<ActivityIcon className="w-4 h-4" /> Recent Activity
<ActivityIcon className="w-4 h-4" /> {t('profile.recentActivity')}
</h2>
{activities.length === 0 ? (
<p className="text-[#9CA3AF] text-sm">No recent activity.</p>
<p className="text-[#9CA3AF] text-sm">{t('profile.noRecentActivity')}</p>
) : (
<div className="space-y-1">
{activities.map((a) => (
@@ -124,7 +126,7 @@ export default function Profile() {
className="text-red-600 border-red-200 hover:bg-red-50 hover:text-red-700"
>
<LogOut className="w-4 h-4 mr-2" />
Log out
{t('profile.logOut')}
</Button>
</div>
);

View File

@@ -4,8 +4,10 @@ import TalentPoolCard from '@/components/krow/TalentPoolCard';
import TalentDetailModal from '@/components/krow/TalentDetailModal';
import MessageCandidateModal from '@/components/krow/MessageCandidateModal';
import { Users } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export default function TalentPool() {
const { t } = useTranslation();
const { data: profiles = [], isLoading } = useWorkerProfiles();
const [search, setSearch] = useState('');
const [scoreFilter, setScoreFilter] = useState('all');
@@ -49,8 +51,8 @@ export default function TalentPool() {
<Users className="w-5 h-5 text-[#0838E0]" />
</div>
<div>
<h1 className="text-[22px] font-bold text-[#111827]">Talent Pool</h1>
<p className="text-[12px] text-[#6B7280]">Browse all KROW talent and contact them directly</p>
<h1 className="text-[22px] font-bold text-[#111827]">{t('talentPool.talentPool')}</h1>
<p className="text-[12px] text-[#6B7280]">{t('talentPool.browseAllKrowTalentAndContact')}</p>
</div>
</div>
<span className="text-[13px] text-[#6B7280]">{profiles.length} talents</span>
@@ -79,7 +81,7 @@ export default function TalentPool() {
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by name, skill, or desired role..."
placeholder={t('talentPool.searchByNameSkillOrDesired')}
className="flex-1 h-10 px-4 rounded-lg border border-[#E5E7EB] bg-white text-[13px] focus:border-[#0838E0] focus:outline-none"
/>
<select
@@ -87,11 +89,11 @@ export default function TalentPool() {
onChange={(e) => setScoreFilter(e.target.value)}
className="h-10 px-4 rounded-lg border border-[#E5E7EB] bg-white text-[13px] focus:border-[#0838E0] focus:outline-none"
>
<option value="all">All Scores</option>
<option value="elite">Elite (90+)</option>
<option value="excellent">Excellent (75-89)</option>
<option value="solid">Solid (60-74)</option>
<option value="building">Building (1-59)</option>
<option value="all">{t('talentPool.allScores')}</option>
<option value="elite">{t('talentPool.elite90')}</option>
<option value="excellent">{t('talentPool.excellent7589')}</option>
<option value="solid">{t('talentPool.solid6074')}</option>
<option value="building">{t('talentPool.building159')}</option>
</select>
</div>
@@ -102,7 +104,7 @@ export default function TalentPool() {
</div>
) : filtered.length === 0 ? (
<div className="text-center py-16">
<p className="text-[14px] text-[#6B7280]">No talent matches your filters</p>
<p className="text-[14px] text-[#6B7280]">{t('talentPool.noTalentMatchesYourFilters')}</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">

View File

@@ -11,6 +11,7 @@ import { SectionTitle } from '@/components/admin/PageShell';
import { FilterSelect } from '@/pages/admin/Positions';
import { completedCourseIds, skillStates } from '@/lib/skillGraph';
import { SkillSurface } from '@/components/skills/SkillSurface';
import { useTranslation } from 'react-i18next';
/**
* KROW Forge — workforce skill verification, from the managing side.
@@ -39,6 +40,7 @@ import { SkillSurface } from '@/components/skills/SkillSurface';
const STATUS_ORDER = ['Published', 'Draft', 'Archived'];
export default function University() {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const panel = useAssistantPanel();
@@ -214,17 +216,17 @@ export default function University() {
is next", which is the question both audiences arrive with. */}
<section className="space-y-4">
<SectionTitle
title="Skill progression"
title={t('university.skillProgression')}
meta={person ? `${person.full_name} · ${states.filter((s) => s.verifiedLevel).length} of ${states.length} skills verified` : undefined}
/>
{profiles.length > 1 && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-caption text-ink-4">Progress for</span>
<span className="text-caption text-ink-4">{t('university.progressFor')}</span>
<FilterSelect
value={person?.id || ''}
onChange={setPersonId}
label="Worker"
label={t('university.worker')}
options={profiles.map((p) => ({ value: p.id, label: p.full_name }))}
className="min-w-[11rem]"
/>

View File

@@ -5,8 +5,10 @@ import ActivityCharts from '@/components/krow/ActivityCharts';
import ActivityLogTable from '@/components/krow/ActivityLogTable';
import { Loader2 } from 'lucide-react';
import { AssistantTrigger } from '@/components/ai-assistant';
import { useTranslation } from 'react-i18next';
export default function UserTracking() {
const { t } = useTranslation();
const { data: activities = [], isLoading } = useUserActivity();
if (isLoading) {
@@ -21,9 +23,9 @@ export default function UserTracking() {
<div className="space-y-6">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-[22px] font-bold text-[#111827]">User Tracking</h1>
<h1 className="text-[22px] font-bold text-[#111827]">{t('userTracking.userTracking')}</h1>
<p className="text-[13px] text-[#6B7280] mt-0.5">
Sign-ups, logins, and key actions across your platform
{t('userTracking.signUpsLoginsAndKeyActions')}
</p>
</div>
<AssistantTrigger />

View File

@@ -3,8 +3,10 @@ import { useWorkerProfile } from '@/lib/krowHooks';
import IdentityCareerCard from '@/components/krow/talent/IdentityCareerCard';
import CareerPassport from '@/components/krow/talent/CareerPassport';
import { Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export default function WorkerProfile() {
const { t } = useTranslation();
const { data: profile, isLoading } = useWorkerProfile();
if (isLoading || !profile) {
@@ -14,8 +16,8 @@ export default function WorkerProfile() {
return (
<div className="max-w-5xl mx-auto">
<div className="mb-6 text-center">
<h1 className="text-[22px] font-bold text-[#0F172A] tracking-tight">My KROW ID</h1>
<p className="text-[13px] text-[#6B7280] mt-0.5">Your verified, portable career identity.</p>
<h1 className="text-[22px] font-bold text-[#0F172A] tracking-tight">{t('workerProfile.myKrowId')}</h1>
<p className="text-[13px] text-[#6B7280] mt-0.5">{t('workerProfile.yourVerifiedPortableCareerIdentity')}</p>
</div>
<IdentityCareerCard profile={profile} />
<div className="mt-6">

View File

@@ -7,6 +7,7 @@ import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
import { UiEditor } from '@/components/ui-editor/UiEditor';
import '@/components/ui-tree/nodeTypes';
import '@/pages/admin/activity/nodes';
import { useTranslation } from 'react-i18next';
/**
* Admin Activity — an audit centre.
@@ -42,6 +43,7 @@ const ipFor = (email) => {
};
export default function AdminActivity() {
const { t } = useTranslation();
const { data: activity = [], isLoading } = useUserActivity();
const [search, setSearch] = useState('');
@@ -133,8 +135,8 @@ export default function AdminActivity() {
return (
<AdminPage
title="Activity"
subtitle="Monitor workforce operations, user actions and system events."
title={t('activity.activity')}
subtitle={t('activity.monitorWorkforceOperationsUserActionsAnd')}
>
{/* The editing session is mounted by the layout, so the panel beside this
page shares it. The page only renders what it is handed, which is why

View File

@@ -20,6 +20,7 @@ import { AgentTestPanel } from '@/components/agents/AgentTestPanel';
import { AgentInsightsPanel } from '@/components/agents/AgentInsightsPanel';
import { useAssistantPanel } from '@/components/ai-assistant';
import { boardCatalog, skillCatalog } from '@/lib/skills/catalog';
import { useTranslation } from 'react-i18next';
/**
* One agent, configured.
@@ -75,6 +76,7 @@ function skillNotice({ entry, id, agentName, attached }) {
}
export default function AdminAgentDetail() {
const { t } = useTranslation();
const { id } = useParams();
const navigate = useNavigate();
const preferences = usePreferences();
@@ -266,8 +268,8 @@ export default function AdminAgentDetail() {
return (
<div className="mx-auto w-full max-w-container">
<EmptyState
title="No such agent"
description="It may have been removed, or the address may be wrong."
title={t('agentDetail.noSuchAgent')}
description={t('agentDetail.itMayHaveBeenRemovedOr')}
action={{ label: 'Back to agents', onClick: () => navigate('/admin/workspace/agents') }}
/>
</div>
@@ -309,7 +311,7 @@ export default function AdminAgentDetail() {
text-ink-4 transition-colors duration-base hover:text-krow-blue
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/40`}
>
<ArrowLeft className="h-3.5 w-3.5" aria-hidden="true" /> Agents
<ArrowLeft className="h-3.5 w-3.5" aria-hidden="true" /> {t('agentDetail.agents')}
</button>
<div className="mt-1.5 mb-1 flex flex-wrap items-start gap-x-4 gap-y-2.5">
@@ -339,8 +341,8 @@ export default function AdminAgentDetail() {
</Badge>
</>
)}
{!agent && <Badge variant="neutral">Draft</Badge>}
{shipped && isOverridden(id) && <Badge variant="info">Customized</Badge>}
{!agent && <Badge variant="neutral">{t('agentDetail.draft')}</Badge>}
{shipped && isOverridden(id) && <Badge variant="info">{t('agentDetail.customized')}</Badge>}
</div>
<p className="mt-0.5 line-clamp-2 max-w-[80ch] text-body-sm text-ink-3">
@@ -374,7 +376,7 @@ export default function AdminAgentDetail() {
</span>
<Button variant="outline" size="sm" onClick={persist} disabled={!dirty} loading={saving}>
<Save className="h-3.5 w-3.5" /> Save
<Save className="h-3.5 w-3.5" /> {t('agentDetail.save')}
</Button>
<Button size="sm" onClick={onPublish} loading={saving}>
<Send className="h-3.5 w-3.5" />
@@ -390,9 +392,7 @@ export default function AdminAgentDetail() {
<p className="mt-2 flex max-w-[86ch] items-start gap-2 text-caption leading-relaxed text-ink-3">
<Info className="mt-px h-3.5 w-3.5 shrink-0 text-krow-blue" aria-hidden="true" />
<span>
This agent ships with Krow. Your changes are saved as this workspace&apos;s own
version and take over from the shipped one — the shipped definition is never
altered, and reverting brings it back.
{t('agentDetail.thisAgentShipsWithKrowYour')}
</span>
</p>
)}

View File

@@ -8,6 +8,7 @@ import { UiEditor } from '@/components/ui-editor/UiEditor';
import { composePage } from '@/lib/ui/composition';
import '@/components/ui-tree/nodeTypes';
import '@/pages/admin/analytics/nodes';
import { useTranslation } from 'react-i18next';
import {
buildEfficiency, buildFunnel, buildInsights, buildTrend, byDepartment, byPosition,
buildHires, summarise,
@@ -36,6 +37,7 @@ import {
*/
export default function AdminAnalytics() {
const { t } = useTranslation();
const { data: staff = [], isLoading } = useStaff();
const { data: applications = [] } = useApplications();
const { data: postings = [] } = useJobPostings();
@@ -62,8 +64,8 @@ export default function AdminAnalytics() {
return (
<AdminPage
title="Analytics"
subtitle="How hiring is performing — rates, trends, comparison and what to act on."
title={t('analytics.analytics')}
subtitle={t('analytics.howHiringIsPerformingRatesTrends')}
>
<UiEditor />
<AnalyticsComposition context={context} />

View File

@@ -23,6 +23,7 @@ import AIInterviewModal from '@/components/krow/AIInterviewModal';
import ScheduleInterviewModal from '@/components/krow/ScheduleInterviewModal';
import MessageCandidateModal from '@/components/krow/MessageCandidateModal';
import { usePageSkills } from '@/lib/skills/usePageSkills';
import { useTranslation } from 'react-i18next';
import {
ALL_DIMENSIONS, SCORE_GROUPS, activityTimeline, careerScore, careerTier,
communicationLabel, endorsements, marketValue, nextPromotion, recommendationVerdict,
@@ -185,6 +186,7 @@ function Pill({ index = 0, className = '', children }) {
/** Identity, and the position this person is being read against. */
function Hero({ candidate, tier, viewingFor, onBack }) {
const { t } = useTranslation();
const c = candidate;
const scored = c.ai_score > 0;
@@ -198,7 +200,7 @@ function Hero({ candidate, tier, viewingFor, onBack }) {
hover:text-krow-blue focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
<ArrowLeft className="h-3.5 w-3.5" aria-hidden="true" />
All candidates
{t('candidateProfile.allCandidates')}
</button>
<div className="flex min-w-0 flex-col items-start gap-3 sm:flex-row sm:items-center sm:gap-4">
@@ -255,7 +257,7 @@ function Hero({ candidate, tier, viewingFor, onBack }) {
{viewingFor && (
<p className="inline-flex shrink-0 flex-wrap items-center gap-1.5 rounded-full bg-krow-blue-tint px-3 py-1.5 text-body-sm text-krow-blue sm:self-start">
<span className="text-[10px] font-semibold uppercase tracking-[0.08em] text-krow-blue/70">
Viewing for
{t('candidateProfile.viewingFor')}
</span>
<span className="font-semibold">{viewingFor.title}</span>
</p>
@@ -277,6 +279,7 @@ function Hero({ candidate, tier, viewingFor, onBack }) {
function ScoreCard({
candidate, onShortlist, onInterview, onMessage, onHire, onDecline, onScreen, screening, busy,
}) {
const { t } = useTranslation();
const c = candidate;
const scored = c.ai_score > 0;
const settled = c.status === 'hired' || c.status === 'rejected';
@@ -286,7 +289,7 @@ function ScoreCard({
<div className="overflow-hidden rounded-xl border border-border bg-surface shadow-sm">
{scored ? (
<div className="border-b border-border px-4 py-4">
<p className="text-[10px] font-semibold uppercase tracking-[0.08em] text-ink-4">KROW score</p>
<p className="text-[10px] font-semibold uppercase tracking-[0.08em] text-ink-4">{t('candidateProfile.krowScore')}</p>
<p className="mt-1 font-heading text-display font-bold leading-none tabular-nums text-krow-blue">
{shownScore}
<span className="text-title font-semibold text-ink-4">/100</span>
@@ -308,11 +311,11 @@ function ScoreCard({
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-lg bg-krow-blue text-white">
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
</span>
<p className="font-heading text-body font-semibold text-ink-1">Not screened yet</p>
<p className="font-heading text-body font-semibold text-ink-1">{t('candidateProfile.notScreenedYet')}</p>
</div>
<p className="mt-2 text-body-sm leading-relaxed text-ink-2">
Screening scores {c.applicant_name.split(' ')[0]} against the role across{' '}
{ALL_DIMENSIONS.length} dimensions and writes the analysis on this page.
{t('candidateProfile.screeningScores')} {c.applicant_name.split(' ')[0]} {t('candidateProfile.againstTheRoleAcross')}{' '}
{ALL_DIMENSIONS.length} {t('candidateProfile.dimensionsAndWritesTheAnalysisOn')}
</p>
<Button
size="sm"
@@ -356,26 +359,26 @@ function ScoreCard({
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" shape="rounded" className={cn('w-full', PRESSABLE)}>
Actions
{t('candidateProfile.actions')}
<ChevronDown className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem onClick={onMessage}>
<MessageSquare className="mr-2 h-4 w-4 text-krow-blue" aria-hidden="true" />
Message
{t('candidateProfile.message')}
</DropdownMenuItem>
<DropdownMenuItem onClick={onInterview}>
<Calendar className="mr-2 h-4 w-4 text-krow-blue" aria-hidden="true" />
Set up interview
{t('candidateProfile.setUpInterview')}
</DropdownMenuItem>
<DropdownMenuItem onClick={onHire} disabled={c.status === 'hired'}>
<UserCheck className="mr-2 h-4 w-4 text-success" aria-hidden="true" />
Hire candidate
{t('candidateProfile.hireCandidate')}
</DropdownMenuItem>
<DropdownMenuItem onClick={onDecline} disabled={c.status === 'rejected'}>
<X className="mr-2 h-4 w-4 text-destructive" aria-hidden="true" />
Decline candidate
{t('candidateProfile.declineCandidate')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -398,29 +401,30 @@ function ScoreCard({
* answers "what are they being considered for, and what can I do about it".
*/
function SnapshotCard({ candidate, job }) {
const { t } = useTranslation();
const c = candidate;
const applied = c.created_date ? new Date(c.created_date) : null;
return (
<div className="overflow-hidden rounded-xl border border-border bg-surface shadow-sm">
<div className="border-b border-border px-4 py-3">
<p className="text-[10px] font-semibold uppercase tracking-[0.08em] text-ink-4">Position</p>
<p className="text-[10px] font-semibold uppercase tracking-[0.08em] text-ink-4">{t('candidateProfile.position')}</p>
</div>
<div className="divide-y divide-border">
<Fact label="Applied for">{c.job_title || job?.title || '—'}</Fact>
{job?.location && <Fact label="Location">{job.location}</Fact>}
<Fact label={t('candidateProfile.appliedFor')}>{c.job_title || job?.title || '—'}</Fact>
{job?.location && <Fact label={t('candidateProfile.location')}>{job.location}</Fact>}
{job?.pay_range_max > 0 && (
<Fact label="Pay range">
<Fact label={t('candidateProfile.payRange')}>
${job.pay_range_min}–${job.pay_range_max}/hr
</Fact>
)}
{job?.employment_type && (
<Fact label="Type">
<Fact label={t('candidateProfile.type')}>
<span className="capitalize">{String(job.employment_type).replace(/_/g, ' ')}</span>
</Fact>
)}
{applied && (
<Fact label="Applied">
<Fact label={t('candidateProfile.applied')}>
{applied.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
</Fact>
)}
@@ -455,6 +459,7 @@ const CAREER_MAX = 850;
* it is left out rather than printed as "N/A".
*/
function Reputation({ candidate }) {
const { t } = useTranslation();
const c = candidate;
const tier = careerTier(c);
const metrics = reputationMetrics(c).filter((m) => m.value != null && m.value !== '');
@@ -471,7 +476,7 @@ function Reputation({ candidate }) {
it as the headline without a number so big it unbalances the row. */}
<div className="bg-krow-blue-tint/60 px-5 py-4">
<p className="text-[10px] font-semibold uppercase tracking-[0.08em] text-krow-blue/70">
Career score
{t('candidateProfile.careerScore')}
</p>
<div className="mt-1.5 flex flex-wrap items-baseline gap-x-2.5 gap-y-1">
<p className="font-heading text-display font-bold leading-none tabular-nums text-krow-blue">
@@ -495,20 +500,20 @@ function Reputation({ candidate }) {
<div className="px-5 py-4">
<p className="text-[10px] font-medium uppercase tracking-wide text-ink-4">
Potential market value
{t('candidateProfile.potentialMarketValue')}
</p>
<p className="mt-1.5 font-heading text-title-lg font-bold leading-none text-ink-1">
{marketValue(c)}
</p>
<p className="mt-1.5 text-[10px] text-ink-4">Derived from the career score</p>
<p className="mt-1.5 text-[10px] text-ink-4">{t('candidateProfile.derivedFromTheCareerScore')}</p>
</div>
<div className="px-5 py-4">
<p className="text-[10px] font-medium uppercase tracking-wide text-ink-4">Next promotion</p>
<p className="text-[10px] font-medium uppercase tracking-wide text-ink-4">{t('candidateProfile.nextPromotion')}</p>
<p className="mt-1.5 font-heading text-title-lg font-bold leading-none text-ink-1">
{nextPromotion(c)}
</p>
<p className="mt-1.5 text-[10px] text-ink-4">Tier this candidate tracks toward</p>
<p className="mt-1.5 text-[10px] text-ink-4">{t('candidateProfile.tierThisCandidateTracksToward')}</p>
</div>
</div>
@@ -523,7 +528,7 @@ function Reputation({ candidate }) {
{c.companies_worked?.length > 0 && (
<div className="mt-4">
<SubLabel>Worked with</SubLabel>
<SubLabel>{t('candidateProfile.workedWith')}</SubLabel>
<div className="flex flex-wrap gap-1.5">
{c.companies_worked.map((company, i) => (
<Pill
@@ -540,7 +545,7 @@ function Reputation({ candidate }) {
{tags.length > 0 && (
<div className="mt-4">
<SubLabel>Verified strengths · {tags.length}</SubLabel>
<SubLabel>{t('candidateProfile.verifiedStrengths')} {tags.length}</SubLabel>
<div className="flex flex-wrap gap-1.5">
{tags.map((t, i) => (
<Pill
@@ -567,6 +572,7 @@ function Reputation({ candidate }) {
* Owliver — so the profile cannot contradict the recommendation that led here.
*/
function PositionMatch({ row, position }) {
const { t } = useTranslation();
const score = row?.score ?? row?.match?.score ?? 0;
const shown = useCountUp(score);
if (!row?.match) return null;
@@ -577,7 +583,7 @@ function PositionMatch({ row, position }) {
<div className="px-4 py-3.5">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.08em] text-krow-blue">
Match for {position.title}
{t('candidateProfile.matchFor')} {position.title}
</p>
<p className="font-heading text-title font-bold leading-none tabular-nums text-krow-blue">
{shown}%
@@ -608,7 +614,7 @@ function PositionMatch({ row, position }) {
/>
<span className="min-w-0 flex-1 truncate text-ink-2">
{line.name} — {line.heldLabel}
{!line.met && <span className="text-ink-4"> · needs {line.requiredLabel}</span>}
{!line.met && <span className="text-ink-4"> {t('candidateProfile.needs')} {line.requiredLabel}</span>}
</span>
</li>
);
@@ -616,7 +622,7 @@ function PositionMatch({ row, position }) {
</ul>
{row.availability?.known === false && (
<p className="mt-2.5 text-caption text-ink-4">Availability not on file for this person.</p>
<p className="mt-2.5 text-caption text-ink-4">{t('candidateProfile.availabilityNotOnFileForThis')}</p>
)}
</div>
</div>
@@ -625,6 +631,7 @@ function PositionMatch({ row, position }) {
/** Screening signals, grouped, with the full set behind a disclosure. */
function MatchAnalysis({ candidate, positionRow, position }) {
const { t } = useTranslation();
const [showAll, setShowAll] = useState(false);
const b = candidate.score_breakdown || {};
const scored = candidate.ai_score > 0;
@@ -668,7 +675,7 @@ function MatchAnalysis({ candidate, positionRow, position }) {
transition-colors hover:underline focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-krow-blue/50"
>
View all {ALL_DIMENSIONS.length} signals
{t('candidateProfile.viewAll')} {ALL_DIMENSIONS.length} signals
<ChevronDown className="h-3 w-3" aria-hidden="true" />
</button>
)}
@@ -676,8 +683,7 @@ function MatchAnalysis({ candidate, positionRow, position }) {
) : (
<div className="rounded-xl border border-dashed border-border px-4 py-3.5">
<p className="text-body-sm leading-relaxed text-ink-3">
Screening scores appear here once it runs — skills, experience, reliability
and role fit, each rated against this position.
{t('candidateProfile.screeningScoresAppearHereOnceIt')}
</p>
</div>
)}
@@ -708,6 +714,7 @@ function Assessment({ candidate }) {
* page's interpretation, and it should not be the first thing that speaks.
*/
function AiInsight({ candidate }) {
const { t } = useTranslation();
const c = candidate;
return (
@@ -719,7 +726,7 @@ function AiInsight({ candidate }) {
<Sparkles className="h-3 w-3" aria-hidden="true" />
</span>
<p className="text-[10px] font-semibold uppercase tracking-[0.08em] text-ink-4">
KROW screening summary
{t('candidateProfile.krowScreeningSummary')}
</p>
</div>
<p className="mt-2 max-w-prose text-body leading-relaxed text-ink-2">{c.ai_summary}</p>
@@ -730,7 +737,7 @@ function AiInsight({ candidate }) {
<div className={cn('grid gap-6 sm:grid-cols-2', c.ai_summary && 'mt-5')}>
{c.ai_strengths?.length > 0 && (
<div>
<SubLabel>Strengths</SubLabel>
<SubLabel>{t('candidateProfile.strengths')}</SubLabel>
<ul className="space-y-1.5">
{c.ai_strengths.map((s) => (
<li key={s} className="flex gap-2 text-body-sm leading-relaxed text-ink-2">
@@ -756,7 +763,7 @@ function AiInsight({ candidate }) {
))}
</ul>
<p className="mt-2 text-caption leading-relaxed text-ink-4">
Noted for context, not a disqualification.
{t('candidateProfile.notedForContextNotADisqualification')}
</p>
</div>
)}
@@ -774,6 +781,7 @@ function AiInsight({ candidate }) {
* section whose point is the combination.
*/
function Experience({ candidate, interview }) {
const { t } = useTranslation();
const c = candidate;
const cells = [
@@ -834,7 +842,7 @@ function Experience({ candidate, interview }) {
: 'Interview started, not scored'}
</p>
<p className="text-caption text-ink-3">
Integrity {interview.integrity_score ?? 100}/100
{t('candidateProfile.integrity')} {interview.integrity_score ?? 100}/100
</p>
</div>
<StatusBadge status={interview.verdict} size="sm" />
@@ -858,12 +866,13 @@ function Experience({ candidate, interview }) {
* and a page that blends them makes the weaker one look stronger.
*/
function SkillsAndCerts({ candidate }) {
const { t } = useTranslation();
const c = candidate;
return (
<div className="grid overflow-hidden rounded-xl border border-border bg-surface sm:grid-cols-2 sm:divide-x divide-y divide-border sm:divide-y-0">
<div className="px-4 py-3.5">
<SubLabel>Skills {c.skills?.length ? `· ${c.skills.length}` : ''}</SubLabel>
<SubLabel>{t('candidateProfile.skills')} {c.skills?.length ? `· ${c.skills.length}` : ''}</SubLabel>
{c.skills?.length ? (
<div className="flex flex-wrap gap-1.5">
{c.skills.map((s, i) => (
@@ -873,12 +882,12 @@ function SkillsAndCerts({ candidate }) {
))}
</div>
) : (
<p className="text-body-sm text-ink-4">No skills recorded.</p>
<p className="text-body-sm text-ink-4">{t('candidateProfile.noSkillsRecorded')}</p>
)}
</div>
<div className="px-4 py-3.5">
<SubLabel>Certifications {c.certifications?.length ? `· ${c.certifications.length}` : ''}</SubLabel>
<SubLabel>{t('candidateProfile.certifications')} {c.certifications?.length ? `· ${c.certifications.length}` : ''}</SubLabel>
{c.certifications?.length ? (
<div className="flex flex-wrap gap-1.5">
{c.certifications.map((s, i) => (
@@ -893,7 +902,7 @@ function SkillsAndCerts({ candidate }) {
))}
</div>
) : (
<p className="text-body-sm text-ink-4">None on file.</p>
<p className="text-body-sm text-ink-4">{t('candidateProfile.noneOnFile')}</p>
)}
</div>
</div>
@@ -902,6 +911,7 @@ function SkillsAndCerts({ candidate }) {
/** The endorsement feed. Three to start, the rest on request. */
function Endorsements({ candidate, voices }) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const shown = expanded ? voices : voices.slice(0, 3);
@@ -935,7 +945,7 @@ function Endorsements({ candidate, voices }) {
className={PRESSABLE}
onClick={() => toast.success(`Reference requested from ${v.name}`)}
>
Request Reference
{t('candidateProfile.requestReference')}
</Button>
<Button
variant="outline"
@@ -944,7 +954,7 @@ function Endorsements({ candidate, voices }) {
className={PRESSABLE}
onClick={() => toast.success(`Skill endorsement recorded for ${candidate.applicant_name}`)}
>
Endorse Skill
{t('candidateProfile.endorseSkill')}
</Button>
</div>
</div>
@@ -961,7 +971,7 @@ function Endorsements({ candidate, voices }) {
transition-colors hover:underline focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-krow-blue/50"
>
Show all {voices.length} endorsements
{t('candidateProfile.showAll')} {voices.length} endorsements
<ChevronDown className="h-3 w-3" aria-hidden="true" />
</button>
)}
@@ -1006,6 +1016,7 @@ function Activity({ events }) {
/** Where the page lands: the verdict, and where this candidate now stands. */
function Recommendation({ candidate, verdict }) {
const { t } = useTranslation();
const c = candidate;
return (
@@ -1023,12 +1034,12 @@ function Recommendation({ candidate, verdict }) {
{/* A div, not a <p>: StatusBadge renders a block element, and a badge
inside a paragraph is invalid markup React warns about. */}
<div className="mt-2 flex flex-wrap items-center gap-2 text-body-sm text-ink-3">
<span>Current stage:</span>
<span>{t('candidateProfile.currentStage')}</span>
<StatusBadge status={c.status} size="sm" />
{c.ai_recommendation && <span>· KROW recommends {c.ai_recommendation}</span>}
{c.ai_recommendation && <span>{t('candidateProfile.krowRecommends')} {c.ai_recommendation}</span>}
</div>
<p className="mt-2 text-caption leading-relaxed text-ink-4">
KROW recommends; the hiring decision is yours.
{t('candidateProfile.krowRecommendsTheHiringDecisionIs')}
</p>
</div>
);
@@ -1037,6 +1048,7 @@ function Recommendation({ candidate, verdict }) {
/* ── Page ───────────────────────────────────────────────────────────────── */
export default function CandidateProfile() {
const { t } = useTranslation();
const { id } = useParams();
const [params] = useSearchParams();
const navigate = useNavigate();
@@ -1121,11 +1133,11 @@ export default function CandidateProfile() {
return (
<div className="px-5 py-16 lg:px-8">
<EmptyState
title="Candidate not found"
description="This candidate may have been removed, or the link may be out of date."
title={t('candidateProfile.candidateNotFound')}
description={t('candidateProfile.thisCandidateMayHaveBeenRemoved')}
action={
<Button size="sm" onClick={() => navigate('/admin/candidates')}>
Back to candidates
{t('candidateProfile.backToCandidates')}
</Button>
}
/>
@@ -1370,8 +1382,8 @@ export default function CandidateProfile() {
open={declineOpen}
onOpenChange={setDeclineOpen}
title={`Decline ${candidate.applicant_name}?`}
description="They move to the declined stage. This can be changed later from the candidate list."
confirmLabel="Decline candidate"
description={t('candidateProfile.theyMoveToTheDeclinedStage')}
confirmLabel={t('candidateProfile.declineCandidate')}
onConfirm={decline}
busy={updateApp.isPending}
/>

View File

@@ -15,6 +15,7 @@ import '@/pages/admin/candidates/nodes';
import AIInterviewModal from '@/components/krow/AIInterviewModal';
import ScheduleInterviewModal from '@/components/krow/ScheduleInterviewModal';
import MessageCandidateModal from '@/components/krow/MessageCandidateModal';
import { useTranslation } from 'react-i18next';
const SCORE_BANDS = {
all: () => true,
@@ -67,6 +68,7 @@ const matchesStage = (application, stage, interviews) => {
export default function AdminCandidates() {
const { t } = useTranslation();
const { data: applications = [], isLoading } = useApplications();
const { data: postings = [] } = useJobPostings();
/* Fetched and then discarded until now, which is why the queue could not be
@@ -157,8 +159,8 @@ export default function AdminCandidates() {
return (
<AdminPage
title="Candidates"
subtitle="Review, compare and manage the candidate pipeline."
title={t('candidates.candidates')}
subtitle={t('candidates.reviewCompareAndManageTheCandidate')}
>
<UiEditor />
<CandidatesComposition context={context} />

View File

@@ -12,6 +12,7 @@ import { UiEditor } from '@/components/ui-editor/UiEditor';
import { composePage } from '@/lib/ui/composition';
import '@/components/ui-tree/nodeTypes';
import '@/pages/admin/candidates-analysis/nodes';
import { useTranslation } from 'react-i18next';
/**
* Admin Candidates Analysis — the analytical counterpart to Candidates.
@@ -22,6 +23,7 @@ import '@/pages/admin/candidates-analysis/nodes';
* are the questions worth asking in language rather than filters.
*/
export default function AdminCandidatesAnalysis() {
const { t } = useTranslation();
const navigate = useNavigate();
const { data: applications = [] } = useApplications();
const { data: postings = [] } = useJobPostings();
@@ -167,8 +169,8 @@ export default function AdminCandidatesAnalysis() {
return (
<AdminPage
title="Candidates Analysis"
subtitle="Talent supply, quality distribution and pipeline risk."
title={t('candidatesAnalysis.candidatesAnalysis')}
subtitle={t('candidatesAnalysis.talentSupplyQualityDistributionAndPipeline')}
>
<UiEditor />
<CandidatesAnalysisComposition context={context} />

View File

@@ -1,5 +1,6 @@
import React, { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
AlertTriangle, ArrowRight, Building2, CalendarCheck, CheckCircle2, ChevronRight, Clock, Download, Filter, Sparkles, Users, Zap,
@@ -169,11 +170,12 @@ function Snapshot({ metrics, loading }) {
/* ── 3. Pipeline intelligence ───────────────────────────────────────────── */
function PipelineFunnel({ funnel, transitions, weakest, total }) {
const { t } = useTranslation();
if (!total) {
return (
<EmptyState
title="Nothing in the pipeline"
description="Once candidates apply, stage conversion and drop-off appear here."
title={t('controlCenter.nothingInThePipeline')}
description={t('controlCenter.onceCandidatesApplyStageConversionAnd')}
/>
);
}
@@ -208,7 +210,7 @@ function PipelineFunnel({ funnel, transitions, weakest, total }) {
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-[10px] font-extrabold text-ink-4 tabular-nums">0{i + 1}</span>
<h4 className="truncate font-heading text-body-sm font-bold text-ink-1">
{stage.label}
{t(`stage.${stage.key}`)}
</h4>
</div>
<div
@@ -238,7 +240,7 @@ function PipelineFunnel({ funnel, transitions, weakest, total }) {
: 'bg-surface-subtle text-ink-3 border-border/60'
)}
>
{share}% pool
{t('funnel.pool', { percent: share })}
</span>
</div>
</div>
@@ -263,12 +265,12 @@ function PipelineFunnel({ funnel, transitions, weakest, total }) {
{transition ? (
<>
<span className={cn(isWeak ? 'font-bold text-amber-600 dark:text-amber-400' : 'text-ink-3')}>
{transition.rate}% pass
{t('funnel.pass', { percent: transition.rate })}
</span>
<span>{transition.lost ? `−${transition.lost} lost` : '0 lost'}</span>
<span>{transition.lost ? t('funnel.lost', { count: transition.lost }) : t('funnel.noneLost')}</span>
</>
) : (
<span className="text-ink-4">Entry Stage</span>
<span className="text-ink-4">{t('funnel.entryStage')}</span>
)}
</div>
</div>
@@ -287,15 +289,14 @@ function PipelineFunnel({ funnel, transitions, weakest, total }) {
<div>
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold uppercase tracking-wider text-amber-700 dark:text-amber-400">
Primary Pipeline Bottleneck
{t('funnel.bottleneck')}
</span>
<span className="rounded-full bg-amber-200/60 dark:bg-amber-900/60 px-2 py-0.5 text-[10px] font-bold text-amber-800 dark:text-amber-300">
{weakest.from} → {weakest.to}
{t(`stage.${weakest.fromKey}`)} → {t(`stage.${weakest.toKey}`)}
</span>
</div>
<p className="mt-0.5 text-body-sm text-ink-2">
Only <span className="font-bold text-amber-700 dark:text-amber-400">{weakest.rate}%</span> pass through and{' '}
<span className="font-bold text-ink-1">{weakest.lost} candidates</span> are lost at this transition. Recovering this conversion yields the highest ROI.
{t('funnel.bottleneckBody', { rate: weakest.rate, count: weakest.lost })}
</p>
</div>
</div>
@@ -308,19 +309,19 @@ function PipelineFunnel({ funnel, transitions, weakest, total }) {
/* ── 5. Action center ───────────────────────────────────────────────────── */
const SEVERITY = {
critical: { dot: 'bg-destructive', label: 'Critical' },
warning: { dot: 'bg-warning', label: 'Warning' },
info: { dot: 'bg-krow-blue', label: 'For review' },
critical: { dot: 'bg-destructive', key: 'severity.critical' },
warning: { dot: 'bg-warning', key: 'severity.warning' },
info: { dot: 'bg-krow-blue', key: 'severity.forReview' },
};
/** One operational queue, divided rows. Not five cards in a grid. */
function ActionQueue({ items }) {
const { t } = useTranslation();
if (!items.length) {
return (
<div className="rounded-xl border border-border bg-surface px-4 py-8 text-center">
<p className="text-body-sm text-ink-3">
Nothing needs intervention. Every role has candidates, every applicant is scored,
and no hire is awaiting review.
{t('actionCenter.nothingToDo')}
</p>
</div>
);
@@ -341,7 +342,7 @@ function ActionQueue({ items }) {
className={cn('h-2 w-2 shrink-0 rounded-full', SEVERITY[item.severity].dot)}
aria-hidden="true"
/>
<span className="sr-only">{SEVERITY[item.severity].label}.</span>
<span className="sr-only">{t(SEVERITY[item.severity].key)}.</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-body-sm font-medium text-ink-1">{item.title}</span>
@@ -356,7 +357,7 @@ function ActionQueue({ items }) {
</span>
<span className="hidden shrink-0 items-center gap-0.5 text-caption font-semibold text-krow-blue sm:inline-flex">
Review
{t('action.review')}
<ChevronRight
className="h-3 w-3 transition-transform duration-base group-hover:translate-x-0.5"
aria-hidden="true"
@@ -372,6 +373,7 @@ function ActionQueue({ items }) {
/* ── Page ───────────────────────────────────────────────────────────────── */
export default function ControlCenter() {
const { t } = useTranslation();
const navigate = useNavigate();
const [range, setRange] = useState('30d');
@@ -389,20 +391,20 @@ export default function ControlCenter() {
/* 1. Operations snapshot. */
const metrics = [
{ label: 'Open positions', value: f.openPositions.length, sub: `${f.postings.length} total` },
{ label: 'Candidates', value: f.total, sub: `${f.scored.length} scored` },
{ label: t('metrics.openPositions'), value: f.openPositions.length, sub: t('metrics.total', { count: f.postings.length }) },
{ label: t('metrics.candidates'), value: f.total, sub: t('metrics.scored', { count: f.scored.length }) },
{
label: 'In review',
label: t('metrics.inReview'),
value: f.unscreened.length + f.interviewing.length,
tone: f.unscreened.length ? 'warning' : 'default',
sub: `${f.unscreened.length} unscreened`,
sub: t('metrics.unscreened', { count: f.unscreened.length }),
},
{ label: 'AI screened', value: f.scored.length, sub: `${f.standardizedPct}% coverage` },
{ label: 'Hired', value: f.hired.length, tone: 'brand', sub: `${f.hireRate}% of applicants` },
{ label: t('metrics.aiScreened'), value: f.scored.length, sub: t('metrics.coverage', { percent: f.standardizedPct }) },
{ label: t('metrics.hired'), value: f.hired.length, tone: 'brand', sub: t('metrics.ofApplicants', { percent: f.hireRate }) },
{
label: 'Avg KROW score',
label: t('metrics.avgKrowScore'),
value: f.avgScore || '—',
sub: f.avgScore ? `across ${f.scored.length}` : 'none scored',
sub: f.avgScore ? t('metrics.across', { count: f.scored.length }) : t('metrics.noneScored'),
},
];
@@ -498,53 +500,58 @@ export default function ControlCenter() {
const actions = useMemo(() => [
f.stalled.length && {
severity: 'critical',
title: 'Strong candidates not actioned',
detail: `${f.stalled.map((a) => a.applicant_name).join(', ')} scored 80 or above and ${f.stalled.length === 1 ? 'is' : 'are'} still at AI Screened.`,
title: t('action.stalledTitle'),
detail: t('action.stalledDetail', {
count: f.stalled.length,
names: f.stalled.map((a) => a.applicant_name).join(', '),
}),
metric: f.stalled.length,
metricLabel: 'at 80+',
metricLabel: t('action.at80'),
onClick: () => navigate('/admin/candidates'),
},
f.starvedPositions.length && {
severity: 'critical',
title: 'Open roles with no applicants',
detail: `${f.starvedPositions.map((p) => p.title).join(', ')} — live and attracting nobody.`,
title: t('action.starvedTitle'),
detail: t('action.starvedDetail', { titles: f.starvedPositions.map((p) => p.title).join(', ') }),
metric: f.starvedPositions.length,
metricLabel: 'roles',
metricLabel: t('action.roles'),
onClick: () => navigate('/admin/positions'),
},
f.unscreened.length && {
severity: 'warning',
title: 'Candidates waiting for screening',
detail: 'Unscored applications are decisions made without a standard.',
title: t('action.unscreenedTitle'),
detail: t('action.unscreenedDetail'),
metric: f.unscreened.length,
metricLabel: 'waiting',
metricLabel: t('action.waiting'),
onClick: () => navigate('/admin/candidates'),
},
f.bottleneck && f.bottleneck.lost > 0 && {
severity: 'warning',
title: `${f.bottleneck.from} → ${f.bottleneck.to} bottleneck`,
detail: `Only ${f.bottleneck.rate}% pass through this step, the weakest transition in the funnel.`,
title: t('action.bottleneckTitle', {
from: t(`stage.${f.bottleneck.fromKey}`), to: t(`stage.${f.bottleneck.toKey}`),
}),
detail: t('action.bottleneckDetail', { rate: f.bottleneck.rate }),
metric: `−${f.bottleneck.lost}`,
metricLabel: 'lost',
metricLabel: t('action.lost'),
onClick: () => navigate('/admin/analytics'),
},
f.unscoredProfiles.length && {
severity: 'info',
title: 'Talent profiles without a score',
detail: 'Unscored workers are invisible to matching, so employers cannot find them.',
title: t('action.unscoredTitle'),
detail: t('action.unscoredDetail'),
metric: f.unscoredProfiles.length,
metricLabel: 'profiles',
metricLabel: t('action.profiles'),
onClick: () => navigate('/admin/talent-pool'),
},
f.unratedStaff.length && {
severity: 'info',
title: 'Hires awaiting review',
detail: 'Employer ratings are the only verified performance signal feeding scores.',
title: t('action.unratedTitle'),
detail: t('action.unratedDetail'),
metric: f.unratedStaff.length,
metricLabel: 'unrated',
metricLabel: t('action.unrated'),
onClick: () => navigate('/admin/hired'),
},
].filter(Boolean), [f, navigate]);
].filter(Boolean), [f, navigate, t]);
/* 6. Active positions — live demand, biggest first. */
const activePositions = useMemo(
@@ -583,11 +590,11 @@ export default function ControlCenter() {
return (
<AdminPage
title="Control Center"
subtitle="Workforce operations · hiring intelligence · platform health"
title={t('controlCenter.title')}
subtitle={t('controlCenter.subtitle')}
actions={(
<Button variant="outline" size="sm" shape="rounded" onClick={exportSnapshot}>
<Download aria-hidden="true" /> Export
<Download aria-hidden="true" /> {t('controlCenter.export')}
</Button>
)}
>

View File

@@ -12,6 +12,7 @@ import { composePage } from '@/lib/ui/composition';
import { DAY, RANGES, formatDate } from '@/pages/admin/hired-history/nodes';
import '@/components/ui-tree/nodeTypes';
import '@/pages/admin/hired-history/nodes';
import { useTranslation } from 'react-i18next';
/**
* Admin Hired History — the record of who was hired.
@@ -44,6 +45,7 @@ function Fact({ label, children }) {
/** The full record for one hire — the page's own detail view. */
function HireDetail({ hire, onClose }) {
const { t } = useTranslation();
if (!hire) return null;
return (
@@ -68,15 +70,15 @@ function HireDetail({ hire, onClose }) {
</div>
<section>
<SectionTitle title="The hire" />
<SectionTitle title={t('hiredHistory.theHire')} />
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="mt-2 border border-border">
<Fact label="Position">{hire.role}</Fact>
<Fact label="Client">{hire.company}</Fact>
<Fact label="Department">{hire.department}</Fact>
<Fact label="Hired">{formatDate(hire.hire_date)}</Fact>
<Fact label="Time to hire">{hire.timeToHire ? `${hire.timeToHire} days` : null}</Fact>
<Fact label="AI score">{hire.score}</Fact>
<Fact label="Client review">
<Fact label={t('hiredHistory.position')}>{hire.role}</Fact>
<Fact label={t('hiredHistory.client')}>{hire.company}</Fact>
<Fact label={t('hiredHistory.department')}>{hire.department}</Fact>
<Fact label={t('hiredHistory.hired')}>{formatDate(hire.hire_date)}</Fact>
<Fact label={t('hiredHistory.timeToHire')}>{hire.timeToHire ? `${hire.timeToHire} days` : null}</Fact>
<Fact label={t('hiredHistory.aiScore')}>{hire.score}</Fact>
<Fact label={t('hiredHistory.clientReview')}>
{hire.client_rating ? `${hire.client_rating} / 5` : 'Not reviewed yet'}
</Fact>
</Surface>
@@ -84,7 +86,7 @@ function HireDetail({ hire, onClose }) {
{(hire.email || hire.phone) && (
<section>
<SectionTitle title="Contact" />
<SectionTitle title={t('hiredHistory.contact')} />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="mt-2 space-y-2 border border-border">
{hire.email && (
<a
@@ -117,6 +119,7 @@ function HireDetail({ hire, onClose }) {
}
export default function AdminHiredHistory() {
const { t } = useTranslation();
const { data: staff = [], isLoading } = useStaff();
const { data: applications = [] } = useApplications();
const { data: postings = [] } = useJobPostings();
@@ -183,8 +186,8 @@ export default function AdminHiredHistory() {
return (
<AdminPage
title="Hired History"
subtitle="Who was hired, for which position, and what has happened since."
title={t('hiredHistory.hiredHistory')}
subtitle={t('hiredHistory.whoWasHiredForWhichPosition')}
meta={`${hires.length} on record`}
>
<UiEditor />

View File

@@ -6,6 +6,7 @@ import { KROW_LOGO_URL } from '@/assets/brand';
import { Checkbox } from '@/components/ds';
import { useAuth } from '@/lib/AuthContext';
import { safeReturnTo } from '@/lib/authReturnTo';
import { useTranslation } from 'react-i18next';
const DEMO_EMAIL = 'demo@krow.app';
@@ -23,6 +24,7 @@ const GENERIC_AUTH_ERROR =
'That email and password do not match an account. Check both and try again.';
export default function AdminLogin() {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const { login } = useAuth();
@@ -123,7 +125,7 @@ export default function AdminLogin() {
<div className="flex items-center gap-3">
<img src={KROW_LOGO_URL} alt="KROW" className="h-8 w-auto object-contain" />
<span className="text-[11px] font-extrabold uppercase tracking-widest px-3 py-1 rounded-full bg-[#F9E547] text-[#0F172A] border border-[#FACC15] shadow-sm">
Enterprise
{t('login.enterprise')}
</span>
</div>
</div>
@@ -133,7 +135,7 @@ export default function AdminLogin() {
<div className="relative rounded-2xl overflow-hidden shadow-md border border-[#E2E8F0] bg-white p-2 max-w-xs">
<img
src="/krow-logo-visual.jpeg"
alt="KROW Logo Visual"
alt={t('login.krowLogoVisual')}
className="w-full h-auto object-cover rounded-xl transition-transform duration-500 hover:scale-105"
/>
</div>
@@ -143,13 +145,13 @@ export default function AdminLogin() {
<div className="relative z-10 space-y-2">
<div className="flex items-center gap-1.5 text-xs font-bold text-[#0838E0] uppercase tracking-wider">
<Sparkles className="w-3.5 h-3.5 text-[#0838E0]" />
Recruiting Intelligence Platform
{t('login.recruitingIntelligencePlatform')}
</div>
<h2 className="text-xl font-bold text-[#0F172A] tracking-tight leading-snug">
Workforce decisions powered by intelligence
{t('login.workforceDecisionsPoweredByIntelligence')}
</h2>
<p className="text-xs text-[#64748B] leading-relaxed">
AI-driven candidate screening, verified score dimensions, and talent matching.
{t('login.aiDrivenCandidateScreeningVerifiedScore')}
</p>
</div>
</div>
@@ -162,12 +164,12 @@ export default function AdminLogin() {
<div className="flex items-center gap-2 mb-3 md:hidden">
<img src={KROW_LOGO_URL} alt="KROW" className="h-7 w-auto object-contain" />
<span className="text-[10px] font-bold uppercase tracking-widest px-2 py-0.5 rounded bg-[#F9E547] text-[#0F172A]">
Admin
{t('login.admin')}
</span>
</div>
<h1 className="text-2xl font-extrabold text-[#0F172A] tracking-tight">Welcome back</h1>
<h1 className="text-2xl font-extrabold text-[#0F172A] tracking-tight">{t('login.welcomeBack')}</h1>
<p className="mt-1.5 text-xs text-[#64748B] leading-relaxed">
Sign in to manage hiring, candidates, talent intelligence, and workforce decisions.
{t('login.signInToManageHiringCandidates')}
</p>
</div>
@@ -184,7 +186,7 @@ export default function AdminLogin() {
{/* Email */}
<div className="space-y-1.5">
<label className="block text-xs font-bold text-[#334155] uppercase tracking-wider">
Work email
{t('login.workEmail')}
</label>
<input
type="email"
@@ -197,7 +199,7 @@ export default function AdminLogin() {
"w-full h-11 px-3.5 rounded-xl bg-[#FAF9F5] border text-sm text-[#0F172A] placeholder-slate-400 focus:outline-none transition-all",
errors.email ? "border-red-500 focus:border-red-500" : "border-[#E2E8F0] focus:border-[#F59E0B] focus:ring-2 focus:ring-[#FEF3C7]"
)}
placeholder="name@company.com"
placeholder={t('login.nameCompanyCom')}
/>
{errors.email && <p className="text-xs text-red-600 font-medium">{errors.email}</p>}
</div>
@@ -205,7 +207,7 @@ export default function AdminLogin() {
{/* Password */}
<div className="space-y-1.5">
<label className="block text-xs font-bold text-[#334155] uppercase tracking-wider">
Password
{t('login.password')}
</label>
<div className="relative">
<input
@@ -239,7 +241,7 @@ export default function AdminLogin() {
onCheckedChange={(v) => setForm({ ...form, remember: Boolean(v) })}
disabled={busy}
/>
Remember me
{t('login.rememberMe')}
</label>
<button
type="button"
@@ -247,7 +249,7 @@ export default function AdminLogin() {
onClick={() => setAuthError('Password resets are not available yet — ask an administrator to set a new password.')}
className="text-[#D97706] hover:text-[#B45309] font-semibold transition-colors"
>
Forgot password?
{t('login.forgotPassword')}
</button>
</div>
@@ -265,8 +267,7 @@ export default function AdminLogin() {
</form>
<p className="text-center text-xs text-[#94A3B8] leading-relaxed pt-2">
Sign in with your Krow account. Passwords are set by an administrator with
the <span className="text-[#64748B] font-semibold">setpassword</span> utility.
{t('login.signInWithYourKrowAccount')} <span className="text-[#64748B] font-semibold">setpassword</span> {t('login.utility')}
</p>
</div>
</div>

View File

@@ -20,6 +20,7 @@ import {
surfaceFor,
} from '@/lib/skills/surfaces';
import { AdminPage, SectionTitle } from '@/components/admin/PageShell';
import { useTranslation } from 'react-i18next';
/**
* Add or edit an Owliver skill.
@@ -106,6 +107,7 @@ function LineList({ items, onChange, placeholder, addLabel, ariaLabel }) {
}
export default function OwliverSkillEditor() {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const { id: routeId } = useParams();
@@ -294,7 +296,7 @@ export default function OwliverSkillEditor() {
return (
<AdminPage
title={editingId ? 'Edit Owliver skill' : 'Add Owliver skill'}
subtitle="Define what Owliver can be asked for, and the pages it can be asked on."
subtitle={t('owliverSkillEditor.defineWhatOwliverCanBeAsked')}
actions={
<>
<Button
@@ -303,7 +305,7 @@ export default function OwliverSkillEditor() {
shape="rounded"
onClick={() => navigate('/admin/workspace/skills?tab=owliver')}
>
<ChevronLeft aria-hidden="true" /> Skills
<ChevronLeft aria-hidden="true" /> {t('owliverSkillEditor.skills')}
</Button>
{/* A capability with no source cannot be saved: `validateSkillSource`
refuses it, and refusing at the button says so before the author
@@ -324,48 +326,48 @@ export default function OwliverSkillEditor() {
<div className="min-w-0 space-y-6">
{/* ── Identity and reach ─────────────────────────────────────── */}
<section className="space-y-3">
<SectionTitle title="Owliver skill" />
<SectionTitle title={t('owliverSkillEditor.owliverSkill')} />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Skill name" required>
<Field label={t('owliverSkillEditor.skillName')} required>
<Input
value={draft.name}
placeholder="e.g. Hiring Activity Assistant"
placeholder={t('owliverSkillEditor.eGHiringActivityAssistant')}
onChange={(e) => update({ name: e.target.value })}
/>
</Field>
<Field label="Skill ID" required hint="Lower-case letters, numbers and dashes.">
<Field label={t('owliverSkillEditor.skillId')} required hint={t('owliverSkillEditor.lowerCaseLettersNumbersAndDashes')}>
<Input
value={draft.id}
placeholder="e.g. hiring-activity-assistant"
placeholder={t('owliverSkillEditor.eGHiringActivityAssistant2')}
onChange={(e) => update({ id: e.target.value })}
/>
</Field>
</div>
<Field label="Description" hint="Owliver reads this when deciding whether a question is for this skill.">
<Field label={t('owliverSkillEditor.description')} hint={t('owliverSkillEditor.owliverReadsThisWhenDecidingWhether')}>
<Input
value={draft.description}
placeholder="e.g. Answer questions about recent hiring activity on a position."
placeholder={t('owliverSkillEditor.eGAnswerQuestionsAboutRecent')}
onChange={(e) => update({ description: e.target.value })}
/>
</Field>
<Field
label="Pages / context"
label={t('owliverSkillEditor.pagesContext')}
required
hint="Where should Owliver know this skill? The panel on these pages offers it; no other panel does."
hint={t('owliverSkillEditor.whereShouldOwliverKnowThisSkill')}
>
<div className="space-y-2.5">
<MultiSelect
options={PAGE_KEYS.map((k) => ({ value: k, label: surfaceFor(k)?.label || k }))}
value={draft.pages}
onChange={(pages) => update({ pages })}
placeholder="Select pages (e.g. positions, candidates...)"
placeholder={t('owliverSkillEditor.selectPagesEGPositionsCandidates')}
maxChips={6}
/>
<div className="flex flex-wrap items-center gap-1.5 pt-1">
<span className="mr-1 text-[11px] font-medium text-ink-4">Available pages:</span>
<span className="mr-1 text-[11px] font-medium text-ink-4">{t('owliverSkillEditor.availablePages')}</span>
{PAGE_KEYS.map((pageKey) => {
const selected = draft.pages.includes(pageKey);
return (
@@ -395,28 +397,28 @@ export default function OwliverSkillEditor() {
<div className="flex items-center justify-between gap-3 border-t border-border pt-4">
<div className="min-w-0">
<p className="text-body-sm font-medium text-ink-2">Active</p>
<p className="text-body-sm font-medium text-ink-2">{t('owliverSkillEditor.active')}</p>
<p className="text-caption text-ink-3">
An inactive skill stays registered, but Owliver offers neither its suggestions nor its answers.
{t('owliverSkillEditor.anInactiveSkillStaysRegisteredBut')}
</p>
</div>
<Switch checked={active} onCheckedChange={setStatus} aria-label="Skill active" />
<Switch checked={active} onCheckedChange={setStatus} aria-label={t('owliverSkillEditor.skillActive')} />
</div>
</Surface>
</section>
{/* ── What starts it ─────────────────────────────────────────── */}
<section className="space-y-3">
<SectionTitle title="Triggers" meta="What activates this skill" />
<SectionTitle title={t('owliverSkillEditor.triggers')} meta={t('owliverSkillEditor.whatActivatesThisSkill')} />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs">
<Field
label="Words and phrases"
hint="A question containing one of these is treated as being about this skill. `*` stands for anything in between."
label={t('owliverSkillEditor.wordsAndPhrases')}
hint={t('owliverSkillEditor.aQuestionContainingOneOfThese')}
>
<LineList
items={draft.triggers}
onChange={(triggers) => update({ triggers })}
placeholder="hiring activity"
placeholder={t('owliverSkillEditor.hiringActivity')}
addLabel="Add trigger"
ariaLabel="Triggers"
/>
@@ -426,16 +428,16 @@ export default function OwliverSkillEditor() {
{/* ── What it offers ─────────────────────────────────────────── */}
<section className="space-y-3">
<SectionTitle title="Suggestions" meta="Shown as chips inside Owliver" />
<SectionTitle title={t('owliverSkillEditor.suggestions')} meta={t('owliverSkillEditor.shownAsChipsInsideOwliver')} />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs">
<Field
label="Suggested prompts"
hint="Offered on the pages above. Clicking one asks it, exactly as typing it would."
label={t('owliverSkillEditor.suggestedPrompts')}
hint={t('owliverSkillEditor.offeredOnThePagesAboveClicking')}
>
<LineList
items={draft.suggestions}
onChange={(suggestions) => update({ suggestions })}
placeholder="Show hiring activity"
placeholder={t('owliverSkillEditor.showHiringActivity')}
addLabel="Add suggestion"
ariaLabel="Suggestions"
/>
@@ -445,7 +447,7 @@ export default function OwliverSkillEditor() {
{/* ── What an answer looks like, and what each one reads ────── */}
<section className="space-y-3">
<SectionTitle title="Capabilities" meta="Each one reads its own source" />
<SectionTitle title={t('owliverSkillEditor.capabilities')} meta={t('owliverSkillEditor.eachOneReadsItsOwnSource')} />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-2.5">
{OWLIVER_CAPABILITIES.map((capability) => {
const checked = draft.capabilities.includes(capability.id);
@@ -492,7 +494,7 @@ export default function OwliverSkillEditor() {
{checked && (
<div className="space-y-3 border-t border-border/60 px-3 py-3">
<Field
label="Source"
label={t('owliverSkillEditor.source')}
hint={`A named reading of data KROW already holds. Only sources that can be answered as ${capability.label.toLowerCase()} are offered.`}
error={missing ? `${capability.label} needs a source before this skill can be saved.` : undefined}
>
@@ -501,7 +503,7 @@ export default function OwliverSkillEditor() {
onValueChange={(value) => updateResponse(capability.id, { source: value })}
>
<SelectTrigger aria-label={`${capability.label} source`}>
<SelectValue placeholder="Select a data source" />
<SelectValue placeholder={t('owliverSkillEditor.selectADataSource')} />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
@@ -526,7 +528,7 @@ export default function OwliverSkillEditor() {
)}
{sourceSupportsOption(response.source, 'periods') && (
<Field label="Periods" hint="Computed against the current date at read time, never stored.">
<Field label={t('owliverSkillEditor.periods')} hint={t('owliverSkillEditor.computedAgainstTheCurrentDateAt')}>
<div className="flex flex-wrap gap-1.5">
{PERIODS.map((period) => {
const selected = (response.periods || []).includes(period.id);
@@ -556,7 +558,7 @@ export default function OwliverSkillEditor() {
)}
{sourceSupportsOption(response.source, 'limit') && (
<Field label="Limit" hint="How many records the answer lists. Left empty, the reading uses its own default.">
<Field label={t('owliverSkillEditor.limit')} hint={t('owliverSkillEditor.howManyRecordsTheAnswerLists')}>
<Input
type="number"
min="1"
@@ -578,9 +580,7 @@ export default function OwliverSkillEditor() {
{unconfigured.length > 0 && (
<p className="text-caption text-destructive">
{unconfigured.map(owliverCapabilityLabel).join(', ')}
{unconfigured.length === 1 ? ' has' : ' have'} no source yet.
A capability with nothing to read is dropped when the definition is registered,
so the skill would save and then offer no suggestion at all.
{unconfigured.length === 1 ? ' has' : ' have'} {t('owliverSkillEditor.noSourceYetACapabilityWith2')}
</p>
)}
</Surface>
@@ -588,18 +588,17 @@ export default function OwliverSkillEditor() {
{/* ── The artefact ───────────────────────────────────────────── */}
<section className="space-y-3">
<SectionTitle title="Definition" meta="Markdown" />
<SectionTitle title={t('owliverSkillEditor.definition')} meta={t('owliverSkillEditor.markdown')} />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-4">
{offerBoard && (
<p className="rounded-lg border border-krow-blue/30 bg-krow-blue-tint px-3 py-2 text-caption leading-relaxed text-krow-blue">
This file declares a page section and no Owliver block. It saves correctly from
here, but the fields that configure it are in the Board Skill Editor.
{t('owliverSkillEditor.thisFileDeclaresAPageSection2')}
</p>
)}
<Field
label="Skill definition"
hint="This is what gets saved. The fields above edit its frontmatter; the body is yours."
label={t('owliverSkillEditor.skillDefinition')}
hint={t('owliverSkillEditor.thisIsWhatGetsSavedThe')}
error={error}
>
<Textarea
@@ -655,11 +654,11 @@ export default function OwliverSkillEditor() {
className="hidden"
/>
<Button variant="outline" size="sm" shape="rounded" onClick={() => fileRef.current?.click()}>
<Upload aria-hidden="true" /> Upload .md
<Upload aria-hidden="true" /> {t('owliverSkillEditor.uploadMd')}
</Button>
{loadedFrom && (
<span className="text-caption text-ink-4">
Fields read from <span className="font-medium text-ink-2">{loadedFrom}</span>
{t('owliverSkillEditor.fieldsReadFrom')} <span className="font-medium text-ink-2">{loadedFrom}</span>
</span>
)}
{offerBoard && (
@@ -669,12 +668,12 @@ export default function OwliverSkillEditor() {
shape="rounded"
onClick={() => navigate('/admin/workspace/skills/new', { state: { source } })}
>
Open in Board editor
{t('owliverSkillEditor.openInBoardEditor')}
</Button>
)}
</div>
<p className="text-caption text-ink-4">
Stored as data in this browser, for this workspace. It is never executed.
{t('owliverSkillEditor.storedAsDataInThisBrowser')}
</p>
</div>
</Surface>
@@ -683,7 +682,7 @@ export default function OwliverSkillEditor() {
{/* ── What the registry will make of it ────────────────────────── */}
<aside className="space-y-3 lg:sticky lg:top-20">
<SectionTitle title="Preview" />
<SectionTitle title={t('owliverSkillEditor.preview')} />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-3">
{preview ? (
<>
@@ -699,11 +698,11 @@ export default function OwliverSkillEditor() {
<dl className="space-y-2 border-t border-border pt-3">
<div>
<dt className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Skill ID</dt>
<dt className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">{t('owliverSkillEditor.skillId')}</dt>
<dd className="font-mono text-caption text-ink-2">{preview.id}</dd>
</div>
<div>
<dt className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Pages</dt>
<dt className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">{t('owliverSkillEditor.pages')}</dt>
<dd className="text-caption text-ink-2">
{preview.pages.length
? preview.pages.map((p) => surfaceFor(p)?.label || p).join(', ')
@@ -713,7 +712,7 @@ export default function OwliverSkillEditor() {
{preview.owliver.suggestions.length > 0 && (
<div>
<dt className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Suggested</dt>
<dt className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">{t('owliverSkillEditor.suggested')}</dt>
{/* Drawn as the panel draws them, so the author can see
whether they read as buttons rather than sentences. */}
<dd className="mt-1 flex flex-wrap gap-1.5">
@@ -731,7 +730,7 @@ export default function OwliverSkillEditor() {
{preview.owliver.capabilities.length > 0 && (
<div>
<dt className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Capabilities</dt>
<dt className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">{t('owliverSkillEditor.capabilities')}</dt>
<dd className="mt-1 space-y-1">
{preview.owliver.capabilities.map((c) => (
<div key={c} className="flex flex-wrap items-baseline gap-x-2">
@@ -754,7 +753,7 @@ export default function OwliverSkillEditor() {
{preview.triggers.length > 0 && (
<div>
<dt className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Triggers</dt>
<dt className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">{t('owliverSkillEditor.triggers')}</dt>
<dd className="text-[11px] leading-relaxed text-ink-3">
{preview.triggers.join(' · ')}
</dd>
@@ -768,20 +767,20 @@ export default function OwliverSkillEditor() {
<div className="rounded-lg border border-border bg-surface-subtle p-2.5">
<dt className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4">
<LayoutTemplate className="h-3 w-3" aria-hidden="true" />
Also a UI skill
{t('owliverSkillEditor.alsoAUiSkill')}
</dt>
<dd className="mt-1 space-y-1">
<p className="text-[11px] leading-relaxed text-ink-3">
This definition also draws{' '}
{t('owliverSkillEditor.thisDefinitionAlsoDraws')}{' '}
{uiSections.map((s) => sectionTypeLabel(s.type).toLowerCase()).join(', ')}
{' '}on its pages. Saving here keeps that exactly as written.
{' '}{t('owliverSkillEditor.onItsPagesSavingHereKeeps')}
</p>
<button
type="button"
onClick={() => navigate(`/admin/workspace/skills/${preview.id}`)}
className="text-[11px] font-semibold text-krow-blue hover:underline"
>
Edit its page section →
{t('owliverSkillEditor.editItsPageSection')}
</button>
</dd>
</div>
@@ -790,7 +789,7 @@ export default function OwliverSkillEditor() {
{preview.owliverErrors?.length > 0 && (
<div>
<dt className="text-[10px] font-semibold uppercase tracking-wide text-destructive">
Owliver configuration
{t('owliverSkillEditor.owliverConfiguration')}
</dt>
<dd className="mt-1 space-y-1">
{preview.owliverErrors.map((message) => (
@@ -805,7 +804,7 @@ export default function OwliverSkillEditor() {
</>
) : (
<p className="text-caption text-ink-3">
The definition could not be parsed yet. Keep the frontmatter block at the top.
{t('owliverSkillEditor.theDefinitionCouldNotBeParsed')}
</p>
)}
</Surface>

View File

@@ -28,6 +28,7 @@ import { continueDraftRequest } from '@/lib/skills/draftFlow';
import { AdminPage, SectionTitle } from '@/components/admin/PageShell';
import { RoleGlyph } from '@/components/admin/RoleGlyph';
import { buildPosition, payLabel } from '@/lib/admin/positionInsights';
import { useTranslation } from 'react-i18next';
/**
* Admin Positions — a hiring workspace, one card per role.
@@ -106,10 +107,11 @@ const STEP_ICONS = {
* Rendered as an executive multi-segment funnel bar and 5 stage micro-cards with tailored icons.
*/
function Progression({ steps, className }: any) {
const { t } = useTranslation();
const entered = steps[0]?.count || 0;
return (
<div className={cn('w-full space-y-2', className)} role="group" aria-label="Hiring progression">
<div className={cn('w-full space-y-2', className)} role="group" aria-label={t('positions.hiringProgression')}>
{/* Top Proportional Funnel Progress Bar */}
<div className="flex h-1.5 w-full overflow-hidden rounded-full bg-surface-sunken p-0.5 gap-0.5 border border-border/40">
{steps.map((step) => {
@@ -209,6 +211,7 @@ function Progression({ steps, className }: any) {
* is known, the row does not render at all.
*/
function WorkforceRow({ workforce }) {
const { t } = useTranslation();
if (!workforce) return null;
const { demand, strong, newToday } = workforce;
@@ -232,7 +235,7 @@ function WorkforceRow({ workforce }) {
),
newToday.length > 0 && (
<span key="new" className="tabular-nums font-semibold text-krow-blue">
{newToday.length} new today
{newToday.length} {t('positions.newToday')}
</span>
),
].filter(Boolean);
@@ -363,6 +366,7 @@ function useRecentlySavedIds(postings) {
* screen actually declares a headcount, rather than filling a column with dashes.
*/
function PositionsTable({ rows, onOpen }) {
const { t } = useTranslation();
const showDemand = rows.some((p) => p.workforce.demand.declared);
const columns = [
@@ -383,7 +387,7 @@ function PositionsTable({ rows, onOpen }) {
return (
<div className="overflow-hidden rounded-xl border border-border bg-surface shadow-xs">
<table className="hidden w-full border-collapse text-body-sm lg:table">
<caption className="sr-only">Open positions, their hiring pipeline and workforce demand</caption>
<caption className="sr-only">{t('positions.openPositionsTheirHiringPipelineAnd')}</caption>
<thead>
<tr className="border-b border-border bg-surface-subtle">
{columns.map((c) => (
@@ -441,7 +445,7 @@ function PositionsTable({ rows, onOpen }) {
<td className="px-3 py-2.5"><HealthChip health={p.health} /></td>
<td className="px-3 py-2.5 text-right">
<span className="inline-flex items-center gap-1 text-caption font-semibold text-krow-blue">
View <ArrowRight className="h-3 w-3" aria-hidden="true" />
{t('positions.view')} <ArrowRight className="h-3 w-3" aria-hidden="true" />
</span>
</td>
</tr>
@@ -517,6 +521,7 @@ function candidateCriteria(position) {
* and the status pill the card has always drawn now reads Draft.
*/
function PositionCard({ position, onOpen, isRecent = false }) {
const { t } = useTranslation();
const p = position;
const isDraft = p.status === 'draft';
@@ -572,12 +577,12 @@ function PositionCard({ position, onOpen, isRecent = false }) {
{/* Terms */}
<dl className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-caption text-ink-3">
<div className="flex min-w-0 items-center gap-1">
<dt className="sr-only">Location</dt>
<dt className="sr-only">{t('positions.location')}</dt>
<MapPin className="h-3 w-3 shrink-0 text-ink-4" aria-hidden="true" />
<dd className="truncate">{p.location || 'Location not set'}</dd>
</div>
<div className="flex min-w-0 items-center gap-1">
<dt className="sr-only">Pay</dt>
<dt className="sr-only">{t('positions.pay')}</dt>
<Wallet className="h-3 w-3 shrink-0 text-ink-4" aria-hidden="true" />
<dd className="truncate">{payLabel(p)}</dd>
</div>
@@ -610,7 +615,7 @@ function PositionCard({ position, onOpen, isRecent = false }) {
<div className="mt-auto flex items-center justify-between border-t border-border pt-2.5">
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-ink-4">
{p.ai_generated
? <><Sparkles className="h-3 w-3" aria-hidden="true" /> AI Built</>
? <><Sparkles className="h-3 w-3" aria-hidden="true" /> {t('positions.aiBuilt')}</>
: 'Manual'}
</span>
{/* A draft is unfinished work, so the card says what happens next on it:
@@ -644,6 +649,7 @@ function PositionCard({ position, onOpen, isRecent = false }) {
/** The full record, over the grid rather than instead of it. */
function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition }) {
const { t } = useTranslation();
if (!position) return null;
const p = position;
const s = p.stats;
@@ -686,12 +692,12 @@ function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition })
size="xl"
footer={
<>
<Button variant="outline" size="sm" onClick={onClose}>Close</Button>
<Button variant="outline" size="sm" onClick={onClose}>{t('positions.close')}</Button>
{/* The drawer holds the record; the page holds the record plus every
applicant on it. This is the way through to the second. */}
<Button variant="outline" size="sm" onClick={() => onOpenPosition(p)}>Open position</Button>
<Button variant="outline" size="sm" onClick={() => onOpenPosition(p)}>{t('positions.openPosition')}</Button>
<Button size="sm" onClick={() => onViewCandidates(p)}>
View {s.applied} candidate{s.applied === 1 ? '' : 's'}
{t('positions.view')} {s.applied} candidate{s.applied === 1 ? '' : 's'}
</Button>
</>
}
@@ -722,7 +728,7 @@ function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition })
for is read back here — the three sections below mirror the three
groups of that form, in the same order. */}
<section>
<SectionTitle title="Position details" />
<SectionTitle title={t('positions.positionDetails')} />
<PositionOverview position={p} className="mt-2" />
</section>
@@ -733,14 +739,14 @@ function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition })
demand nobody made. */}
{hasRequirements(p) && (
<section>
<SectionTitle title="Requirements" />
<SectionTitle title={t('positions.requirements')} />
<PositionRequirements position={p} className="mt-2" />
</section>
)}
{customRequirementsText(p) && (
<section>
<SectionTitle title="Custom requirements" />
<SectionTitle title={t('positions.customRequirements')} />
<PositionCustomRequirements position={p} className="mt-2" />
</section>
)}
@@ -753,23 +759,23 @@ function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition })
gone and the three figures the progression does *not* carry — supply,
quality and the screening average — lead it as one line. */}
<section>
<SectionTitle title="Hiring progression" />
<SectionTitle title={t('positions.hiringProgression')} />
<dl className="mb-3 flex flex-wrap items-baseline gap-x-2 gap-y-1 text-body-sm">
<div className="flex items-baseline gap-1.5">
<dt className="sr-only">Total applicants</dt>
<dt className="sr-only">{t('positions.totalApplicants')}</dt>
<dd className="font-semibold tabular-nums text-ink-1">{s.applied}</dd>
<span className="text-ink-3">Applicant{s.applied === 1 ? '' : 's'}</span>
<span className="text-ink-3">{t('positions.applicant')}{s.applied === 1 ? '' : 's'}</span>
</div>
<span className="text-ink-4" aria-hidden="true">·</span>
<div className="flex items-baseline gap-1.5">
<dt className="sr-only">Qualified, scoring 70 or above</dt>
<dt className="sr-only">{t('positions.qualifiedScoring70OrAbove')}</dt>
<dd className="font-semibold tabular-nums text-ink-1">{s.qualified}</dd>
<span className="text-ink-3">Qualified (70+)</span>
<span className="text-ink-3">{t('positions.qualified70')}</span>
</div>
<span className="text-ink-4" aria-hidden="true">·</span>
<div className="flex items-baseline gap-1.5">
<dt className="text-ink-3">Avg Score</dt>
<dt className="text-ink-3">{t('positions.avgScore')}</dt>
<dd className="font-semibold tabular-nums text-ink-1">{s.avgScore || '—'}</dd>
</div>
{/* Screening coverage was the one figure in the old block that the
@@ -779,7 +785,7 @@ function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition })
<>
<span className="text-ink-4" aria-hidden="true">·</span>
<div className="flex items-baseline gap-1.5">
<dt className="sr-only">Screening coverage</dt>
<dt className="sr-only">{t('positions.screeningCoverage')}</dt>
<dd className="font-semibold tabular-nums text-ink-1">{s.coverage}%</dd>
<span className="text-ink-3">screened</span>
</div>
@@ -798,7 +804,7 @@ function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition })
{/* Top candidates */}
<section>
<SectionTitle
title="Top candidates"
title={t('positions.topCandidates')}
meta={p.topCandidates.length ? `${p.topCandidates.length} scored` : 'None scored'}
/>
{p.topCandidates.length ? (
@@ -815,7 +821,7 @@ function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition })
</div>
<div className="shrink-0 text-right">
<p className="font-heading text-body font-bold tabular-nums text-ink-1">{c.ai_score}</p>
<p className="text-[10px] text-ink-4">KROW Score</p>
<p className="text-[10px] text-ink-4">{t('positions.krowScore')}</p>
</div>
</li>
))}
@@ -825,9 +831,9 @@ function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition })
always the same — screening has not run — so it needs stating once,
plainly, rather than explained. */
<div className="rounded-lg border border-dashed border-border px-3 py-4">
<p className="text-body-sm font-medium text-ink-2">No screened candidates yet</p>
<p className="text-body-sm font-medium text-ink-2">{t('positions.noScreenedCandidatesYet')}</p>
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">
Candidates will appear here once screening is completed.
{t('positions.candidatesWillAppearHereOnceScreening')}
</p>
</div>
)}
@@ -838,7 +844,7 @@ function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition })
half. The dots say "this one is chronological" without a heavier
treatment. */}
<section>
<SectionTitle title="Hiring timeline" />
<SectionTitle title={t('positions.hiringTimeline')} />
<ol className="space-y-0">
{timeline.map((e, i) => (
<li key={i} className="flex gap-2.5 pb-3 last:pb-0">
@@ -870,7 +876,7 @@ function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition })
<SkillSurface page="positions" placement="after-candidates" context={{ position: p }} />
<section>
<SectionTitle title="Recent activity" />
<SectionTitle title={t('positions.recentActivity')} />
{p.applications.length ? (
<ol className="space-y-2">
{[...p.applications]
@@ -890,7 +896,7 @@ function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition })
))}
</ol>
) : (
<p className="text-body-sm text-ink-3">No activity on this position yet.</p>
<p className="text-body-sm text-ink-3">{t('positions.noActivityOnThisPositionYet')}</p>
)}
</section>
@@ -901,6 +907,7 @@ function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition })
}
export default function AdminPositions() {
const { t } = useTranslation();
const navigate = useNavigate();
/* How this page starts a turn in the panel beside it — see `openPosition`. */
const { ask } = useAssistantPanel();
@@ -1081,8 +1088,8 @@ export default function AdminPositions() {
return (
<AdminPage
title="Positions"
subtitle="Manage open roles, hiring demand and position performance."
title={t('positions.positions')}
subtitle={t('positions.manageOpenRolesHiringDemandAnd')}
>
{/* Controls toolbar — unified flex row with clean alignment across viewports */}
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
@@ -1091,7 +1098,7 @@ export default function AdminPositions() {
<SearchInput
value={search}
onChange={setSearch}
placeholder="Search positions"
placeholder={t('positions.searchPositions2')}
size="sm"
shape="pill"
className="bg-white border-border/80 hover:border-border transition-colors"
@@ -1099,16 +1106,16 @@ export default function AdminPositions() {
</div>
<div className="flex flex-wrap items-center gap-2 min-w-0">
<FilterSelect value={status} onChange={setStatus} label="Status" options={[
<FilterSelect value={status} onChange={setStatus} label={t('positions.status')} options={[
{ value: 'all', label: 'All' }, { value: 'active', label: 'Hiring' },
{ value: 'paused', label: 'Paused' }, { value: 'draft', label: 'Draft' },
{ value: 'closed', label: 'Closed' },
]} />
<FilterSelect value={department} onChange={setDepartment} label="Role"
<FilterSelect value={department} onChange={setDepartment} label={t('positions.role')}
options={[{ value: 'all', label: 'All roles' }, ...departments.map((d) => ({ value: d, label: d }))]} />
<FilterSelect value={location} onChange={setLocation} label="Location"
<FilterSelect value={location} onChange={setLocation} label={t('positions.location')}
options={[{ value: 'all', label: 'All locations' }, ...locations.map((l) => ({ value: l, label: l }))]} />
<FilterSelect value={sort} onChange={setSort} label="Sort"
<FilterSelect value={sort} onChange={setSort} label={t('positions.sort')}
options={Object.entries(SORTS).map(([value, s]) => ({ value, label: s.label }))} />
</div>
</div>
@@ -1177,7 +1184,7 @@ export default function AdminPositions() {
}
action={
isFiltered
? <Button variant="outline" size="sm" onClick={clearFilters}>Clear filters</Button>
? <Button variant="outline" size="sm" onClick={clearFilters}>{t('positions.clearFilters')}</Button>
: undefined
}
/>

View File

@@ -13,12 +13,14 @@ import { AdminPage } from '@/components/admin/PageShell';
import { endAdminSession } from '@/lib/admin/session';
import { PERMISSIONS } from '@/lib/admin/permissions';
import { SkillSurface } from '@/components/skills/SkillSurface';
import { useTranslation } from 'react-i18next';
/**
* Admin Profile — Premium enterprise account & security control center.
*/
export default function AdminProfile() {
const { t } = useTranslation();
const navigate = useNavigate();
const { data: user } = useCurrentUser();
const updateProfile = useUpdateProfile();
@@ -89,8 +91,8 @@ export default function AdminProfile() {
return (
<AdminPage
title="Admin Profile"
subtitle="Manage your platform credentials, system permissions and security governance."
title={t('profile.adminProfile')}
subtitle={t('profile.manageYourPlatformCredentialsSystemPermissions')}
/* Three buttons need 295px; a 320px phone has 273 after the page gutters,
so the row wraps instead of pushing Sign Out off the edge. */
actions={
@@ -101,21 +103,21 @@ export default function AdminProfile() {
>
<SkillSurface page="profile" placement="after-header" />
<SettingsIcon className="w-4 h-4 text-[#687386]" />
Settings
{t('profile.settings')}
</button>
<button
onClick={() => setEditOpen(true)}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl bg-[#0838E0] hover:bg-[#062BAF] text-white text-[13px] font-semibold transition-all shadow-md shadow-blue-500/20 hover:scale-[1.02]"
>
<Pencil className="w-4 h-4" />
Edit Profile
{t('profile.editProfile')}
</button>
<button
onClick={() => { endAdminSession(); base44.auth.logout('/admin/login'); }}
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-xl bg-red-50 hover:bg-red-100 text-red-600 border border-red-200/60 text-[13px] font-semibold transition-all"
>
<LogOut className="w-4 h-4" />
Sign Out
{t('profile.signOut')}
</button>
</div>
}
@@ -150,7 +152,7 @@ export default function AdminProfile() {
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white">{fullName}</h1>
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full bg-white/20 backdrop-blur-md text-white text-[11px] font-semibold border border-white/30">
<ShieldCheck className="w-3.5 h-3.5 text-sky-300" />
Verified Admin
{t('profile.verifiedAdmin')}
</span>
</div>
<p className="text-[14px] text-blue-100 font-normal">{email}</p>
@@ -158,16 +160,16 @@ export default function AdminProfile() {
<div className="flex items-center gap-2 pt-1 flex-wrap">
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-lg bg-white/10 backdrop-blur-md text-white text-[12px] font-medium border border-white/20">
<Shield className="w-3.5 h-3.5 text-yellow-300" />
Platform Administrator
{t('profile.platformAdministrator')}
</span>
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-lg bg-[#16A765]/20 backdrop-blur-md text-emerald-200 text-[12px] font-semibold border border-[#16A765]/40">
<span className="w-2 h-2 rounded-full bg-[#16A765] animate-ping shrink-0" />
Active · Verified
{t('profile.activeVerified')}
</span>
{twoFactor && (
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-lg bg-purple-500/20 backdrop-blur-md text-purple-200 text-[12px] font-medium border border-purple-400/30">
<Zap className="w-3.5 h-3.5 text-purple-300" />
2FA Protected
{t('profile.2faProtected')}
</span>
)}
</div>
@@ -177,14 +179,14 @@ export default function AdminProfile() {
{/* Quick stats in Hero */}
<div className="flex flex-wrap items-center gap-3 border-t md:border-t-0 md:border-l md:flex-nowrap border-white/20 pt-4 md:pt-0 md:pl-6">
<div className="flex-1 md:flex-none bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
<p className="text-[11px] uppercase tracking-wider text-blue-200 font-semibold">Scopes</p>
<p className="text-[11px] uppercase tracking-wider text-blue-200 font-semibold">{t('profile.scopes')}</p>
<p className="text-2xl font-bold text-white mt-0.5">{PERMISSIONS.length}</p>
<span className="text-[10px] text-blue-200">Full Control</span>
<span className="text-[10px] text-blue-200">{t('profile.fullControl')}</span>
</div>
<div className="flex-1 md:flex-none bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
<p className="text-[11px] uppercase tracking-wider text-blue-200 font-semibold">Security Level</p>
<p className="text-2xl font-bold text-emerald-300 mt-0.5">Tier 1</p>
<span className="text-[10px] text-blue-200">Max Privileges</span>
<p className="text-[11px] uppercase tracking-wider text-blue-200 font-semibold">{t('profile.securityLevel')}</p>
<p className="text-2xl font-bold text-emerald-300 mt-0.5">{t('profile.tier1')}</p>
<span className="text-[10px] text-blue-200">{t('profile.maxPrivileges')}</span>
</div>
</div>
</div>
@@ -194,46 +196,46 @@ export default function AdminProfile() {
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-white rounded-2xl border border-[#E0E4EA] p-4 shadow-xs hover:shadow-md transition-all group">
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] font-bold uppercase tracking-wider text-[#687386]">System Role</span>
<span className="text-[11px] font-bold uppercase tracking-wider text-[#687386]">{t('profile.systemRole')}</span>
<div className="w-8 h-8 rounded-xl bg-blue-50 text-[#0838E0] flex items-center justify-center group-hover:scale-110 transition-transform">
<Shield className="w-4 h-4" />
</div>
</div>
<p className="text-[15px] font-bold text-[#101820]">Platform Administrator</p>
<p className="text-[12px] text-[#687386] mt-0.5">Full System Governance</p>
<p className="text-[15px] font-bold text-[#101820]">{t('profile.platformAdministrator')}</p>
<p className="text-[12px] text-[#687386] mt-0.5">{t('profile.fullSystemGovernance')}</p>
</div>
<div className="bg-white rounded-2xl border border-[#E0E4EA] p-4 shadow-xs hover:shadow-md transition-all group">
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] font-bold uppercase tracking-wider text-[#687386]">Department</span>
<span className="text-[11px] font-bold uppercase tracking-wider text-[#687386]">{t('profile.department')}</span>
<div className="w-8 h-8 rounded-xl bg-indigo-50 text-indigo-600 flex items-center justify-center group-hover:scale-110 transition-transform">
<Building2 className="w-4 h-4" />
</div>
</div>
<p className="text-[15px] font-bold text-[#101820]">Workforce Operations</p>
<p className="text-[12px] text-[#687386] mt-0.5">Enterprise Management</p>
<p className="text-[15px] font-bold text-[#101820]">{t('profile.workforceOperations')}</p>
<p className="text-[12px] text-[#687386] mt-0.5">{t('profile.enterpriseManagement')}</p>
</div>
<div className="bg-white rounded-2xl border border-[#E0E4EA] p-4 shadow-xs hover:shadow-md transition-all group">
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] font-bold uppercase tracking-wider text-[#687386]">Account Status</span>
<span className="text-[11px] font-bold uppercase tracking-wider text-[#687386]">{t('profile.accountStatus')}</span>
<div className="w-8 h-8 rounded-xl bg-emerald-50 text-[#16A765] flex items-center justify-center group-hover:scale-110 transition-transform">
<ShieldCheck className="w-4 h-4" />
</div>
</div>
<p className="text-[15px] font-bold text-[#101820]">Active · Verified</p>
<p className="text-[12px] text-[#16A765] font-medium mt-0.5">All services active</p>
<p className="text-[15px] font-bold text-[#101820]">{t('profile.activeVerified')}</p>
<p className="text-[12px] text-[#16A765] font-medium mt-0.5">{t('profile.allServicesActive')}</p>
</div>
<div className="bg-white rounded-2xl border border-[#E0E4EA] p-4 shadow-xs hover:shadow-md transition-all group">
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] font-bold uppercase tracking-wider text-[#687386]">Member Since</span>
<span className="text-[11px] font-bold uppercase tracking-wider text-[#687386]">{t('profile.memberSince')}</span>
<div className="w-8 h-8 rounded-xl bg-violet-50 text-violet-600 flex items-center justify-center group-hover:scale-110 transition-transform">
<Clock className="w-4 h-4" />
</div>
</div>
<p className="text-[15px] font-bold text-[#101820]">{memberSince}</p>
<p className="text-[12px] text-[#687386] mt-0.5">Authenticated Account</p>
<p className="text-[12px] text-[#687386] mt-0.5">{t('profile.authenticatedAccount')}</p>
</div>
</div>
@@ -248,12 +250,12 @@ export default function AdminProfile() {
<div>
<h2 className="text-[16px] font-bold text-[#101820] flex items-center gap-2">
<Key className="w-4 h-4 text-[#0838E0]" />
Access & Scope Permissions
{t('profile.accessScopePermissions')}
</h2>
<p className="text-[13px] text-[#687386]">Operational scopes granted to your administrative account.</p>
<p className="text-[13px] text-[#687386]">{t('profile.operationalScopesGrantedToYourAdministrative')}</p>
</div>
<span className="px-3 py-1 rounded-full bg-blue-50 text-[#0838E0] text-[12px] font-semibold border border-blue-200/60">
{PERMISSIONS.length} Active Scopes
{PERMISSIONS.length} {t('profile.activeScopes')}
</span>
</div>
@@ -274,12 +276,12 @@ export default function AdminProfile() {
{isFull ? (
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-[#EAF8F0] text-[#16A765] text-[12px] font-bold border border-[#16A765]/30">
<CheckCircle2 className="w-3.5 h-3.5" />
Full Access
{t('profile.fullAccess')}
</span>
) : (
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-[#E8F0F8] text-[#0838E0] text-[12px] font-bold border border-[#0838E0]/20">
<Eye className="w-3.5 h-3.5" />
Read Only
{t('profile.readOnly')}
</span>
)}
</div>
@@ -295,12 +297,12 @@ export default function AdminProfile() {
<div>
<h2 className="text-[16px] font-bold text-[#101820] flex items-center gap-2">
<Activity className="w-4 h-4 text-[#0838E0]" />
Recent Account Activity
{t('profile.recentAccountActivity')}
</h2>
<p className="text-[13px] text-[#687386]">Audit log of administrative actions performed on your profile.</p>
<p className="text-[13px] text-[#687386]">{t('profile.auditLogOfAdministrativeActionsPerformed')}</p>
</div>
<span className="px-3 py-1 rounded-full bg-[#F8F8F8] text-[#687386] text-[12px] font-medium border border-[#E0E4EA]">
{myActivity.length} Events Logged
{myActivity.length} {t('profile.eventsLogged')}
</span>
</div>
@@ -326,7 +328,7 @@ export default function AdminProfile() {
) : (
<div className="text-center py-8 bg-[#F8F8F8] rounded-xl border border-dashed border-[#E0E4EA]">
<Activity className="w-8 h-8 text-[#8B94A5] mx-auto mb-2" />
<p className="text-[13px] text-[#687386]">No recent activity recorded for this account.</p>
<p className="text-[13px] text-[#687386]">{t('profile.noRecentActivityRecordedForThis')}</p>
</div>
)}
</div>
@@ -339,29 +341,29 @@ export default function AdminProfile() {
<div id="security" className="bg-white rounded-2xl border border-[#E0E4EA] p-6 shadow-xs space-y-5">
<div className="flex items-center gap-2 border-b border-[#E0E4EA] pb-3">
<ShieldCheck className="w-5 h-5 text-[#0838E0]" />
<h2 className="text-[16px] font-bold text-[#101820]">Security & 2FA</h2>
<h2 className="text-[16px] font-bold text-[#101820]">{t('profile.security2fa')}</h2>
</div>
{/* Last Sign-In */}
<div className="p-3.5 rounded-xl bg-[#F8F8F8] border border-[#E0E4EA]">
<div className="flex items-center justify-between">
<span className="text-[11px] font-bold uppercase tracking-wider text-[#687386]">Last Sign-in</span>
<span className="text-[11px] font-bold uppercase tracking-wider text-[#687386]">{t('profile.lastSignIn')}</span>
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-[#16A765] bg-[#EAF8F0] px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-[#16A765]" />
Active Device
{t('profile.activeDevice')}
</span>
</div>
<p className="text-[14px] font-bold text-[#101820] mt-1">
{new Date().toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}
</p>
<p className="text-[12px] text-[#687386] mt-0.5">IP: 10.24.11.86 · {deviceLabel}</p>
<p className="text-[12px] text-[#687386] mt-0.5">{t('profile.ip10241186')} {deviceLabel}</p>
</div>
{/* 2FA Toggle */}
<div className="p-3.5 rounded-xl border border-[#E0E4EA] bg-white">
<Toggle
label="Two-Factor Authentication"
description="Require code verification for all admin logins"
label={t('profile.twoFactorAuthentication')}
description={t('profile.requireCodeVerificationForAllAdmin')}
checked={twoFactor}
onCheckedChange={(v) => {
setTwoFactor(v);
@@ -377,14 +379,14 @@ export default function AdminProfile() {
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl border border-[#E0E4EA] bg-white hover:bg-[#F8F8F8] text-[#101820] text-[13px] font-semibold transition-all shadow-xs"
>
<KeyRound className="w-4 h-4 text-[#0838E0]" />
Change Password
{t('profile.changePassword')}
</button>
<button
onClick={() => toast.info('Session management is active for this device.')}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl border border-[#E0E4EA] bg-white hover:bg-[#F8F8F8] text-[#101820] text-[13px] font-semibold transition-all shadow-xs"
>
<Smartphone className="w-4 h-4 text-[#687386]" />
Manage Active Sessions
{t('profile.manageActiveSessions')}
</button>
</div>
@@ -392,7 +394,7 @@ export default function AdminProfile() {
<div className="flex items-start gap-2.5 p-3 rounded-xl bg-amber-50/80 border border-amber-200/60 text-amber-900 text-[12px] leading-relaxed">
<AlertCircle className="w-4 h-4 text-amber-600 shrink-0 mt-0.5" />
<p>
Password last changed <strong>42 days ago</strong>. Administrator credentials expire every 90 days.
{t('profile.passwordLastChanged')} <strong>{t('profile.42DaysAgo')}</strong>{t('profile.administratorCredentialsExpireEvery90Days')}
</p>
</div>
</div>
@@ -401,33 +403,33 @@ export default function AdminProfile() {
<div className="bg-white rounded-2xl border border-[#E0E4EA] p-6 shadow-xs space-y-4">
<div className="flex items-center gap-2 border-b border-[#E0E4EA] pb-3">
<Laptop className="w-5 h-5 text-[#0838E0]" />
<h2 className="text-[16px] font-bold text-[#101820]">Current Active Session</h2>
<h2 className="text-[16px] font-bold text-[#101820]">{t('profile.currentActiveSession')}</h2>
</div>
<dl className="space-y-3 text-[13px]">
<div className="flex justify-between items-center border-b border-[#F1F4F7] pb-2.5">
<dt className="text-[#687386] font-medium">Session Started</dt>
<dt className="text-[#687386] font-medium">{t('profile.sessionStarted')}</dt>
<dd className="font-semibold text-[#101820]">{sessionStarted}</dd>
</div>
<div className="flex justify-between items-center border-b border-[#F1F4F7] pb-2.5">
<dt className="text-[#687386] font-medium">Session Type</dt>
<dd className="font-semibold text-[#101820]">Browser Tab</dd>
<dt className="text-[#687386] font-medium">{t('profile.sessionType')}</dt>
<dd className="font-semibold text-[#101820]">{t('profile.browserTab')}</dd>
</div>
<div className="flex justify-between items-center border-b border-[#F1F4F7] pb-2.5">
<dt className="text-[#687386] font-medium">Platform / OS</dt>
<dt className="text-[#687386] font-medium">{t('profile.platformOs')}</dt>
<dd className="font-semibold text-[#101820]">{deviceLabel}</dd>
</div>
<div className="flex justify-between items-center">
<dt className="text-[#687386] font-medium">Token Security</dt>
<dt className="text-[#687386] font-medium">{t('profile.tokenSecurity')}</dt>
<dd className="font-semibold text-[#16A765] inline-flex items-center gap-1">
<Lock className="w-3.5 h-3.5" /> Encrypted
<Lock className="w-3.5 h-3.5" /> {t('profile.encrypted')}
</dd>
</div>
</dl>
<div className="pt-2">
<p className="text-[11px] text-[#8B94A5] leading-relaxed bg-[#F8F8F8] p-3 rounded-xl border border-[#E0E4EA]">
Administrator sessions expire automatically on browser close to enforce zero-trust security.
{t('profile.administratorSessionsExpireAutomaticallyOnBrowser')}
</p>
</div>
</div>
@@ -438,20 +440,20 @@ export default function AdminProfile() {
<Modal
open={editOpen}
onOpenChange={setEditOpen}
title="Edit Admin Profile"
description="Update your display name and administrative credentials."
title={t('profile.editAdminProfile')}
description={t('profile.updateYourDisplayNameAndAdministrative')}
icon={Pencil}
size="sm"
busy={saving}
footer={
<>
<Button variant="outline" onClick={() => setEditOpen(false)} disabled={saving}>Cancel</Button>
<Button onClick={save} loading={saving}>Save Changes</Button>
<Button variant="outline" onClick={() => setEditOpen(false)} disabled={saving}>{t('profile.cancel')}</Button>
<Button onClick={save} loading={saving}>{t('profile.saveChanges')}</Button>
</>
}
>
<div className="space-y-4">
<Field label="Full Name" required error={nameError}>
<Field label={t('profile.fullName')} required error={nameError}>
<Input
value={form.full_name}
onChange={(e) => {
@@ -460,7 +462,7 @@ export default function AdminProfile() {
}}
/>
</Field>
<Field label="Email Address" hint="Email changes require system verification.">
<Field label={t('profile.emailAddress')} hint={t('profile.emailChangesRequireSystemVerification')}>
<Input value={form.email} readOnly disabled />
</Field>
</div>
@@ -470,25 +472,25 @@ export default function AdminProfile() {
<Modal
open={passwordOpen}
onOpenChange={setPasswordOpen}
title="Change Password"
description="Update your administrator login password."
title={t('profile.changePassword')}
description={t('profile.updateYourAdministratorLoginPassword')}
icon={KeyRound}
size="sm"
footer={
<>
<Button variant="outline" onClick={() => setPasswordOpen(false)}>Cancel</Button>
<Button variant="outline" onClick={() => setPasswordOpen(false)}>{t('profile.cancel')}</Button>
<Button onClick={() => { setPasswordOpen(false); toast.success('Password updated'); }}>
Update Password
{t('profile.updatePassword')}
</Button>
</>
}
>
<div className="space-y-4">
<Field label="Current Password"><Input type="password" autoComplete="current-password" /></Field>
<Field label="New Password" hint="At least 12 characters, with numbers and symbols.">
<Field label={t('profile.currentPassword')}><Input type="password" autoComplete="current-password" /></Field>
<Field label={t('profile.newPassword')} hint={t('profile.atLeast12CharactersWithNumbers')}>
<Input type="password" autoComplete="new-password" />
</Field>
<Field label="Confirm New Password"><Input type="password" autoComplete="new-password" /></Field>
<Field label={t('profile.confirmNewPassword')}><Input type="password" autoComplete="new-password" /></Field>
</div>
</Modal>
</div>

View File

@@ -11,12 +11,14 @@ import {
useJobPostings, usePreferences, useStaff, useUpdatePreferences,
} from '@/lib/krowHooks';
import { AdminPage } from '@/components/admin/PageShell';
import { useTranslation } from 'react-i18next';
/**
* Admin Settings — Full-width platform configuration control center.
*/
export default function AdminSettings() {
const { t } = useTranslation();
const preferences = usePreferences();
const updatePreferences = useUpdatePreferences();
const panel = useAssistantPanel();
@@ -36,8 +38,8 @@ export default function AdminSettings() {
return (
<AdminPage
title="Settings"
subtitle="Manage your account, organization and system configuration."
title={t('settings.settings')}
subtitle={t('settings.manageYourAccountOrganizationAndSystem')}
>
<div className="space-y-6 w-full">
{/* ── 1. Hero Banner (Full Width) ───────────────────────────────── */}
@@ -47,29 +49,29 @@ export default function AdminSettings() {
<div className="space-y-2">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-white/20 backdrop-blur-md text-white text-[12px] font-semibold border border-white/30">
<Sliders className="w-3.5 h-3.5 text-sky-300" />
Workspace Control Center
{t('settings.workspaceControlCenter')}
</div>
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white">Settings </h1>
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white">{t('settings.settings')} </h1>
<p className="text-[14px] text-blue-100 font-normal max-w-2xl">
Configure account credentials, access security policies, organizational workforce units, and system automation behavior.
{t('settings.configureAccountCredentialsAccessSecurityPolicies')}
</p>
</div>
<div className="flex flex-wrap items-center gap-3 sm:flex-nowrap sm:shrink-0">
<div className="flex-1 sm:flex-none bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
<p className="text-[11px] uppercase tracking-wider text-blue-200 font-semibold">Active Staff</p>
<p className="text-[11px] uppercase tracking-wider text-blue-200 font-semibold">{t('settings.activeStaff')}</p>
<p className="text-2xl font-bold text-white mt-0.5">{staff.length}</p>
<span className="text-[10px] text-blue-200">On Platform</span>
<span className="text-[10px] text-blue-200">{t('settings.onPlatform')}</span>
</div>
<div className="flex-1 sm:flex-none bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
<p className="text-[11px] uppercase tracking-wider text-blue-200 font-semibold">Positions</p>
<p className="text-[11px] uppercase tracking-wider text-blue-200 font-semibold">{t('settings.positions')}</p>
<p className="text-2xl font-bold text-emerald-300 mt-0.5">{postings.length}</p>
<span className="text-[10px] text-blue-200">Active Roles</span>
<span className="text-[10px] text-blue-200">{t('settings.activeRoles')}</span>
</div>
<div className="flex-1 sm:flex-none bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
<p className="text-[11px] uppercase tracking-wider text-blue-200 font-semibold">Clients</p>
<p className="text-[11px] uppercase tracking-wider text-blue-200 font-semibold">{t('settings.clients')}</p>
<p className="text-2xl font-bold text-sky-300 mt-0.5">{clientCount}</p>
<span className="text-[10px] text-blue-200">Organizations</span>
<span className="text-[10px] text-blue-200">{t('settings.organizations')}</span>
</div>
</div>
</div>
@@ -84,10 +86,10 @@ export default function AdminSettings() {
<div className="flex items-center justify-between border-b border-[#E0E4EA] pb-3">
<div className="flex items-center gap-2">
<UserIcon className="w-5 h-5 text-[#0838E0]" />
<h2 className="text-[16px] font-bold text-[#101820]">Account & Security</h2>
<h2 className="text-[16px] font-bold text-[#101820]">{t('settings.accountSecurity')}</h2>
</div>
<span className="text-[12px] font-semibold text-[#0838E0] bg-blue-50 px-2.5 py-0.5 rounded-full border border-blue-200/60">
Personal Credentials
{t('settings.personalCredentials')}
</span>
</div>
@@ -101,10 +103,10 @@ export default function AdminSettings() {
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<p className="text-[14px] font-bold text-[#101820] group-hover:text-[#0838E0] transition-colors">Profile Details</p>
<p className="text-[14px] font-bold text-[#101820] group-hover:text-[#0838E0] transition-colors">{t('settings.profileDetails')}</p>
<ChevronRight className="w-4 h-4 text-[#8B94A5] group-hover:text-[#0838E0] group-hover:translate-x-0.5 transition-all" />
</div>
<p className="text-[12px] text-[#687386] mt-1 leading-relaxed">Your name, email address, and platform identity information.</p>
<p className="text-[12px] text-[#687386] mt-1 leading-relaxed">{t('settings.yourNameEmailAddressAndPlatform')}</p>
</div>
</Link>
@@ -117,10 +119,10 @@ export default function AdminSettings() {
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<p className="text-[14px] font-bold text-[#101820] group-hover:text-purple-600 transition-colors">Security & 2FA</p>
<p className="text-[14px] font-bold text-[#101820] group-hover:text-purple-600 transition-colors">{t('settings.security2fa')}</p>
<ChevronRight className="w-4 h-4 text-[#8B94A5] group-hover:text-purple-600 group-hover:translate-x-0.5 transition-all" />
</div>
<p className="text-[12px] text-[#687386] mt-1 leading-relaxed">Password management, two-factor authentication and active sessions.</p>
<p className="text-[12px] text-[#687386] mt-1 leading-relaxed">{t('settings.passwordManagementTwoFactorAuthenticationAnd')}</p>
</div>
</Link>
</div>
@@ -131,10 +133,10 @@ export default function AdminSettings() {
<div className="flex items-center justify-between border-b border-[#E0E4EA] pb-3">
<div className="flex items-center gap-2">
<Building2 className="w-5 h-5 text-indigo-600" />
<h2 className="text-[16px] font-bold text-[#101820]">Organization Structure</h2>
<h2 className="text-[16px] font-bold text-[#101820]">{t('settings.organizationStructure')}</h2>
</div>
<span className="text-[12px] font-semibold text-indigo-600 bg-indigo-50 px-2.5 py-0.5 rounded-full border border-indigo-200/60">
Workforce Operations
{t('settings.workforceOperations')}
</span>
</div>
@@ -148,13 +150,13 @@ export default function AdminSettings() {
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<p className="text-[14px] font-bold text-[#101820] group-hover:text-indigo-600 transition-colors">Clients & Positions</p>
<p className="text-[14px] font-bold text-[#101820] group-hover:text-indigo-600 transition-colors">{t('settings.clientsPositions')}</p>
<ChevronRight className="w-4 h-4 text-[#8B94A5] group-hover:text-indigo-600 group-hover:translate-x-0.5 transition-all" />
</div>
<p className="text-[12px] text-[#687386] mt-1 leading-relaxed">Companies and active positions currently being staffed.</p>
<p className="text-[12px] text-[#687386] mt-1 leading-relaxed">{t('settings.companiesAndActivePositionsCurrentlyBeing')}</p>
<div className="mt-2.5">
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-indigo-700 bg-indigo-50 px-2.5 py-0.5 rounded-md">
{clientCount} clients · {postings.length} positions
{clientCount} {t('settings.clients2')} {postings.length} positions
</span>
</div>
</div>
@@ -169,13 +171,13 @@ export default function AdminSettings() {
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<p className="text-[14px] font-bold text-[#101820] group-hover:text-[#16A765] transition-colors">Users & Permissions</p>
<p className="text-[14px] font-bold text-[#101820] group-hover:text-[#16A765] transition-colors">{t('settings.usersPermissions')}</p>
<ChevronRight className="w-4 h-4 text-[#8B94A5] group-hover:text-[#16A765] group-hover:translate-x-0.5 transition-all" />
</div>
<p className="text-[12px] text-[#687386] mt-1 leading-relaxed">Platform user roles and administrative permission scopes.</p>
<p className="text-[12px] text-[#687386] mt-1 leading-relaxed">{t('settings.platformUserRolesAndAdministrativePermission')}</p>
<div className="mt-2.5">
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-emerald-700 bg-emerald-50 px-2.5 py-0.5 rounded-md">
{staff.length} on staff
{staff.length} {t('settings.onStaff')}
</span>
</div>
</div>
@@ -193,8 +195,8 @@ export default function AdminSettings() {
<Activity className="w-6 h-6" />
</div>
<div>
<p className="text-[15px] font-bold text-[#101820] group-hover:text-amber-600 transition-colors">Audit & System Activity Log</p>
<p className="text-[12px] text-[#687386] mt-0.5">Review all platform administrative actions, login events and security logs.</p>
<p className="text-[15px] font-bold text-[#101820] group-hover:text-amber-600 transition-colors">{t('settings.auditSystemActivityLog')}</p>
<p className="text-[12px] text-[#687386] mt-0.5">{t('settings.reviewAllPlatformAdministrativeActionsLogin')}</p>
</div>
</div>
<ChevronRight className="w-5 h-5 text-[#8B94A5] group-hover:text-amber-600 group-hover:translate-x-0.5 transition-all" />
@@ -208,24 +210,24 @@ export default function AdminSettings() {
<div className="flex items-center justify-between border-b border-[#E0E4EA] pb-3">
<div className="flex items-center gap-2">
<Sliders className="w-5 h-5 text-amber-600" />
<h2 className="text-[16px] font-bold text-[#101820]">Workspace Preferences</h2>
<h2 className="text-[16px] font-bold text-[#101820]">{t('settings.workspacePreferences')}</h2>
</div>
<span className="text-[12px] font-semibold text-amber-700 bg-amber-50 px-2.5 py-0.5 rounded-full border border-amber-200/60">
Automation Controls
{t('settings.automationControls')}
</span>
</div>
<div className="p-5 rounded-xl border border-[#E0E4EA] bg-[#F8F8F8]/50 space-y-4">
<div className="flex items-center gap-2 pb-1">
<Bell className="w-4 h-4 text-[#0838E0]" />
<p className="text-[13px] font-bold text-[#101820]">Account Behaviors & Notifications</p>
<p className="text-[13px] font-bold text-[#101820]">{t('settings.accountBehaviorsNotifications')}</p>
</div>
<div className="space-y-4 divide-y divide-[#E0E4EA] pt-1">
<div className="pt-3 first:pt-0">
<Toggle
label="Owliver Workspace Assistant"
description="Open the AI contextual panel by default on supported admin pages"
label={t('settings.owliverWorkspaceAssistant')}
description={t('settings.openTheAiContextualPanelBy')}
checked={preferences.owliverDefault}
onCheckedChange={(v) => {
if (v) panel.open(); else panel.close();
@@ -239,8 +241,8 @@ export default function AdminSettings() {
<div className="pt-3">
<Toggle
label="Compact UI Density"
description="Tighter table row spacing for high-density monitoring"
label={t('settings.compactUiDensity')}
description={t('settings.tighterTableRowSpacingForHigh')}
checked={preferences.compactDensity}
onCheckedChange={(v) => setPreference(
'compactDensity', v,
@@ -251,8 +253,8 @@ export default function AdminSettings() {
<div className="pt-3">
<Toggle
label="Daily Executive Email Digest"
description="Automated daily summary of platform applications and actions"
label={t('settings.dailyExecutiveEmailDigest')}
description={t('settings.automatedDailySummaryOfPlatformApplications')}
checked={preferences.emailDigest}
onCheckedChange={(v) => setPreference(
'emailDigest', v,
@@ -268,24 +270,24 @@ export default function AdminSettings() {
<div className="bg-white rounded-2xl border border-[#E0E4EA] p-6 shadow-xs space-y-4">
<div className="flex items-center gap-2 border-b border-[#E0E4EA] pb-3">
<Server className="w-5 h-5 text-[#0838E0]" />
<h2 className="text-[16px] font-bold text-[#101820]">Environment & Compliance</h2>
<h2 className="text-[16px] font-bold text-[#101820]">{t('settings.environmentCompliance')}</h2>
</div>
<dl className="space-y-3 text-[13px]">
<div className="flex justify-between items-center border-b border-[#F1F4F7] pb-2.5">
<dt className="text-[#687386] font-medium">Environment</dt>
<dt className="text-[#687386] font-medium">{t('settings.environment')}</dt>
<dd className="font-semibold text-[#101820] flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-[#16A765]" /> Production (Live)
<span className="w-2 h-2 rounded-full bg-[#16A765]" /> {t('settings.productionLive')}
</dd>
</div>
<div className="flex justify-between items-center border-b border-[#F1F4F7] pb-2.5">
<dt className="text-[#687386] font-medium">Workspace Isolation</dt>
<dd className="font-semibold text-[#101820]">Enterprise Tenant #1042</dd>
<dt className="text-[#687386] font-medium">{t('settings.workspaceIsolation')}</dt>
<dd className="font-semibold text-[#101820]">{t('settings.enterpriseTenant1042')}</dd>
</div>
<div className="flex justify-between items-center">
<dt className="text-[#687386] font-medium">Security Standard</dt>
<dt className="text-[#687386] font-medium">{t('settings.securityStandard')}</dt>
<dd className="font-semibold text-[#16A765] inline-flex items-center gap-1">
<ShieldCheck className="w-3.5 h-3.5" /> SOC-2 Type II
<ShieldCheck className="w-3.5 h-3.5" /> {t('settings.soc2TypeIi')}
</dd>
</div>
</dl>
@@ -293,7 +295,7 @@ export default function AdminSettings() {
<div className="pt-1">
<div className="flex items-center gap-2 p-3.5 rounded-xl bg-blue-50/60 border border-blue-200/50 text-[#0838E0] text-[12px] font-medium leading-relaxed">
<Shield className="w-4 h-4 shrink-0 text-[#0838E0]" />
<span>Administrator configurations apply across this entire workspace environment.</span>
<span>{t('settings.administratorConfigurationsApplyAcrossThisEntire')}</span>
</div>
</div>
</div>

View File

@@ -8,6 +8,7 @@ import { useWorkforcePaths } from '@/lib/skills/usePageSkills';
import { profileForEmail } from '@/lib/skillGraph';
import { SkillProgressDetail } from '@/components/forge';
import { AdminPage } from '@/components/admin/PageShell';
import { useTranslation } from 'react-i18next';
/**
* Skill development — every training path, as a management list.
@@ -80,6 +81,7 @@ function LevelTrail({ state }) {
/** One training path. Everything a manager asks of it, on one row. */
function PathRow({ definition, state, onOpen }) {
const { t } = useTranslation();
const status = STATUS[summaryStatus(state)];
const percent = state.totalModules
? Math.round((state.totalCompleted / state.totalModules) * 100)
@@ -113,7 +115,7 @@ function PathRow({ definition, state, onOpen }) {
it — a percentage alone does not say how much work that is. */}
<div className="min-w-0">
<div className="flex items-baseline justify-between gap-2">
<span className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Progress</span>
<span className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">{t('skillDevelopment.progress')}</span>
<span className="text-caption font-semibold tabular-nums text-ink-1">{percent}%</span>
</div>
<ProgressBar
@@ -129,10 +131,10 @@ function PathRow({ definition, state, onOpen }) {
{/* Current level and the one thing to do next. */}
<div className="min-w-0">
<p className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Current level</p>
<p className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">{t('skillDevelopment.currentLevel')}</p>
<p className="text-body-sm font-medium text-ink-1">{state.verifiedLabel}</p>
<p className="mt-1 truncate text-caption text-ink-3">
<span className="text-ink-4">Next: </span>
<span className="text-ink-4">{t('skillDevelopment.next')} </span>
{state.nextRequirement
? state.nextRequirement.title
: state.nextLevel ? 'No training authored yet' : 'Nothing outstanding'}
@@ -141,7 +143,7 @@ function PathRow({ definition, state, onOpen }) {
<div className="md:justify-self-end">
<Button variant="outline" size="sm" shape="rounded" onClick={() => onOpen({ definition, state })}>
View <ArrowRight aria-hidden="true" />
{t('skillDevelopment.view')} <ArrowRight aria-hidden="true" />
</Button>
</div>
</div>
@@ -159,6 +161,7 @@ function SummaryStat({ label, value }) {
}
export default function AdminSkillDevelopment() {
const { t } = useTranslation();
const navigate = useNavigate();
const { data: user } = useCurrentUser();
const { data: courses = [] } = useCourses();
@@ -192,16 +195,16 @@ export default function AdminSkillDevelopment() {
return (
<AdminPage
title="Skill development"
title={t('skillDevelopment.skillDevelopment')}
meta={`${rows.length} path${rows.length === 1 ? '' : 's'}`}
subtitle="Track workforce progress across every training path. Levels are earned by completing modules and passing Owliver verification."
subtitle={t('skillDevelopment.trackWorkforceProgressAcrossEveryTraining')}
actions={
<>
<Button variant="outline" size="sm" shape="rounded" onClick={() => navigate('/admin/workspace')}>
<ChevronLeft aria-hidden="true" /> Agent Registry
<ChevronLeft aria-hidden="true" /> {t('skillDevelopment.agentRegistry')}
</Button>
<Button variant="outline" size="sm" shape="rounded" onClick={() => navigate('/admin/university')}>
Manage training
{t('skillDevelopment.manageTraining')}
</Button>
</>
}
@@ -216,9 +219,9 @@ export default function AdminSkillDevelopment() {
className="flex divide-x divide-border overflow-hidden"
>
<SummaryStat label={rows.length === 1 ? 'Path' : 'Paths'} value={rows.length} />
<SummaryStat label="In progress" value={summary.in_progress} />
<SummaryStat label="Completed" value={summary.completed} />
<SummaryStat label="Not started" value={summary.not_started} />
<SummaryStat label={t('skillDevelopment.inProgress')} value={summary.in_progress} />
<SummaryStat label={t('skillDevelopment.completed')} value={summary.completed} />
<SummaryStat label={t('skillDevelopment.notStarted')} value={summary.not_started} />
</Surface>
<Surface
@@ -240,8 +243,7 @@ export default function AdminSkillDevelopment() {
</>
) : (
<p className="text-body-sm text-ink-3">
No training paths are registered yet. A path is a Markdown definition that
declares a ladder — add one from Skills and it appears here.
{t('skillDevelopment.noTrainingPathsAreRegisteredYet')}
</p>
)}

View File

@@ -9,6 +9,7 @@ import { composePage } from '@/lib/ui/composition';
import { SORTS } from '@/pages/admin/talent-pool/nodes';
import '@/components/ui-tree/nodeTypes';
import '@/pages/admin/talent-pool/nodes';
import { useTranslation } from 'react-i18next';
/**
* Admin Talent Pool — talent intelligence, not a card gallery.
@@ -26,6 +27,7 @@ const BANDS = {
export default function AdminTalentPool() {
const { t } = useTranslation();
const { data: profiles = [], isLoading } = useWorkerProfiles();
const [search, setSearch] = useState('');
@@ -118,8 +120,8 @@ export default function AdminTalentPool() {
return (
<AdminPage
title="Talent Pool"
subtitle="Discover and manage high-potential talent for future hiring."
title={t('talentPool.talentPool')}
subtitle={t('talentPool.discoverAndManageHighPotentialTalent')}
>
<UiEditor />
<TalentPoolComposition context={context} />

View File

@@ -9,6 +9,7 @@ import { AdminPage } from '@/components/admin/PageShell';
import { useApplications, useJobPostings, useInterviews, useStaff, useWorkerProfiles } from '@/lib/krowHooks';
import { applicationsForProfile, isFinalSelection } from '@/lib/hiringRecords';
import AddToPositionModal from '@/components/krow/AddToPositionModal';
import { useTranslation } from 'react-i18next';
/**
* The person, not the application.
@@ -79,6 +80,7 @@ function Tags({ title, values = [] }: TagsProps) {
}
export default function TalentProfile() {
const { t } = useTranslation();
const { id } = useParams();
const navigate = useNavigate();
@@ -102,16 +104,16 @@ export default function TalentProfile() {
);
if (isLoading) {
return <AdminPage title="Loading…"><div className="h-40" /></AdminPage>;
return <AdminPage title={t('talentProfile.loading')}><div className="h-40" /></AdminPage>;
}
if (!profile) {
return (
<AdminPage title="Talent profile">
<AdminPage title={t('talentProfile.talentProfile')}>
<EmptyState
title="That profile is not in this talent pool"
description="It may have been removed, or the link may be out of date."
action={<Button variant="outline" onClick={() => navigate('/admin/talent-pool')}>Back to Talent Pool</Button>}
title={t('talentProfile.thatProfileIsNotInThis')}
description={t('talentProfile.itMayHaveBeenRemovedOr')}
action={<Button variant="outline" onClick={() => navigate('/admin/talent-pool')}>{t('talentProfile.backToTalentPool')}</Button>}
/>
</AdminPage>
);
@@ -126,10 +128,10 @@ export default function TalentProfile() {
actions={(
<div className="flex items-center gap-2">
<Button variant="ghost" onClick={() => navigate('/admin/talent-pool')}>
<ArrowLeft className="h-4 w-4" /> Talent Pool
<ArrowLeft className="h-4 w-4" /> {t('talentProfile.talentPool')}
</Button>
<Button onClick={() => setAddOpen(true)}>
<Plus className="h-4 w-4" /> Consider for a position
<Plus className="h-4 w-4" /> {t('talentProfile.considerForAPosition')}
</Button>
</div>
)}
@@ -158,8 +160,8 @@ export default function TalentProfile() {
</div>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<Tags title="Skills" values={profile.skills} />
<Tags title="Certifications" values={profile.certifications} />
<Tags title={t('talentProfile.skills')} values={profile.skills} />
<Tags title={t('talentProfile.certifications')} values={profile.certifications} />
</div>
</Surface>
@@ -167,14 +169,14 @@ export default function TalentProfile() {
one person, every position, each at its own stage. */}
<section aria-labelledby="talent-positions" className="space-y-3">
<h2 id="talent-positions" className="font-heading text-body font-semibold text-ink-1">
Positions considered for
{t('talentProfile.positionsConsideredFor')}
</h2>
{mine.length === 0 ? (
<EmptyState
title="Not yet considered for any position"
description="Adding someone to a position files an application and starts the hiring pipeline for that vacancy. The same person can be considered for several."
action={<Button onClick={() => setAddOpen(true)}><Plus className="h-4 w-4" /> Consider for a position</Button>}
title={t('talentProfile.notYetConsideredForAnyPosition')}
description={t('talentProfile.addingSomeoneToAPositionFiles')}
action={<Button onClick={() => setAddOpen(true)}><Plus className="h-4 w-4" /> {t('talentProfile.considerForAPosition')}</Button>}
/>
) : (
<div className="space-y-2">
@@ -200,7 +202,7 @@ export default function TalentProfile() {
{isFinalSelection(a, interviews) && (
<Badge variant="info" size="sm">
<Sparkles className="mr-1 h-3 w-3" aria-hidden="true" />
Awaiting decision
{t('talentProfile.awaitingDecision')}
</Badge>
)}
<StatusBadge status={a.status} />

View File

@@ -6,6 +6,7 @@ import { aiAgentSkills, allSkills } from '@/lib/skills/registry';
import { allAgents } from '@/lib/agents/registry';
import { AdminPage } from '@/components/admin/PageShell';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
import { useTranslation } from 'react-i18next';
/**
* Workspace — the way in to AI configuration, and only that.
@@ -32,6 +33,7 @@ import OwliverAvatar from '@/components/krow/OwliverAvatar';
*/
export default function AdminWorkspace() {
const { t } = useTranslation();
const preferences = usePreferences();
/* Read through the agent registry, never by counting skills — one registry
@@ -65,8 +67,8 @@ export default function AdminWorkspace() {
return (
<AdminPage
title="Agent Registry"
subtitle="Manage how Owliver behaves across Krow."
title={t('workspace.agentRegistry')}
subtitle={t('workspace.manageHowOwliverBehavesAcrossKrow')}
>
<div className="w-full space-y-6">
{/* ── Hero ───────────────────────────────────────────────────────
@@ -78,14 +80,13 @@ export default function AdminWorkspace() {
<div className="min-w-0 space-y-2">
<div className="inline-flex items-center gap-2 rounded-full border border-white/30 bg-white/20 px-3 py-1 text-[12px] font-semibold text-white backdrop-blur-md">
<Sparkles className="h-3.5 w-3.5 text-sky-300" />
AI Agent Registry
{t('workspace.aiAgentRegistry')}
</div>
<h1 className="text-2xl font-bold tracking-tight text-white sm:text-3xl">
AI Agent Registry
{t('workspace.aiAgentRegistry')}
</h1>
<p className="max-w-2xl text-[14px] font-normal text-blue-100">
Manage the agents that power Owliver across Krow. Each agent decides which
pages it answers on and which skills it may use.
{t('workspace.manageTheAgentsThatPowerOwliver')}
</p>
</div>
@@ -112,31 +113,29 @@ export default function AdminWorkspace() {
<div className="flex items-center justify-between gap-3 border-b border-[#E0E4EA] pb-3">
<div className="flex min-w-0 items-center gap-2">
<OwliverAvatar className="h-6 w-6" rounded="rounded-md" />
<h2 className="truncate text-[16px] font-bold text-[#101820]">Agents</h2>
<h2 className="truncate text-[16px] font-bold text-[#101820]">{t('workspace.agents')}</h2>
</div>
<span className="shrink-0 rounded-full border border-blue-200/60 bg-blue-50 px-3 py-1 text-[12px] font-semibold text-[#0838E0]">
{agentCount} Registered
{agentCount} {t('workspace.registered')}
</span>
</div>
<p className="text-[13px] text-[#687386]">
An agent is how Owliver answers on a page: which skills it may use, what it knows,
and how much work an answer is worth. Agents narrow what a page offers — they never
widen it.
{t('workspace.anAgentIsHowOwliverAnswers')}
</p>
{/* The hierarchy, said once, so the reader knows where skills live
before they click rather than after. */}
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 rounded-xl border border-[#E0E4EA] bg-[#F8F8F8]/60 px-3.5 py-2.5 text-[12px] text-[#687386]">
<span className="font-semibold text-[#101820]">Agents</span>
<span className="font-semibold text-[#101820]">{t('workspace.agents')}</span>
<ArrowRight className="h-3 w-3 shrink-0" aria-hidden="true" />
<span>Configure</span>
<span>{t('workspace.configure')}</span>
<ArrowRight className="h-3 w-3 shrink-0" aria-hidden="true" />
<span>Skills</span>
<span>{t('workspace.skills')}</span>
<ArrowRight className="h-3 w-3 shrink-0" aria-hidden="true" />
<span className="font-medium text-[#101820]">Owliver Skills</span>
<span className="font-medium text-[#101820]">{t('workspace.owliverSkills')}</span>
<span aria-hidden="true">/</span>
<span className="font-medium text-[#101820]">Board Skills</span>
<span className="font-medium text-[#101820]">{t('workspace.boardSkills')}</span>
</div>
<Link
@@ -144,7 +143,7 @@ export default function AdminWorkspace() {
className="inline-flex w-full items-center justify-center gap-2 rounded-xl bg-[#0838E0] px-5 py-3 text-[13px] font-semibold text-white shadow-md shadow-blue-500/20 transition-all hover:scale-[1.01] hover:bg-[#062BAF]"
>
<Sparkles className="h-4 w-4" />
Manage Agents
{t('workspace.manageAgents')}
<ArrowRight className="h-4 w-4" />
</Link>
</div>
@@ -156,13 +155,10 @@ export default function AdminWorkspace() {
<div className="flex items-start gap-3.5 rounded-2xl border border-blue-200/50 bg-blue-50/60 p-5 text-[13px] leading-relaxed text-[#0838E0]">
<BookOpen className="mt-0.5 h-5 w-5 shrink-0 text-[#0838E0]" />
<div className="min-w-0">
<p className="font-bold text-[#101820]">AI skills and workforce training are different things</p>
<p className="font-bold text-[#101820]">{t('workspace.aiSkillsAndWorkforceTrainingAre')}</p>
<p className="mt-0.5 text-[#687386]">
This page governs <span className="font-semibold">AI agent skills</span> — what Owliver
understands, and what a Board skill draws on a page. <span className="font-semibold">Workforce
training</span> — training paths, proving challenges, verification and talent
progression — is what a person learns, and it is managed in{' '}
<Link to="/admin/university" className="font-semibold underline hover:no-underline">KROW Forge</Link>.
{t('workspace.thisPageGoverns')} <span className="font-semibold">{t('workspace.aiAgentSkills')}</span> {t('workspace.whatOwliverUnderstandsAndWhatA')} <span className="font-semibold">{t('workspace.workforceTraining')}</span> {t('workspace.trainingPathsProvingChallengesVerificationAnd')}{' '}
<Link to="/admin/university" className="font-semibold underline hover:no-underline">{t('workspace.krowForge')}</Link>.
</p>
</div>
</div>

View File

@@ -14,6 +14,7 @@ import { searchAgents } from '@/lib/agents/registry';
import { agentIconFor } from '@/components/agents/icons';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
import { AdminPage } from '@/components/admin/PageShell';
import { useTranslation } from 'react-i18next';
/**
* Agents — the workspace management list.
@@ -42,6 +43,7 @@ function AgentGlyph({ agent, className = 'h-10 w-10' }) {
/** Card representation for Grid view */
function AgentCard({ agent, shipped, overridden, onOpen, onAction }) {
const { t } = useTranslation();
return (
<div
className={cn(
@@ -69,9 +71,9 @@ function AgentCard({ agent, shipped, overridden, onOpen, onAction }) {
{agent.status}
</Badge>
{shipped && overridden && (
<Badge variant="info" size="sm" className="shrink-0">Customized</Badge>
<Badge variant="info" size="sm" className="shrink-0">{t('workspaceAgents.customized')}</Badge>
)}
{!shipped && <Badge variant="neutral" size="sm" className="shrink-0">Custom</Badge>}
{!shipped && <Badge variant="neutral" size="sm" className="shrink-0">{t('workspaceAgents.custom')}</Badge>}
</div>
{agent.description && (
@@ -95,29 +97,29 @@ function AgentCard({ agent, shipped, overridden, onOpen, onAction }) {
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onOpen} className="cursor-pointer">
<Settings2 className="mr-2 h-3.5 w-3.5" /> Configure
<Settings2 className="mr-2 h-3.5 w-3.5" /> {t('workspaceAgents.configure')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onAction('duplicate', agent)} className="cursor-pointer">
<Copy className="mr-2 h-3.5 w-3.5" /> Duplicate
<Copy className="mr-2 h-3.5 w-3.5" /> {t('workspaceAgents.duplicate')}
</DropdownMenuItem>
<DropdownMenuSeparator />
{agent.status !== 'published' && (
<DropdownMenuItem onClick={() => onAction('publish', agent)} className="cursor-pointer">
<Send className="mr-2 h-3.5 w-3.5" /> Publish
<Send className="mr-2 h-3.5 w-3.5" /> {t('workspaceAgents.publish')}
</DropdownMenuItem>
)}
{agent.status === 'published' && (
<DropdownMenuItem onClick={() => onAction('republish', agent)} className="cursor-pointer">
<Send className="mr-2 h-3.5 w-3.5" /> Publish update
<Send className="mr-2 h-3.5 w-3.5" /> {t('workspaceAgents.publishUpdate')}
</DropdownMenuItem>
)}
{agent.status !== 'archived' ? (
<DropdownMenuItem onClick={() => onAction('archive', agent)} className="cursor-pointer">
<Archive className="mr-2 h-3.5 w-3.5" /> Archive
<Archive className="mr-2 h-3.5 w-3.5" /> {t('workspaceAgents.archive')}
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => onAction('restore', agent)} className="cursor-pointer">
<ArchiveRestore className="mr-2 h-3.5 w-3.5" /> Restore as draft
<ArchiveRestore className="mr-2 h-3.5 w-3.5" /> {t('workspaceAgents.restoreAsDraft')}
</DropdownMenuItem>
)}
{(overridden || !shipped) && (
@@ -125,8 +127,8 @@ function AgentCard({ agent, shipped, overridden, onOpen, onAction }) {
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onAction('remove', agent)} className="cursor-pointer text-destructive focus:text-destructive">
{shipped
? <><RotateCcw className="mr-2 h-3.5 w-3.5" /> Revert to shipped</>
: <><Trash2 className="mr-2 h-3.5 w-3.5" /> Remove</>}
? <><RotateCcw className="mr-2 h-3.5 w-3.5" /> {t('workspaceAgents.revertToShipped')}</>
: <><Trash2 className="mr-2 h-3.5 w-3.5" /> {t('workspaceAgents.remove')}</>}
</DropdownMenuItem>
</>
)}
@@ -137,7 +139,7 @@ function AgentCard({ agent, shipped, overridden, onOpen, onAction }) {
{/* When to use context */}
{agent.trigger && (
<div className="rounded-xl border border-border/60 bg-surface-subtle/60 px-3 py-2 text-[12px] text-ink-3">
<span className="font-semibold text-ink-2">Trigger: </span>
<span className="font-semibold text-ink-2">{t('workspaceAgents.trigger')} </span>
<span>{agent.trigger}</span>
</div>
)}
@@ -147,25 +149,25 @@ function AgentCard({ agent, shipped, overridden, onOpen, onAction }) {
<div className="mt-4 pt-3.5 border-t border-border/60 space-y-2.5">
<div className="flex flex-wrap items-center gap-1.5 text-[11px]">
<span className="inline-flex items-center gap-1 rounded-md bg-surface-subtle border border-border/60 px-2 py-0.5 font-medium text-ink-3">
<span className="text-ink-4">Skills</span>
<span className="text-ink-4">{t('workspaceAgents.skills')}</span>
<strong className="font-bold text-ink-1 tabular-nums">{agent.skills.length}</strong>
</span>
<span className="inline-flex items-center gap-1 rounded-md bg-surface-subtle border border-border/60 px-2 py-0.5 font-medium text-ink-3">
<span className="text-ink-4">Pages</span>
<span className="text-ink-4">{t('workspaceAgents.pages')}</span>
<strong className="font-bold text-ink-1 tabular-nums">{agent.pages.length}</strong>
</span>
{agent.subagents.length > 0 && (
<span className="inline-flex items-center gap-1 rounded-md bg-surface-subtle border border-border/60 px-2 py-0.5 font-medium text-ink-3">
<span className="text-ink-4">Subagents</span>
<span className="text-ink-4">{t('workspaceAgents.subagents')}</span>
<strong className="font-bold text-ink-1 tabular-nums">{agent.subagents.length}</strong>
</span>
)}
<span className="inline-flex items-center gap-1 rounded-md bg-surface-subtle border border-border/60 px-2 py-0.5 font-medium text-ink-3">
<span className="text-ink-4">Knowledge</span>
<span className="text-ink-4">{t('workspaceAgents.knowledge')}</span>
<strong className="font-bold text-ink-1 tabular-nums">{agent.knowledge.length}</strong>
</span>
<span className="inline-flex items-center gap-1 rounded-md bg-surface-subtle border border-border/60 px-2 py-0.5 font-medium text-ink-3">
<span className="text-ink-4">Reasoning</span>
<span className="text-ink-4">{t('workspaceAgents.reasoning')}</span>
<strong className="font-bold capitalize text-ink-1">{agent.reasoning}</strong>
</span>
</div>
@@ -187,6 +189,7 @@ function AgentCard({ agent, shipped, overridden, onOpen, onAction }) {
/** Directory Table Row for List view */
function AgentTableRow({ agent, shipped, overridden, onOpen, onAction }) {
const { t } = useTranslation();
return (
<tr className="group border-b border-border/60 transition-colors hover:bg-surface-subtle/50">
{/* Agent Identity */}
@@ -222,9 +225,9 @@ function AgentTableRow({ agent, shipped, overridden, onOpen, onAction }) {
{agent.status}
</Badge>
{shipped && overridden && (
<Badge variant="info" size="sm">Customized</Badge>
<Badge variant="info" size="sm">{t('workspaceAgents.customized')}</Badge>
)}
{!shipped && <Badge variant="neutral" size="sm">Custom</Badge>}
{!shipped && <Badge variant="neutral" size="sm">{t('workspaceAgents.custom')}</Badge>}
</div>
</td>
@@ -263,7 +266,7 @@ function AgentTableRow({ agent, shipped, overridden, onOpen, onAction }) {
<td className="py-3.5 pl-3 pr-4 align-middle text-right whitespace-nowrap">
<div className="inline-flex items-center gap-1.5">
<Button variant="outline" size="sm" onClick={onOpen} className="h-8 text-body-sm font-medium">
<Settings2 className="mr-1.5 h-3.5 w-3.5" /> Configure
<Settings2 className="mr-1.5 h-3.5 w-3.5" /> {t('workspaceAgents.configure')}
</Button>
<DropdownMenu>
@@ -279,29 +282,29 @@ function AgentTableRow({ agent, shipped, overridden, onOpen, onAction }) {
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onOpen} className="cursor-pointer">
<Settings2 className="mr-2 h-3.5 w-3.5" /> Configure
<Settings2 className="mr-2 h-3.5 w-3.5" /> {t('workspaceAgents.configure')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onAction('duplicate', agent)} className="cursor-pointer">
<Copy className="mr-2 h-3.5 w-3.5" /> Duplicate
<Copy className="mr-2 h-3.5 w-3.5" /> {t('workspaceAgents.duplicate')}
</DropdownMenuItem>
<DropdownMenuSeparator />
{agent.status !== 'published' && (
<DropdownMenuItem onClick={() => onAction('publish', agent)} className="cursor-pointer">
<Send className="mr-2 h-3.5 w-3.5" /> Publish
<Send className="mr-2 h-3.5 w-3.5" /> {t('workspaceAgents.publish')}
</DropdownMenuItem>
)}
{agent.status === 'published' && (
<DropdownMenuItem onClick={() => onAction('republish', agent)} className="cursor-pointer">
<Send className="mr-2 h-3.5 w-3.5" /> Publish update
<Send className="mr-2 h-3.5 w-3.5" /> {t('workspaceAgents.publishUpdate')}
</DropdownMenuItem>
)}
{agent.status !== 'archived' ? (
<DropdownMenuItem onClick={() => onAction('archive', agent)} className="cursor-pointer">
<Archive className="mr-2 h-3.5 w-3.5" /> Archive
<Archive className="mr-2 h-3.5 w-3.5" /> {t('workspaceAgents.archive')}
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => onAction('restore', agent)} className="cursor-pointer">
<ArchiveRestore className="mr-2 h-3.5 w-3.5" /> Restore as draft
<ArchiveRestore className="mr-2 h-3.5 w-3.5" /> {t('workspaceAgents.restoreAsDraft')}
</DropdownMenuItem>
)}
{(overridden || !shipped) && (
@@ -309,8 +312,8 @@ function AgentTableRow({ agent, shipped, overridden, onOpen, onAction }) {
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onAction('remove', agent)} className="cursor-pointer text-destructive focus:text-destructive">
{shipped
? <><RotateCcw className="mr-2 h-3.5 w-3.5" /> Revert to shipped</>
: <><Trash2 className="mr-2 h-3.5 w-3.5" /> Remove</>}
? <><RotateCcw className="mr-2 h-3.5 w-3.5" /> {t('workspaceAgents.revertToShipped')}</>
: <><Trash2 className="mr-2 h-3.5 w-3.5" /> {t('workspaceAgents.remove')}</>}
</DropdownMenuItem>
</>
)}
@@ -323,6 +326,7 @@ function AgentTableRow({ agent, shipped, overridden, onOpen, onAction }) {
}
export default function AdminWorkspaceAgents() {
const { t } = useTranslation();
const navigate = useNavigate();
const {
agents, diagnostics, saving, isShipped, isOverridden,
@@ -405,12 +409,12 @@ export default function AdminWorkspaceAgents() {
return (
<div className="w-full">
<AdminPage
title="Agents"
subtitle="The agents Owliver can answer as, what each one covers, and what it may do."
title={t('workspaceAgents.agents')}
subtitle={t('workspaceAgents.theAgentsOwliverCanAnswerAs')}
meta={`${agents.length} registered · ${publishedCount} published`}
actions={
<Button onClick={() => navigate('/admin/workspace/agents/new')}>
<Plus className="h-4 w-4" /> Create agent
<Plus className="h-4 w-4" /> {t('workspaceAgents.createAgent')}
</Button>
}
>
@@ -429,7 +433,7 @@ export default function AdminWorkspaceAgents() {
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-wrap items-center gap-3">
<div className="w-full sm:w-72">
<SearchInput value={query} onChange={setQuery} placeholder="Search agents..." />
<SearchInput value={query} onChange={setQuery} placeholder={t('workspaceAgents.searchAgents')} />
</div>
{/* Modern status filter tabs */}
@@ -444,7 +448,7 @@ export default function AdminWorkspaceAgents() {
: 'text-ink-3 hover:text-ink-1'
)}
>
Active <span className="text-ink-4 font-normal">({activeCount})</span>
{t('workspaceAgents.active')} <span className="text-ink-4 font-normal">({activeCount})</span>
</button>
<button
type="button"
@@ -456,7 +460,7 @@ export default function AdminWorkspaceAgents() {
: 'text-ink-3 hover:text-ink-1'
)}
>
Published <span className="text-ink-4 font-normal">({publishedCount})</span>
{t('workspaceAgents.published')} <span className="text-ink-4 font-normal">({publishedCount})</span>
</button>
{draftCount > 0 && (
<button
@@ -469,7 +473,7 @@ export default function AdminWorkspaceAgents() {
: 'text-ink-3 hover:text-ink-1'
)}
>
Drafts <span className="text-ink-4 font-normal">({draftCount})</span>
{t('workspaceAgents.drafts')} <span className="text-ink-4 font-normal">({draftCount})</span>
</button>
)}
{/* Always rendered, empty or not. Archived is where removing an
@@ -490,7 +494,7 @@ export default function AdminWorkspaceAgents() {
: 'text-ink-3 hover:text-ink-1'
)}
>
Archived <span className="text-ink-4 font-normal">({archivedCount})</span>
{t('workspaceAgents.archived')} <span className="text-ink-4 font-normal">({archivedCount})</span>
</button>
</div>
</div>
@@ -500,8 +504,8 @@ export default function AdminWorkspaceAgents() {
to="/admin/workspace/skills"
className="hidden md:inline-flex items-center gap-1.5 rounded-lg border border-border/70 bg-surface px-3 py-1.5 text-body-sm text-ink-3 transition-colors hover:border-krow-blue/40 hover:text-krow-blue hover:bg-surface-subtle"
>
<span>Skills managed in</span>
<span className="font-semibold text-krow-blue">Agent Registry &rarr; Skills</span>
<span>{t('workspaceAgents.skillsManagedIn')}</span>
<span className="font-semibold text-krow-blue">{t('workspaceAgents.agentRegistrySkills')}kills</span>
</Link>
<SegmentedToggle
@@ -533,11 +537,11 @@ export default function AdminWorkspaceAgents() {
<table className="w-full min-w-[48rem] text-left border-collapse">
<thead>
<tr className="border-b border-border bg-surface-subtle/60 text-[11px] font-semibold uppercase tracking-wider text-ink-4">
<th className="py-3 pl-4 pr-3 font-semibold">Agent</th>
<th className="px-3 py-3 font-semibold">Status</th>
<th className="px-3 py-3 font-semibold">Surfaces Covered</th>
<th className="px-3 py-3 font-semibold">Capabilities</th>
<th className="py-3 pl-3 pr-4 text-right font-semibold">Actions</th>
<th className="py-3 pl-4 pr-3 font-semibold">{t('workspaceAgents.agent')}</th>
<th className="px-3 py-3 font-semibold">{t('workspaceAgents.status')}</th>
<th className="px-3 py-3 font-semibold">{t('workspaceAgents.surfacesCovered')}</th>
<th className="px-3 py-3 font-semibold">{t('workspaceAgents.capabilities')}</th>
<th className="py-3 pl-3 pr-4 text-right font-semibold">{t('workspaceAgents.actions')}</th>
</tr>
</thead>
<tbody>

View File

@@ -22,6 +22,7 @@ import {
owliverCapabilityLabel, placementLabel, sectionTypeLabel, surfaceFor,
} from '@/lib/skills/surfaces';
import { AdminPage } from '@/components/admin/PageShell';
import { useTranslation } from 'react-i18next';
/**
* Skills — the management page.
@@ -106,6 +107,7 @@ function contribution(skill, facet) {
/** One skill, as a row in the management list. */
function SkillRow({ skill, facet, enabled, onToggle, onEdit, onDelete, onExport }) {
const { t } = useTranslation();
const pages = surfaceLabels(skill);
const { type, detail } = contribution(skill, facet);
/* A definition doing both jobs appears on both lists. Saying so on the row is
@@ -121,8 +123,8 @@ function SkillRow({ skill, facet, enabled, onToggle, onEdit, onDelete, onExport
{enabled ? 'Active' : 'Inactive'}
</Badge>
{type && <Badge variant="soft" size="sm">{type}</Badge>}
{skill.custom && <Badge variant="soft" size="sm">Custom</Badge>}
{both && <Badge variant="neutral" size="sm">Also on the other list</Badge>}
{skill.custom && <Badge variant="soft" size="sm">{t('workspaceSkills.custom')}</Badge>}
{both && <Badge variant="neutral" size="sm">{t('workspaceSkills.alsoOnTheOtherList')}</Badge>}
</div>
{skill.description && (
@@ -131,7 +133,7 @@ function SkillRow({ skill, facet, enabled, onToggle, onEdit, onDelete, onExport
<p className="mt-1.5 text-[11px] text-ink-4">
{pages.length > 0 && (
<>Pages: <span className="text-ink-3">{pages.join(', ')}</span></>
<>{t('workspaceSkills.pages')} <span className="text-ink-3">{pages.join(', ')}</span></>
)}
{pages.length > 0 && detail && ' · '}
{detail && <span className="text-ink-3">{detail}</span>}
@@ -172,7 +174,7 @@ function SkillRow({ skill, facet, enabled, onToggle, onEdit, onDelete, onExport
wrong". */}
<DropdownMenuItem onClick={() => onExport(skill)} className="cursor-pointer">
<Download className="mr-2 h-4 w-4" />
Export .md
{t('workspaceSkills.exportMd')}
</DropdownMenuItem>
{/* Offered for every definition. What it does differs — an account
definition is deleted, a shipped one is taken out of this
@@ -192,6 +194,7 @@ function SkillRow({ skill, facet, enabled, onToggle, onEdit, onDelete, onExport
}
export default function WorkspaceSkills() {
const { t } = useTranslation();
const navigate = useNavigate();
const preferences = usePreferences();
const updatePreferences = useUpdatePreferences();
@@ -499,12 +502,12 @@ export default function WorkspaceSkills() {
return (
<AdminPage
title="Skills"
subtitle="Manage the capabilities that extend KROW pages and Owliver."
title={t('workspaceSkills.skills')}
subtitle={t('workspaceSkills.manageTheCapabilitiesThatExtendKrow')}
actions={
<>
<Button variant="outline" size="sm" shape="rounded" onClick={() => navigate('/admin/workspace')}>
<ChevronLeft aria-hidden="true" /> Agent Registry
<ChevronLeft aria-hidden="true" /> {t('workspaceSkills.agentRegistry')}
</Button>
{/* Account definitions live in this browser's storage and nowhere
else. Import and export are what make that a place rather than a
@@ -519,10 +522,10 @@ export default function WorkspaceSkills() {
className="hidden"
/>
<Button variant="outline" size="sm" shape="rounded" onClick={() => importRef.current?.click()}>
<Upload aria-hidden="true" /> Import .md
<Upload aria-hidden="true" /> {t('workspaceSkills.importMd')}
</Button>
<Button variant="outline" size="sm" shape="rounded" onClick={exportAll}>
<Download aria-hidden="true" /> Export all
<Download aria-hidden="true" /> {t('workspaceSkills.exportAll')}
</Button>
<Button size="sm" shape="rounded" onClick={() => navigate(current.route)}>
<Plus aria-hidden="true" /> {current.add}
@@ -569,7 +572,7 @@ export default function WorkspaceSkills() {
onClick={() => handleFixDiagnostic(d)}
className="shrink-0 self-start sm:self-auto"
>
Remove
{t('workspaceSkills.remove')}
</Button>
)}
</li>
@@ -581,7 +584,7 @@ export default function WorkspaceSkills() {
variant="outline"
onClick={handleFixAllDiagnostics}
>
Resolve all problems
{t('workspaceSkills.resolveAllProblems')}
</Button>
</div>
</div>
@@ -595,19 +598,19 @@ export default function WorkspaceSkills() {
<div className="flex flex-wrap items-center gap-2">
<Select value={status} onValueChange={setStatus}>
<SelectTrigger className="h-9 w-36"><SelectValue placeholder="Status" /></SelectTrigger>
<SelectTrigger className="h-9 w-36"><SelectValue placeholder={t('workspaceSkills.status')} /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="disabled">Disabled</SelectItem>
<SelectItem value="all">{t('workspaceSkills.all')}</SelectItem>
<SelectItem value="active">{t('workspaceSkills.active')}</SelectItem>
<SelectItem value="disabled">{t('workspaceSkills.disabled')}</SelectItem>
</SelectContent>
</Select>
{pageOptions.length > 1 && (
<Select value={page} onValueChange={setPage}>
<SelectTrigger className="h-9 w-44"><SelectValue placeholder="Page" /></SelectTrigger>
<SelectTrigger className="h-9 w-44"><SelectValue placeholder={t('workspaceSkills.page')} /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All pages</SelectItem>
<SelectItem value="all">{t('workspaceSkills.allPages')}</SelectItem>
{pageOptions.map((key) => (
<SelectItem key={key} value={key}>{surfaceFor(key)?.label || key}</SelectItem>
))}
@@ -617,7 +620,7 @@ export default function WorkspaceSkills() {
</div>
<p className="text-caption text-ink-4 lg:ml-auto">
{listed.length} registered · {activeCount} active
{listed.length} {t('workspaceSkills.registered')} {activeCount} active
</p>
</div>
@@ -654,7 +657,7 @@ export default function WorkspaceSkills() {
size="sm"
onClick={() => { setSearch(''); setStatus('all'); setPage('all'); }}
>
Clear filters
{t('workspaceSkills.clearFilters')}
</Button>
) : (
<Button size="sm" onClick={() => navigate(current.route)}>
@@ -678,7 +681,7 @@ export default function WorkspaceSkills() {
hover:text-krow-blue focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
<span id="removed-skills">
Removed from this workspace ({removed.length})
{t('workspaceSkills.removedFromThisWorkspace')}{removed.length})
</span>
<ChevronDown
className={cn('h-3.5 w-3.5 transition-transform', showRemoved && 'rotate-180')}
@@ -705,7 +708,7 @@ export default function WorkspaceSkills() {
</p>
</div>
<Button variant="outline" size="sm" shape="rounded" onClick={() => restoreSkill(skill)}>
Restore
{t('workspaceSkills.restore')}
</Button>
</div>
))}
@@ -715,12 +718,7 @@ export default function WorkspaceSkills() {
)}
<p className="text-caption leading-relaxed text-ink-4">
Each skill is a Markdown definition that attaches itself to the pages it names. A UI skill
draws a section on those pages; an Owliver skill answers questions on them. Both are read by
the same registry and the same data resolver, so a figure in a card and a figure in an answer
come from one reading. Definitions shipped with KROW live in the repository and can be
switched off here; skills added on this account are stored with your preferences. Markdown is
stored as data — it is never executed.
{t('workspaceSkills.eachSkillIsAMarkdownDefinition2')}
</p>
<ConfirmModal

View File

@@ -11,6 +11,7 @@ import { FilterSelect } from '@/pages/admin/Positions';
import { registerNodeType } from '@/lib/ui/registry';
import { registerPageComposition } from '@/lib/ui/composition';
import { useUiContext } from '@/components/ui-tree/UiTreeRenderer';
import { useTranslation } from 'react-i18next';
/**
* The Activity page, as addressable nodes.
@@ -80,6 +81,7 @@ function ActivitySummary() {
/** The privileged-action notice. Absent, rather than empty, when there are none. */
function ActivityPrivilegedNotice({ attrs = {} }) {
const { t } = useTranslation();
const { highRisk } = useUiContext();
if (!highRisk.length) return null;
@@ -88,8 +90,7 @@ function ActivityPrivilegedNotice({ attrs = {} }) {
<div className="flex items-start gap-2.5">
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0 text-warning" aria-hidden="true" />
<p className="text-body-sm text-ink-2">
<span className="font-semibold text-ink-1">{highRisk.length} privileged actions</span> in this log —
hires and position changes. These should always trace to a named person.
<span className="font-semibold text-ink-1">{highRisk.length} {t('activity.privilegedActions')}</span> {t('activity.inThisLogHiresAndPosition')}
</p>
</div>
</Surface>
@@ -102,13 +103,14 @@ function ActivityPrivilegedNotice({ attrs = {} }) {
chronology is also how an auditor starts — sequence first, then
interrogate the specifics. */
function ActivityTimeline({ attrs = {} }) {
const { t } = useTranslation();
const { recent, byDay } = useUiContext();
return (
<section aria-labelledby="timeline" className="space-y-3" {...attrs}>
<SectionTitle
id="timeline"
title="Operational timeline"
title={t('activity.operationalTimeline')}
meta={`Most recent ${recent.length} events`}
/>
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
@@ -138,7 +140,7 @@ function ActivityTimeline({ attrs = {} }) {
</div>
) : (
<p className="px-4 py-8 text-center text-body-sm text-ink-3">
No activity has been logged yet.
{t('activity.noActivityHasBeenLoggedYet')}
</p>
)}
</Surface>
@@ -148,6 +150,7 @@ function ActivityTimeline({ attrs = {} }) {
/** The filterable audit log. */
function ActivityAuditLog({ attrs = {} }) {
const { t } = useTranslation();
const {
isLoading, events, filtered, users, actions, isFiltered, clearFilters,
search, setSearch, user, setUser, action, setAction, severity, setSeverity, range, setRange,
@@ -155,21 +158,21 @@ function ActivityAuditLog({ attrs = {} }) {
return (
<section aria-labelledby="audit" className="space-y-3" {...attrs}>
<SectionTitle id="audit" title="Audit log" meta={`${filtered.length} of ${events.length} events`} />
<SectionTitle id="audit" title={t('activity.auditLog')} meta={`${filtered.length} of ${events.length} events`} />
<Toolbar
search={<SearchInput value={search} onChange={setSearch} placeholder="Search events" size="sm" />}
search={<SearchInput value={search} onChange={setSearch} placeholder={t('activity.searchEvents')} size="sm" />}
filters={
<>
<FilterSelect value={user} onChange={setUser} label="User"
<FilterSelect value={user} onChange={setUser} label={t('activity.user')}
options={[{ value: 'all', label: 'All users' }, ...users.map((u) => ({ value: u, label: u }))]} />
<FilterSelect value={action} onChange={setAction} label="Action"
<FilterSelect value={action} onChange={setAction} label={t('activity.action')}
options={[{ value: 'all', label: 'All actions' }, ...actions.map((a) => ({ value: a, label: a.replace(/_/g, ' ') }))]} />
<FilterSelect value={severity} onChange={setSeverity} label="Severity" options={[
<FilterSelect value={severity} onChange={setSeverity} label={t('activity.severity')} options={[
{ value: 'all', label: 'All severities' }, { value: 'high', label: 'High' },
{ value: 'medium', label: 'Medium' }, { value: 'low', label: 'Low' },
]} />
<FilterSelect value={range} onChange={setRange} label="Date" options={[
<FilterSelect value={range} onChange={setRange} label={t('activity.date')} options={[
{ value: 'all', label: 'All time' }, { value: '24h', label: 'Last 24 hours' },
{ value: '7d', label: 'Last 7 days' }, { value: '30d', label: 'Last 30 days' },
]} />
@@ -183,7 +186,7 @@ function ActivityAuditLog({ attrs = {} }) {
pageSize={20}
isFiltered={isFiltered}
onClearFilters={clearFilters}
caption="Platform audit log"
caption={t('activity.platformAuditLog')}
rowClassName={(e) => (e.severity === 'high' ? 'bg-warning-muted/40' : undefined)}
columns={[
{

View File

@@ -21,6 +21,7 @@ import { SectionTitle } from '@/components/admin/PageShell';
import { registerNodeType } from '@/lib/ui/registry';
import { registerPageComposition } from '@/lib/ui/composition';
import { useUiContext } from '@/components/ui-tree/UiTreeRenderer';
import { useTranslation } from 'react-i18next';
const INSIGHT_TONE = {
success: { ring: 'border-emerald-500/30 bg-emerald-50/40 dark:bg-emerald-950/20', dot: 'text-emerald-600 dark:text-emerald-400', Icon: TrendingUp },
@@ -80,10 +81,11 @@ function MeasuredFunnel({ funnel }) {
/** A ranked comparison row — used for both fastest and slowest to fill. */
function VelocityList({ items, median, tone }) {
const { t } = useTranslation();
if (!items.length) {
return (
<p className="rounded-lg border border-dashed border-border px-3 py-3 text-body-sm text-ink-3">
No role has enough dated hires to measure velocity yet.
{t('analytics.noRoleHasEnoughDatedHires')}
</p>
);
}
@@ -118,10 +120,11 @@ function VelocityList({ items, median, tone }) {
}
function AnalyticsPerformance({ attrs = {} }) {
const { t } = useTranslation();
const { summary, funnel, trend, departments, positions, efficiency, insights, hires, isLoading, applications } = useUiContext();
return (
<section aria-labelledby="performance" className="space-y-3" {...attrs}>
<SectionTitle id="performance" title="Hiring performance" meta="Across the workspace" />
<SectionTitle id="performance" title={t('analytics.hiringPerformance')} meta={t('analytics.acrossTheWorkspace')} />
<MetricStrip
columns={4}
loading={isLoading}
@@ -153,20 +156,21 @@ function AnalyticsPerformance({ attrs = {} }) {
}
function AnalyticsFunnel({ attrs = {} }) {
const { t } = useTranslation();
const { summary, funnel, trend, departments, positions, efficiency, insights, hires, isLoading, applications } = useUiContext();
return (
<section aria-labelledby="funnel" className="space-y-3" {...attrs}>
<SectionTitle
id="funnel"
title="Hiring funnel"
meta="Applied → Screened → Shortlisted → Interview → Hired"
title={t('analytics.hiringFunnel')}
meta={t('analytics.appliedScreenedShortlistedInterviewHired')}
/>
{funnel.stages[0].count ? (
<MeasuredFunnel funnel={funnel} />
) : (
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
<p className="text-body-sm leading-relaxed text-ink-3">
No applications on file yet. The funnel appears once the first candidate applies.
{t('analytics.noApplicationsOnFileYetThe')}
</p>
</Surface>
)}
@@ -175,16 +179,17 @@ function AnalyticsFunnel({ attrs = {} }) {
}
function AnalyticsTrend({ attrs = {} }) {
const { t } = useTranslation();
const { summary, funnel, trend, departments, positions, efficiency, insights, hires, isLoading, applications } = useUiContext();
return (
<section aria-labelledby="trend" className="space-y-3" {...attrs}>
<SectionTitle id="trend" title="Hiring trend" meta="Cumulative hires by month" />
<SectionTitle id="trend" title={t('analytics.hiringTrend')} meta={t('analytics.cumulativeHiresByMonth')} />
<HiringTrendChart
points={trend}
emptyState={(
<div className="px-4 py-8 text-center">
<p className="font-heading text-body font-semibold text-ink-1">
Not enough history for a trend
{t('analytics.notEnoughHistoryForATrend')}
</p>
<p className="mx-auto mt-1 max-w-md text-body-sm leading-relaxed text-ink-3">
{hires.length
@@ -199,12 +204,13 @@ function AnalyticsTrend({ attrs = {} }) {
}
function AnalyticsDepartments({ attrs = {} }) {
const { t } = useTranslation();
const { summary, funnel, trend, departments, positions, efficiency, insights, hires, isLoading, applications } = useUiContext();
return (
<section aria-labelledby="departments" className="space-y-3" {...attrs}>
<SectionTitle
id="departments"
title="Department performance"
title={t('analytics.departmentPerformance')}
meta={`${departments.length} department${departments.length === 1 ? '' : 's'} compared`}
/>
<DepartmentPerformance items={departments} />
@@ -213,18 +219,19 @@ function AnalyticsDepartments({ attrs = {} }) {
}
function AnalyticsPositions({ attrs = {} }) {
const { t } = useTranslation();
const { summary, funnel, trend, departments, positions, efficiency, insights, hires, isLoading, applications } = useUiContext();
return (
<section aria-labelledby="positions" className="space-y-3" {...attrs}>
<SectionTitle
id="positions"
title="Position performance"
title={t('analytics.positionPerformance')}
meta={`${positions.length} role${positions.length === 1 ? '' : 's'} filled`}
/>
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden border border-border">
<div className="overflow-x-auto">
<table className="w-full min-w-[36rem] border-collapse text-body-sm">
<caption className="sr-only">Hires, quality, speed and review outcome by role</caption>
<caption className="sr-only">{t('analytics.hiresQualitySpeedAndReviewOutcome')}</caption>
<thead>
<tr className="border-b border-border bg-surface-subtle">
{['Role', 'Hires', 'Avg score', 'Avg days', 'Reviewed', 'Rating'].map((h, i) => (
@@ -252,7 +259,7 @@ function AnalyticsPositions({ attrs = {} }) {
<td className="px-3.5 py-2.5 text-right">
{r.avgRating != null
? <Badge variant="success" size="sm" className="font-bold">{r.avgRating}/5</Badge>
: <Badge variant="warning" size="sm" className="font-bold">Pending</Badge>}
: <Badge variant="warning" size="sm" className="font-bold">{t('analytics.pending')}</Badge>}
</td>
</tr>
))}
@@ -265,19 +272,20 @@ function AnalyticsPositions({ attrs = {} }) {
}
function AnalyticsEfficiency({ attrs = {} }) {
const { t } = useTranslation();
const { summary, funnel, trend, departments, positions, efficiency, insights, hires, isLoading, applications } = useUiContext();
return (
<section aria-labelledby="efficiency" className="space-y-3" {...attrs}>
<SectionTitle
id="efficiency"
title="Hiring efficiency"
title={t('analytics.hiringEfficiency')}
meta={efficiency.median ? `${efficiency.median}d median time-to-hire` : 'Not enough dated hires'}
/>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
<div className="flex items-center gap-2 text-krow-blue">
<Gauge className="h-4 w-4" aria-hidden="true" />
<h3 className="font-heading text-body-sm font-bold text-ink-1">Velocity</h3>
<h3 className="font-heading text-body-sm font-bold text-ink-1">{t('analytics.velocity')}</h3>
</div>
<p className="mt-2 font-heading text-title font-bold tabular-nums text-ink-1">
{efficiency.median ? `${efficiency.median}d` : '—'}
@@ -293,7 +301,7 @@ function AnalyticsEfficiency({ attrs = {} }) {
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
<div className="flex items-center gap-2 text-emerald-600 dark:text-emerald-400">
<Activity className="h-4 w-4" aria-hidden="true" />
<h3 className="font-heading text-body-sm font-bold text-ink-1">Fastest to fill</h3>
<h3 className="font-heading text-body-sm font-bold text-ink-1">{t('analytics.fastestToFill')}</h3>
</div>
<div className="mt-3">
<VelocityList items={efficiency.fastest} median={efficiency.median} tone="fast" />
@@ -303,7 +311,7 @@ function AnalyticsEfficiency({ attrs = {} }) {
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
<Clock className="h-4 w-4" aria-hidden="true" />
<h3 className="font-heading text-body-sm font-bold text-ink-1">Slowest to fill</h3>
<h3 className="font-heading text-body-sm font-bold text-ink-1">{t('analytics.slowestToFill')}</h3>
</div>
<div className="mt-3">
<VelocityList items={efficiency.slowest} median={efficiency.median} tone="slow" />
@@ -315,12 +323,13 @@ function AnalyticsEfficiency({ attrs = {} }) {
}
function AnalyticsInsights({ attrs = {} }) {
const { t } = useTranslation();
const { summary, funnel, trend, departments, positions, efficiency, insights, hires, isLoading, applications } = useUiContext();
return (
<section aria-labelledby="insights" className="space-y-3" {...attrs}>
<SectionTitle
id="insights"
title="AI hiring insights"
title={t('analytics.aiHiringInsights')}
meta={insights.length ? `${insights.length} finding${insights.length === 1 ? '' : 's'}` : undefined}
/>
{insights.length ? (
@@ -330,8 +339,7 @@ function AnalyticsInsights({ attrs = {} }) {
) : (
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
<p className="text-body-sm leading-relaxed text-ink-3">
There is not enough hiring on file to draw a finding from yet. Insights appear as
applications, hires and reviews accumulate.
{t('analytics.thereIsNotEnoughHiringOn')}
</p>
</Surface>
)}

View File

@@ -20,6 +20,7 @@ import { SectionTitle } from '@/components/admin/PageShell';
import { registerNodeType } from '@/lib/ui/registry';
import { registerPageComposition } from '@/lib/ui/composition';
import { useUiContext } from '@/components/ui-tree/UiTreeRenderer';
import { useTranslation } from 'react-i18next';
/**
* Candidates Analysis, as addressable nodes.
@@ -49,12 +50,13 @@ function CASupply({ attrs = {} }) {
}
function CADistribution({ attrs = {} }) {
const { t } = useTranslation();
const { navigate, applications, postings, interviews, staff, profiles, f, bands, risks, top, skillSupply, skillGaps, positionFit, recommendations } = useUiContext();
return (
<>
<div className="grid gap-6 lg:grid-cols-5">
<section aria-labelledby="dist" className="space-y-3 lg:col-span-2">
<SectionTitle id="dist" title="Quality distribution" meta={`${f.total} candidates`} />
<SectionTitle id="dist" title={t('candidatesanalysis.qualityDistribution')} meta={`${f.total} candidates`} />
<ResponsiveContainer width="100%" height={200}>
<PieChart>
<Pie data={bands} dataKey="value" nameKey="name" innerRadius={48} outerRadius={78} paddingAngle={2} strokeWidth={0}>
@@ -78,8 +80,8 @@ function CADistribution({ attrs = {} }) {
<section aria-labelledby="strongest" className="space-y-3 lg:col-span-3">
<SectionTitle
id="strongest"
title="Strongest candidates"
meta="By AI score"
title={t('candidatesanalysis.strongestCandidates')}
meta={t('candidatesanalysis.byAiScore')}
action={{ label: 'All candidates', onClick: () => navigate('/admin/candidates') }}
/>
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="divide-y divide-border overflow-hidden">
@@ -100,7 +102,7 @@ function CADistribution({ attrs = {} }) {
<ProgressRing value={c.ai_score} size={30} strokeWidth={3} tone="score" />
</div>
)) : (
<p className="px-4 py-8 text-center text-body-sm text-ink-3">No scored candidates yet.</p>
<p className="px-4 py-8 text-center text-body-sm text-ink-3">{t('candidatesanalysis.noScoredCandidatesYet')}</p>
)}
</Surface>
</section>
@@ -110,27 +112,29 @@ function CADistribution({ attrs = {} }) {
}
function CARisk({ attrs = {} }) {
const { t } = useTranslation();
const { navigate, applications, postings, interviews, staff, profiles, f, bands, risks, top, skillSupply, skillGaps, positionFit, recommendations } = useUiContext();
return (
<section aria-labelledby="risk" className="space-y-3" {...attrs}>
<SectionTitle id="risk" title="Candidate risk" meta={risks.length ? `${risks.length} flags` : 'No flags'} />
<SectionTitle id="risk" title={t('candidatesanalysis.candidateRisk')} meta={risks.length ? `${risks.length} flags` : 'No flags'} />
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
{risks.length ? (
<InsightList>{risks.map((r, i) => <InsightRow key={i} {...r} />)}</InsightList>
) : (
<p className="px-4 py-8 text-center text-body-sm text-ink-3">
No material risk flags. Credentials, availability and interview integrity all check out.
{t('candidatesanalysis.noMaterialRiskFlagsCredentialsAvailability')}
</p>
)}
</Surface>
<p className="text-caption text-ink-4">
Flags are questions for a human, not rejections.
{t('candidatesanalysis.flagsAreQuestionsForAHuman')}
</p>
</section>
);
}
function CASkills({ attrs = {} }) {
const { t } = useTranslation();
const { navigate, applications, postings, interviews, staff, profiles, f, bands, risks, top, skillSupply, skillGaps, positionFit, recommendations } = useUiContext();
return (
<>
@@ -140,7 +144,7 @@ function CASkills({ attrs = {} }) {
<section aria-labelledby="skills" className="space-y-3 lg:col-span-3">
<SectionTitle
id="skills"
title="Skill supply"
title={t('candidatesanalysis.skillSupply')}
meta={`Top ${skillSupply.length} across the scored pool`}
/>
{skillSupply.length ? (
@@ -170,19 +174,18 @@ function CASkills({ attrs = {} }) {
</ResponsiveContainer>
) : (
<p className="text-body-sm text-ink-3">
Nothing is scored yet, so there is no skill supply to read.
{t('candidatesanalysis.nothingIsScoredYetSoThere')}
</p>
)}
<p className="text-caption text-ink-4">
Bar length is how many candidates claim the skill. Darker blue means the
people holding it average 75 or above.
{t('candidatesanalysis.barLengthIsHowManyCandidates')}
</p>
</section>
<section aria-labelledby="gaps" className="space-y-3 lg:col-span-2">
<SectionTitle
id="gaps"
title="Skill gaps"
title={t('candidatesanalysis.skillGaps')}
meta={skillGaps.length ? `${skillGaps.length} unmet` : 'None'}
/>
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
@@ -192,14 +195,14 @@ function CASkills({ attrs = {} }) {
<li key={g.name} className="px-4 py-2.5">
<p className="text-body-sm font-medium text-ink-1">{g.name}</p>
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">
Required by {g.roles.join(', ')} — held by nobody in the scored pool.
{t('candidatesanalysis.requiredBy')} {g.roles.join(', ')} {t('candidatesanalysis.heldByNobodyInTheScored')}
</p>
</li>
))}
</ul>
) : (
<p className="px-4 py-8 text-center text-body-sm text-ink-3">
Every credential an open role requires is held by someone in the scored pool.
{t('candidatesanalysis.everyCredentialAnOpenRoleRequires')}
</p>
)}
</Surface>
@@ -210,17 +213,18 @@ function CASkills({ attrs = {} }) {
}
function CAFit({ attrs = {} }) {
const { t } = useTranslation();
const { navigate, applications, postings, interviews, staff, profiles, f, bands, risks, top, skillSupply, skillGaps, positionFit, recommendations } = useUiContext();
return (
<>
{/* Position fit — which roles can actually be filled from current supply. */}
<section aria-labelledby="fit" className="space-y-3" {...attrs}>
<SectionTitle id="fit" title="Position fit" meta={`${positionFit.length} open roles`} />
<SectionTitle id="fit" title={t('candidatesanalysis.positionFit')} meta={`${positionFit.length} open roles`} />
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full min-w-[40rem] border-collapse text-body-sm">
<caption className="sr-only">
Applicant supply against candidates clearing the bar, per open role
{t('candidatesanalysis.applicantSupplyAgainstCandidatesClearingThe')}
</caption>
<thead>
<tr className="border-b border-border bg-surface-subtle">
@@ -251,7 +255,7 @@ function CAFit({ attrs = {} }) {
tone={r.fit >= 50 ? 'success' : r.fit > 0 ? 'warning' : 'destructive'}
size="xs"
/>
<p className="mt-1 text-right text-[10px] tabular-nums text-ink-4">{r.fit}% qualified</p>
<p className="mt-1 text-right text-[10px] tabular-nums text-ink-4">{r.fit}{t('candidatesanalysis.qualified')}</p>
</div>
</td>
</tr>
@@ -266,6 +270,7 @@ function CAFit({ attrs = {} }) {
}
function CARecommend({ attrs = {} }) {
const { t } = useTranslation();
const { navigate, applications, postings, interviews, staff, profiles, f, bands, risks, top, skillSupply, skillGaps, positionFit, recommendations } = useUiContext();
return (
<>
@@ -274,7 +279,7 @@ function CARecommend({ attrs = {} }) {
<section aria-labelledby="recommend" className="space-y-3">
<SectionTitle
id="recommend"
title="Recommendations"
title={t('candidatesanalysis.recommendations')}
meta={`${recommendations.length} ordered by impact`}
/>
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
@@ -300,10 +305,11 @@ function CARecommend({ attrs = {} }) {
}
function CAScreening({ attrs = {} }) {
const { t } = useTranslation();
const { navigate, applications, postings, interviews, staff, profiles, f, bands, risks, top, skillSupply, skillGaps, positionFit, recommendations } = useUiContext();
return (
<section aria-labelledby="screening" className="space-y-3" {...attrs}>
<SectionTitle id="screening" title="Screening efficiency" />
<SectionTitle id="screening" title={t('candidatesanalysis.screeningEfficiency')} />
<div className="grid gap-4 sm:grid-cols-3">
{[
{ label: 'Coverage', value: `${f.standardizedPct}%`, detail: `${f.scored.length} of ${f.total} scored`, tone: f.standardizedPct >= 80 ? 'success' : 'warning' },
@@ -320,7 +326,7 @@ function CAScreening({ attrs = {} }) {
))}
</div>
<Button variant="ghost" size="sm" onClick={() => navigate('/admin/analytics')} className="w-fit">
Full analytics <ArrowRight aria-hidden="true" />
{t('candidatesanalysis.fullAnalytics')} <ArrowRight aria-hidden="true" />
</Button>
</section>
);

View File

@@ -7,6 +7,7 @@ import { registerNodeType } from '@/lib/ui/registry';
import { registerPageComposition } from '@/lib/ui/composition';
import { useUiContext } from '@/components/ui-tree/UiTreeRenderer';
import { hasInterview } from '@/lib/hiringRecords';
import { useTranslation } from 'react-i18next';
/**
* Candidates, as addressable nodes.
@@ -23,6 +24,7 @@ export const SORTS = {
};
function CandidatesToolbar() {
const { t } = useTranslation();
const {
search, setSearch, position, setPosition, stage, setStage, band, setBand, sort, setSort,
positions, filtered, applications, isFiltered,
@@ -31,29 +33,29 @@ function CandidatesToolbar() {
return (
<Toolbar
search={<SearchInput value={search} onChange={setSearch} placeholder="Search candidates..." size="sm" />}
search={<SearchInput value={search} onChange={setSearch} placeholder={t('candidates.searchCandidates')} size="sm" />}
filters={
<>
<FilterSelect value={position} onChange={setPosition} label="Position"
<FilterSelect value={position} onChange={setPosition} label={t('candidates.position')}
options={[{ value: 'all', label: 'All positions' }, ...positions.map((p) => ({ value: p, label: p }))]} />
{/* Final selection leads because it is what the page is for. Every
one of the seven `application_status` values follows it, so no
application is unreachable — `shortlisted` and `assigned` were
missing here, and the rows carrying them could not be found at
all. */}
<FilterSelect value={stage} onChange={setStage} label="Stage" options={[
<FilterSelect value={stage} onChange={setStage} label={t('candidates.stage')} options={[
{ value: 'final', label: 'Final selection' }, { value: 'all', label: 'All stages' },
{ value: 'applied', label: 'Applied' }, { value: 'ai_screened', label: 'AI Screened' },
{ value: 'shortlisted', label: 'Shortlisted' }, { value: 'interview', label: 'Interviewing' },
{ value: 'hired', label: 'Hired' }, { value: 'assigned', label: 'Assigned' },
{ value: 'rejected', label: 'Declined' },
]} />
<FilterSelect value={band} onChange={setBand} label="Score" options={[
<FilterSelect value={band} onChange={setBand} label={t('candidates.score')} options={[
{ value: 'all', label: 'All scores' }, { value: 'top', label: '80+ Top' },
{ value: 'strong', label: '60–79 Strong' }, { value: 'weak', label: 'Under 60' },
{ value: 'unscored', label: 'Unscored' },
]} />
<FilterSelect value={sort} onChange={setSort} label="Sort"
<FilterSelect value={sort} onChange={setSort} label={t('candidates.sort')}
options={Object.entries(SORTS).map(([value, s]) => ({ value, label: s.label }))} />
</>
}
@@ -63,6 +65,7 @@ function CandidatesToolbar() {
}
function CandidatesList() {
const { t } = useTranslation();
const {
isLoading, filtered, isFiltered, clearFilters, resolveTitle, stage, interviews = [],
handleAction, setMessageApp, setScheduleApp, handleDecline, handleDelete, handleHire,
@@ -99,12 +102,12 @@ function CandidatesList() {
</p>
{emptyQueue && (
<p className="mt-1 text-caption text-ink-4">
Candidates arrive here once their interview is completed, and leave once they are hired or declined.
{t('candidates.candidatesArriveHereOnceTheirInterview')}
</p>
)}
{isFiltered && (
<Button size="xs" variant="outline" className="mt-3" onClick={clearFilters}>
Clear filters
{t('candidates.clearFilters')}
</Button>
)}
</div>

View File

@@ -11,6 +11,7 @@ import {
AXIS_PROPS, Avatar, Badge, CHART_TONES, ChartTooltip, EmptyState, SegmentedToggle,
Skeleton, StatusBadge,
} from '@/components/ds';
import { useTranslation } from 'react-i18next';
/**
@@ -163,11 +164,12 @@ function Snapshot({ metrics, loading }) {
/* ── 3. Pipeline intelligence ───────────────────────────────────────────── */
function PipelineFunnel({ funnel, transitions, weakest, total }) {
const { t } = useTranslation();
if (!total) {
return (
<EmptyState
title="Nothing in the pipeline"
description="Once candidates apply, stage conversion and drop-off appear here."
title={t('empty.nothingInPipeline')}
description={t('empty.nothingInPipelineBody')}
/>
);
}
@@ -202,7 +204,7 @@ function PipelineFunnel({ funnel, transitions, weakest, total }) {
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-[10px] font-extrabold text-ink-4 tabular-nums">0{i + 1}</span>
<h4 className="truncate font-heading text-body-sm font-bold text-ink-1">
{stage.label}
{t(`stage.${stage.key}`)}
</h4>
</div>
<div
@@ -232,7 +234,7 @@ function PipelineFunnel({ funnel, transitions, weakest, total }) {
: 'bg-surface-subtle text-ink-3 border-border/60'
)}
>
{share}% pool
{t('funnel.pool', { percent: share })}
</span>
</div>
</div>
@@ -257,12 +259,12 @@ function PipelineFunnel({ funnel, transitions, weakest, total }) {
{transition ? (
<>
<span className={cn(isWeak ? 'font-bold text-amber-600 dark:text-amber-400' : 'text-ink-3')}>
{transition.rate}% pass
{t('funnel.pass', { percent: transition.rate })}
</span>
<span>{transition.lost ? `−${transition.lost} lost` : '0 lost'}</span>
<span>{transition.lost ? t('funnel.lost', { count: transition.lost }) : t('funnel.noneLost')}</span>
</>
) : (
<span className="text-ink-4">Entry Stage</span>
<span className="text-ink-4">{t('funnel.entryStage')}</span>
)}
</div>
</div>
@@ -281,15 +283,14 @@ function PipelineFunnel({ funnel, transitions, weakest, total }) {
<div>
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold uppercase tracking-wider text-amber-700 dark:text-amber-400">
Primary Pipeline Bottleneck
{t('funnel.bottleneck')}
</span>
<span className="rounded-full bg-amber-200/60 dark:bg-amber-900/60 px-2 py-0.5 text-[10px] font-bold text-amber-800 dark:text-amber-300">
{weakest.from} → {weakest.to}
{t(`stage.${weakest.fromKey}`)} → {t(`stage.${weakest.toKey}`)}
</span>
</div>
<p className="mt-0.5 text-body-sm text-ink-2">
Only <span className="font-bold text-amber-700 dark:text-amber-400">{weakest.rate}%</span> pass through and{' '}
<span className="font-bold text-ink-1">{weakest.lost} candidates</span> are lost at this transition. Recovering this conversion yields the highest ROI.
{t('funnel.bottleneckBody', { rate: weakest.rate, count: weakest.lost })}
</p>
</div>
</div>
@@ -302,19 +303,21 @@ function PipelineFunnel({ funnel, transitions, weakest, total }) {
/* ── 5. Action center ───────────────────────────────────────────────────── */
const SEVERITY = {
critical: { dot: 'bg-destructive', label: 'Critical' },
warning: { dot: 'bg-warning', label: 'Warning' },
info: { dot: 'bg-krow-blue', label: 'For review' },
/* Keys, resolved at render: this map is built once at import, so a label
here would stay in whichever language the app started in. */
critical: { dot: 'bg-destructive', key: 'severity.critical' },
warning: { dot: 'bg-warning', key: 'severity.warning' },
info: { dot: 'bg-krow-blue', key: 'severity.forReview' },
};
/** One operational queue, divided rows. Not five cards in a grid. */
function ActionQueue({ items }) {
const { t } = useTranslation();
if (!items.length) {
return (
<div className="rounded-xl border border-border bg-surface px-4 py-8 text-center">
<p className="text-body-sm text-ink-3">
Nothing needs intervention. Every role has candidates, every applicant is scored,
and no hire is awaiting review.
{t('actionCenter.nothingToDo')}
</p>
</div>
);
@@ -335,7 +338,7 @@ function ActionQueue({ items }) {
className={cn('h-2 w-2 shrink-0 rounded-full', SEVERITY[item.severity].dot)}
aria-hidden="true"
/>
<span className="sr-only">{SEVERITY[item.severity].label}.</span>
<span className="sr-only">{t(SEVERITY[item.severity].key)}.</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-body-sm font-medium text-ink-1">{item.title}</span>
@@ -350,7 +353,7 @@ function ActionQueue({ items }) {
</span>
<span className="hidden shrink-0 items-center gap-0.5 text-caption font-semibold text-krow-blue sm:inline-flex">
Review
{t('action.review')}
<ChevronRight
className="h-3 w-3 transition-transform duration-base group-hover:translate-x-0.5"
aria-hidden="true"
@@ -380,6 +383,7 @@ import { useUiContext } from '@/components/ui-tree/UiTreeRenderer';
*/
function CCSnapshot({ attrs = {} }) {
const { t } = useTranslation();
const { navigate, range, setRange, applications, isLoading, postings, interviews, staff, profiles, activity, f, metrics, activitySeries, positionPerformance, strongest, weakestRole, actions, activePositions, topTalent, recent, exportSnapshot } = useUiContext();
return (
<>
@@ -390,17 +394,18 @@ function CCSnapshot({ attrs = {} }) {
}
function CCActivity({ attrs = {} }) {
const { t } = useTranslation();
const { navigate, range, setRange, applications, isLoading, postings, interviews, staff, profiles, activity, f, metrics, activitySeries, positionPerformance, strongest, weakestRole, actions, activePositions, topTalent, recent, exportSnapshot } = useUiContext();
return (
<>
{/* 2 ── Hiring activity */}
<Band
id="cc-activity"
title="Hiring activity"
title={t('sections.hiringActivity')}
meta={
RANGES[range].step === 1
? `Applications, screening, interviews and hires · last ${RANGES[range].days} days, daily`
: `Applications, screening, interviews and hires · last ${RANGES[range].days} days, ${RANGES[range].step}-day totals`
? t('chart.daily', { days: RANGES[range].days })
: t('chart.totals', { days: RANGES[range].days, step: RANGES[range].step })
}
action={
<SegmentedToggle
@@ -444,7 +449,7 @@ function CCActivity({ attrs = {} }) {
<Area
type="monotone"
dataKey="applications"
name="Applications"
name={t('series.applications')}
stroke={CHART_TONES.brand}
strokeWidth={2}
fill="url(#ccApplications)"
@@ -453,7 +458,7 @@ function CCActivity({ attrs = {} }) {
<Line
type="monotone"
dataKey="screened"
name="AI Screened"
name={t('series.aiScreened')}
stroke={CHART_TONES.navy}
strokeWidth={2}
strokeDasharray="4 3"
@@ -463,7 +468,7 @@ function CCActivity({ attrs = {} }) {
<Line
type="monotone"
dataKey="interviews"
name="Interviews"
name={t('series.interviews')}
stroke={CHART_TONES.navy}
strokeWidth={1.5}
strokeOpacity={0.55}
@@ -474,7 +479,7 @@ function CCActivity({ attrs = {} }) {
and a bar — a line at this volume reads as flat. */}
<Bar
dataKey="hires"
name="Hires"
name={t('series.hires')}
fill={CHART_TONES.accent}
radius={[3, 3, 0, 0]}
maxBarSize={14}
@@ -484,8 +489,8 @@ function CCActivity({ attrs = {} }) {
) : (
<div className="px-2 py-8">
<EmptyState
title="No applications yet"
description="Once candidates start applying, daily hiring activity appears here."
title={t('empty.noApplications')}
description={t('empty.noApplicationsBody')}
/>
</div>
)}
@@ -496,14 +501,15 @@ function CCActivity({ attrs = {} }) {
}
function CCPipeline({ attrs = {} }) {
const { t } = useTranslation();
const { navigate, range, setRange, applications, isLoading, postings, interviews, staff, profiles, activity, f, metrics, activitySeries, positionPerformance, strongest, weakestRole, actions, activePositions, topTalent, recent, exportSnapshot } = useUiContext();
return (
<>
{/* 3 ── Pipeline intelligence */}
<Band
id="cc-pipeline"
title="Pipeline intelligence"
meta={`${f.total} entered · conversion and drop-off by stage`}
title={t('sections.pipelineIntelligence')}
meta={t('sections.pipelineCaption', { count: f.total })}
>
<PipelineFunnel
funnel={f.funnel}
@@ -517,15 +523,16 @@ function CCPipeline({ attrs = {} }) {
}
function CCPositions({ attrs = {} }) {
const { t } = useTranslation();
const { navigate, range, setRange, applications, isLoading, postings, interviews, staff, profiles, activity, f, metrics, activitySeries, positionPerformance, strongest, weakestRole, actions, activePositions, topTalent, recent, exportSnapshot } = useUiContext();
return (
<>
{/* 4 ── Position performance */}
<Band
id="cc-positions"
title="Position performance"
meta={strongest ? `Strongest conversion: ${strongest}` : undefined}
action={<ViewAll label="View all positions" onClick={() => navigate('/admin/positions')} />}
title={t('sections.positionPerformance')}
meta={strongest ? t('perf.strongest', { role: strongest }) : undefined}
action={<ViewAll label={t('actions.viewAllPositions')} onClick={() => navigate('/admin/positions')} />}
>
<div className="rounded-xl border border-border bg-surface px-2 py-4">
{positionPerformance.length ? (
@@ -555,12 +562,12 @@ function CCPositions({ attrs = {} }) {
/>
<Bar
dataKey="applicants"
name="Applicants"
name={t('perf.applicants')}
fill={CHART_TONES.brand}
radius={[3, 3, 0, 0]}
maxBarSize={24}
/>
<Bar dataKey="qualified" name="Qualified (70+)" radius={[3, 3, 0, 0]} maxBarSize={24}>
<Bar dataKey="qualified" name={t('perf.qualified')} radius={[3, 3, 0, 0]} maxBarSize={24}>
{/* The best converter takes the accent at full strength and the
worst is left pale, so both ends of the comparison are
visible without a callout. */}
@@ -574,7 +581,7 @@ function CCPositions({ attrs = {} }) {
</Bar>
<Bar
dataKey="hired"
name="Hired"
name={t('perf.hired')}
fill={CHART_TONES.navy}
radius={[3, 3, 0, 0]}
maxBarSize={24}
@@ -584,18 +591,18 @@ function CCPositions({ attrs = {} }) {
{weakestRole && (
<p className="px-2 pt-2 text-caption leading-relaxed text-ink-3">
<span className="font-semibold text-ink-1">{strongest}</span> converts applicants
into qualified candidates best;{' '}
<span className="font-semibold text-ink-1">{weakestRole}</span> converts worst and
is where sourcing — or the bar itself — is worth a look.
<span className="font-semibold text-ink-1">{strongest}</span>{' '}
{t('perf.note')}{' '}
<span className="font-semibold text-ink-1">{weakestRole}</span>{' '}
{t('perf.noteWorst')}
</p>
)}
</>
) : (
<div className="px-2 py-8">
<EmptyState
title="No open positions"
description="Publish a role and its hiring performance appears here."
title={t('empty.noOpenPositions')}
description={t('empty.noOpenPositionsBody')}
/>
</div>
)}
@@ -606,14 +613,15 @@ function CCPositions({ attrs = {} }) {
}
function CCActions({ attrs = {} }) {
const { t } = useTranslation();
const { navigate, range, setRange, applications, isLoading, postings, interviews, staff, profiles, activity, f, metrics, activitySeries, positionPerformance, strongest, weakestRole, actions, activePositions, topTalent, recent, exportSnapshot } = useUiContext();
return (
<>
{/* 5 ── Action center */}
<Band
id="cc-actions"
title="Action center"
meta={actions.length ? `${actions.length} items, most consequential first` : 'All clear'}
title={t('sections.actionCenter')}
meta={actions.length ? t('actionCenter.items', { count: actions.length }) : t('actionCenter.allClear')}
>
<ActionQueue items={actions} />
</Band>
@@ -622,6 +630,7 @@ function CCActions({ attrs = {} }) {
}
function CCLists({ attrs = {} }) {
const { t } = useTranslation();
const { navigate, range, setRange, applications, isLoading, postings, interviews, staff, profiles, activity, f, metrics, activitySeries, positionPerformance, strongest, weakestRole, actions, activePositions, topTalent, recent, exportSnapshot } = useUiContext();
return (
<>
@@ -630,9 +639,9 @@ function CCLists({ attrs = {} }) {
<div className="grid gap-6 xl:grid-cols-2">
<Band
id="cc-active"
title="Active positions"
meta={`${f.openPositions.length} hiring`}
action={<ViewAll label="View all" onClick={() => navigate('/admin/positions')} />}
title={t('sections.activePositions')}
meta={t('cc.hiring', { count: f.openPositions.length })}
action={<ViewAll label={t('actions.viewAll')} onClick={() => navigate('/admin/positions')} />}
>
{activePositions.length ? (
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface">
@@ -652,8 +661,8 @@ function CCLists({ attrs = {} }) {
{r.title}
</span>
<span className="block truncate text-caption text-ink-3">
{r.applied} applicant{r.applied === 1 ? '' : 's'} · {r.screened} screened
{' · '}{r.hired} hired
{t('role.applicants', { count: r.applied })} · {t('role.screened', { count: r.screened })}
{' · '}{t('role.hired', { count: r.hired })}
</span>
</span>
@@ -686,15 +695,15 @@ function CCLists({ attrs = {} }) {
})}
</ul>
) : (
<EmptyState title="No open positions" description="Publish a role to start hiring." />
<EmptyState title={t('empty.noOpenPositions')} description={t('empty.publishToStart')} />
)}
</Band>
<Band
id="cc-talent"
title="Top talent"
meta={topTalent.length ? 'By KROW Score' : undefined}
action={<ViewAll label="View all" onClick={() => navigate('/admin/candidates')} />}
title={t('sections.topTalent')}
meta={topTalent.length ? t('cc.byKrowScore') : undefined}
action={<ViewAll label={t('actions.viewAll')} onClick={() => navigate('/admin/candidates')} />}
>
{topTalent.length ? (
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface">
@@ -736,8 +745,8 @@ function CCLists({ attrs = {} }) {
</ul>
) : (
<EmptyState
title="Nobody scored yet"
description="Screen the pipeline and the strongest candidates appear here."
title={t('empty.nobodyScored')}
description={t('empty.nobodyScoredBody')}
/>
)}
</Band>
@@ -747,32 +756,33 @@ function CCLists({ attrs = {} }) {
}
function CCRecent({ attrs = {} }) {
const { t } = useTranslation();
const { navigate, range, setRange, applications, isLoading, postings, interviews, staff, profiles, activity, f, metrics, activitySeries, positionPerformance, strongest, weakestRole, actions, activePositions, topTalent, recent, exportSnapshot } = useUiContext();
return (
<>
{/* 8 ── Recent activity */}
<Band
id="cc-recent"
title="Recent activity"
meta={`${activity.length} events logged`}
action={<ViewAll label="View all" onClick={() => navigate('/admin/activity')} />}
title={t('sections.recentActivity')}
meta={t('cc.eventsLogged', { count: activity.length })}
action={<ViewAll label={t('actions.viewAll')} onClick={() => navigate('/admin/activity')} />}
>
<div className="overflow-hidden rounded-xl border border-border bg-surface">
<div className="overflow-x-auto">
<table className="w-full min-w-[36rem] border-collapse">
<caption className="sr-only">Most recent platform activity</caption>
<caption className="sr-only">{t('cc.recentActivityCaption')}</caption>
<thead>
<tr className="border-b border-border bg-surface-subtle">
{['User', 'Action', 'Entity', 'Time', 'Role'].map((h) => (
{['user', 'action', 'entity', 'time', 'role'].map((h) => (
<th
key={h}
scope="col"
className={cn(
'whitespace-nowrap px-4 py-2.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4',
h === 'Role' ? 'text-right' : 'text-left'
h === 'role' ? 'text-right' : 'text-left'
)}
>
{h}
{t(`table.${h}`)}
</th>
))}
</tr>

View File

@@ -8,6 +8,7 @@ import { SectionTitle } from '@/components/admin/PageShell';
import { registerNodeType } from '@/lib/ui/registry';
import { registerPageComposition } from '@/lib/ui/composition';
import { useUiContext } from '@/components/ui-tree/UiTreeRenderer';
import { useTranslation } from 'react-i18next';
/**
* Hired History, as addressable nodes.
@@ -101,13 +102,14 @@ function HiredSummary() {
* rendered like one — inside the section, which stays.
*/
function HiredChronology({ attrs = {} }) {
const { t } = useTranslation();
const { recent, filtered } = useUiContext();
return (
<section aria-labelledby="chronology" className="space-y-3" {...attrs}>
<SectionTitle
id="chronology"
title="Recent hiring timeline"
title={t('hiredhistory.recentHiringTimeline')}
meta={recent.length ? `Last ${recent.length} of ${filtered.length}` : 'Nothing yet'}
/>
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
@@ -126,8 +128,8 @@ function HiredChronology({ attrs = {} }) {
/>
) : (
<EmptyState
title="No hires yet"
description="The timeline fills in as candidates are hired for a position."
title={t('hiredhistory.noHiresYet')}
description={t('hiredhistory.theTimelineFillsInAsCandidates')}
/>
)}
</Surface>
@@ -137,20 +139,21 @@ function HiredChronology({ attrs = {} }) {
/* 4. The records themselves — every field a hire carries, one row each. */
function HiredRecords({ attrs = {} }) {
const { t } = useTranslation();
const { isLoading, hires, filtered, isFiltered, clearFilters, setSelected } = useUiContext();
return (
<section aria-labelledby="records" className="space-y-3" {...attrs}>
<SectionTitle
id="records"
title="Hired candidate records"
title={t('hiredhistory.hiredCandidateRecords')}
meta={isFiltered ? `${filtered.length} of ${hires.length}` : `${hires.length} people`}
/>
<DataTable
loading={isLoading}
rows={filtered}
pageSize={12}
caption="Everyone hired, with position, client, score and time-to-hire"
caption={t('hiredhistory.everyoneHiredWithPositionClientScore')}
onRowClick={setSelected}
isFiltered={isFiltered}
onClearFilters={clearFilters}
@@ -163,7 +166,7 @@ function HiredRecords({ attrs = {} }) {
: 'Hires appear here as positions close.'
}
action={isFiltered
? <Button variant="outline" size="sm" onClick={clearFilters}>Clear filters</Button>
? <Button variant="outline" size="sm" onClick={clearFilters}>{t('hiredhistory.clearFilters')}</Button>
: undefined}
/>
)}
@@ -216,7 +219,7 @@ function HiredRecords({ attrs = {} }) {
]}
/>
<p className={cn('text-caption text-ink-4')}>
Select a row to open the full record.
{t('hiredhistory.selectARowToOpenThe')}
</p>
</section>
);

View File

@@ -14,6 +14,7 @@ import { FilterSelect } from '@/pages/admin/Positions';
import { registerNodeType } from '@/lib/ui/registry';
import { registerPageComposition } from '@/lib/ui/composition';
import { useUiContext } from '@/components/ui-tree/UiTreeRenderer';
import { useTranslation } from 'react-i18next';
/**
* Talent Pool, as addressable nodes.
@@ -81,12 +82,13 @@ export const SEGMENT_CONFIG = {
/* Segments — executive metric cards */
function TalentSegments({ attrs = {} }) {
const { t } = useTranslation();
const { profiles, segments, band, setBand } = useUiContext();
return (
<section aria-labelledby="segments" className="space-y-3" {...attrs}>
<SectionTitle
id="segments"
title="Talent segments"
title={t('talentpool.talentSegments')}
meta={`${profiles.length} in the pool`}
/>
<div>
@@ -171,6 +173,7 @@ function TalentSegments({ attrs = {} }) {
/** The directory: its heading, its filters and the ranked table. */
function TalentDirectory({ attrs = {} }) {
const { t } = useTranslation();
const navigate = useNavigate();
/* Which positions this person is already up for, so the picker can say so
rather than offering a vacancy the database will refuse. */
@@ -190,27 +193,27 @@ function TalentDirectory({ attrs = {} }) {
up against each other. */
return (
<div className="space-y-6" {...attrs}>
<SectionTitle title="Talent directory" meta={`${profiles.length} profiles`} />
<SectionTitle title={t('talentpool.talentDirectory')} meta={`${profiles.length} profiles`} />
<Toolbar
search={<SearchInput value={search} onChange={setSearch} placeholder="Search name, role or skill" size="sm" />}
search={<SearchInput value={search} onChange={setSearch} placeholder={t('talentpool.searchNameRoleOrSkill')} size="sm" />}
filters={
<>
<FilterSelect value={skill} onChange={setSkill} label="Skills"
<FilterSelect value={skill} onChange={setSkill} label={t('talentpool.skills')}
options={[{ value: 'all', label: 'All skills' }, ...skills.map((s) => ({ value: s, label: s }))]} />
<FilterSelect value={experience} onChange={setExperience} label="Experience" options={[
<FilterSelect value={experience} onChange={setExperience} label={t('talentpool.experience')} options={[
{ value: 'all', label: 'Any experience' }, { value: '5plus', label: '5+ years' },
{ value: '2to5', label: '2–5 years' }, { value: 'under2', label: 'Under 2 years' },
]} />
<FilterSelect value={location} onChange={setLocation} label="Location"
<FilterSelect value={location} onChange={setLocation} label={t('talentpool.location')}
options={[{ value: 'all', label: 'All locations' }, ...locations.map((l) => ({ value: l, label: l }))]} />
<FilterSelect value={band} onChange={setBand} label="Score" options={[
<FilterSelect value={band} onChange={setBand} label={t('talentpool.score')} options={[
{ value: 'all', label: 'All scores' }, { value: 'elite', label: 'Elite 90+' },
{ value: 'excellent', label: 'Excellent 75+' }, { value: 'solid', label: 'Solid 60+' },
{ value: 'building', label: 'Building' }, { value: 'unscored', label: 'Unscored' },
]} />
<FilterSelect value={availability} onChange={setAvailability} label="Availability"
<FilterSelect value={availability} onChange={setAvailability} label={t('talentpool.availability')}
options={[{ value: 'all', label: 'Any availability' }, ...availabilities.map((a) => ({ value: a, label: a }))]} />
<FilterSelect value={sort} onChange={setSort} label="Sort"
<FilterSelect value={sort} onChange={setSort} label={t('talentpool.sort')}
options={Object.entries(SORTS).map(([value, s]) => ({ value, label: s.label }))} />
</>
}
@@ -227,7 +230,7 @@ function TalentDirectory({ attrs = {} }) {
the pointer when it is given one, so clicking a person did nothing and
the pool was a list you could read and not use. */
onRowClick={(p) => navigate(`/admin/talent/${p.id}`)}
caption="Talent pool ranked by career score"
caption={t('talentpool.talentPoolRankedByCareerScore')}
columns={[
{
key: 'full_name', header: 'Candidate', sortable: true,
@@ -269,7 +272,7 @@ function TalentDirectory({ attrs = {} }) {
key: 'availability', header: 'Availability', hideBelow: 'lg',
cell: (p) => (p.availability?.length
? <span className="text-[11px] text-ink-3">{p.availability.join(', ')}</span>
: <Badge variant="warning" size="sm">Not set</Badge>),
: <Badge variant="warning" size="sm">{t('talentpool.notSet')}</Badge>),
},
{
key: 'krow_score', header: 'Career score', align: 'right', sortable: true,