Update Owliver language and suggestions
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' 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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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]"
|
||||
/>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'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>
|
||||
)}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 → 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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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={[
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user