Initial commit

This commit is contained in:
2026-08-07 23:53:15 +05:30
commit 90f14a73b2
222 changed files with 35000 additions and 0 deletions

View File

@@ -0,0 +1,163 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import { useJobPostings, useApplications, useStaff } from '@/lib/krowHooks';
import PipelineFunnel from '@/components/krow/PipelineFunnel';
import KROWCandidateLeaderboard from '@/components/krow/KROWCandidateLeaderboard';
import { Briefcase, Users, Brain, ClipboardCheck, UserCheck, BadgeCheck, Gauge, Plus, ChevronRight, Sparkles } from 'lucide-react';
import { AssistantTrigger } from '@/components/ai-assistant';
const METRICS = [
{ icon: Briefcase, label: 'Open Positions', bg: 'bg-[#0A39DF]', glow: 'shadow-blue-600/20' },
{ icon: Users, label: 'Applicants', bg: 'bg-[#333F48]', glow: 'shadow-slate-600/20' },
{ icon: Brain, label: 'AI Screened', bg: 'bg-[#0A39DF]', glow: 'shadow-blue-600/20' },
{ icon: ClipboardCheck, label: 'Shortlisted', bg: 'bg-[#F9E547]', glow: 'shadow-yellow-400/30' },
{ icon: UserCheck, label: 'Hired', bg: 'bg-[#0A39DF]', glow: 'shadow-blue-600/20' },
{ icon: BadgeCheck, label: 'Active Staff', bg: 'bg-[#333F48]', glow: 'shadow-slate-600/20' },
{ icon: Gauge, label: 'Avg KROW Score', bg: 'bg-[#0A39DF]', glow: 'shadow-blue-600/20' },
{ icon: Sparkles, label: 'AI Interviews', bg: 'bg-[#F9E547]', glow: 'shadow-yellow-400/30' },
];
function MetricCard({ icon: Icon, value, label, bg, glow, index }) {
const isYellow = bg === 'bg-[#F9E547]';
return (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05, duration: 0.4, ease: 'easeOut' }}
whileHover={{ y: -4, transition: { duration: 0.2 } }}
className="glass-card border border-white/60 rounded-2xl p-4 shadow-sm hover:shadow-lg transition-shadow"
>
<div className={`w-10 h-10 rounded-xl ${bg} flex items-center justify-center shadow-lg ${glow} mb-3`}>
<Icon className={`w-5 h-5 ${isYellow ? 'text-[#0A39DF]' : 'text-white'}`} />
</div>
<div className="text-3xl font-heading font-bold gradient-text">{value}</div>
<div className="text-[11px] text-[#9CA3AF] font-medium tracking-wide mt-0.5">{label}</div>
</motion.div>
);
}
export default function AdminDashboard() {
const navigate = useNavigate();
const { data: postings = [] } = useJobPostings();
const { data: applications = [] } = useApplications();
const { data: staff = [] } = useStaff();
const openPositions = postings.filter(p => p.status === 'active');
const screened = applications.filter(a => ['ai_screened', 'shortlisted', 'interview', 'hired'].includes(a.status));
const shortlisted = applications.filter(a => ['shortlisted', 'interview', 'hired'].includes(a.status));
const hired = applications.filter(a => a.status === 'hired');
const activeStaff = staff.filter(s => s.status === 'active' || s.status === 'onboarding');
const scored = applications.filter(a => a.ai_score > 0);
const avgScore = scored.length ? Math.round(scored.reduce((s, a) => s + a.ai_score, 0) / scored.length) : 0;
const interviews = applications.filter(a => a.status === 'interview' || a.interview_id).length;
const values = [
openPositions.length,
applications.length,
screened.length,
shortlisted.length,
hired.length,
activeStaff.length,
avgScore,
interviews,
];
const statsForPosting = (id) => {
const apps = applications.filter(a => a.job_posting_id === id);
return { applied: apps.length, screened: apps.filter(a => a.status !== 'applied').length, hired: apps.filter(a => a.status === 'hired').length };
};
const recentStaff = [...activeStaff].sort((a, b) => new Date(b.hire_date || 0) - new Date(a.hire_date || 0)).slice(0, 6);
return (
<div className="space-y-8">
{/* Hero */}
<motion.div initial={{ opacity: 0, y: -12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5 }} className="flex items-center justify-between flex-wrap gap-4">
<div>
<span className="text-[11px] font-medium tracking-wide text-[#0A39DF] bg-[#EFF6FF] px-2.5 py-1 rounded-full">Admin · Employer</span>
<h1 className="text-3xl font-heading font-bold text-[#111827] mt-2">Workforce <span className="gradient-text">Control Center</span></h1>
<p className="text-[14px] text-[#6B7280] mt-1">Full hiring pipeline, staffing & talent intelligence in one view</p>
</div>
<div className="flex items-center gap-2">
<button onClick={() => navigate('/positions/new')} className="inline-flex items-center gap-2 bg-[#F9E547] text-[#0A39DF] text-[13px] font-bold px-5 py-2.5 rounded-full shadow-lg hover:scale-[1.03] transition-transform">
<Plus className="w-4 h-4" /> Create Position
</button>
<AssistantTrigger />
</div>
</motion.div>
{/* Metrics */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{METRICS.map((m, i) => (
<MetricCard key={m.label} icon={m.icon} value={values[i]} label={m.label} bg={m.bg} glow={m.glow} index={i} />
))}
</div>
{/* Pipeline funnel + Active positions */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<PipelineFunnel applications={applications} />
<motion.div initial={{ opacity: 0, x: 16 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.3 }} 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]">Active Positions</h3>
<button onClick={() => navigate('/positions')} className="text-[12px] text-[#0A39DF] hover:underline inline-flex items-center gap-1 font-medium">View all <ChevronRight className="w-3 h-3" /></button>
</div>
<div className="space-y-1">
{openPositions.length === 0 && (
<div className="text-center py-10">
<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 active positions</p>
</div>
)}
{openPositions.slice(0, 6).map((p, idx) => {
const s = statsForPosting(p.id);
return (
<motion.div key={p.id} initial={{ opacity: 0, x: 8 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.4 + idx * 0.05 }} onClick={() => navigate(`/positions/${p.id}`)} className="flex items-center gap-3 p-2.5 rounded-xl hover:bg-white/50 transition-colors cursor-pointer group">
<div className="w-1 h-9 rounded-full bg-[#0A39DF] shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-[13px] font-medium text-[#111827] truncate">{p.title}</div>
<div className="text-[11px] text-[#6B7280]">{s.applied} applied · {s.screened} screened · {s.hired} hired</div>
</div>
<ChevronRight className="w-4 h-4 text-[#D1D5DB] group-hover:text-[#0A39DF] transition-colors" />
</motion.div>
);
})}
</div>
</motion.div>
</div>
{/* Leaderboard + Active staff */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<KROWCandidateLeaderboard applications={applications} />
<motion.div initial={{ opacity: 0, x: 16 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.4 }} 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]">Active Staff</h3>
<span className="text-[12px] text-[#6B7280]">{activeStaff.length} on roster</span>
</div>
<div className="space-y-1">
{recentStaff.length === 0 && (
<div className="text-center py-10">
<div className="w-12 h-12 rounded-full bg-white/50 flex items-center justify-center mx-auto mb-3"><UserCheck className="w-5 h-5 text-[#9CA3AF]" /></div>
<p className="text-[13px] text-[#9CA3AF]">No hired staff yet</p>
</div>
)}
{recentStaff.map((s, idx) => (
<div key={s.id} className="flex items-center gap-3 p-2.5 rounded-xl hover:bg-white/50 transition-colors">
<div className="w-9 h-9 rounded-full bg-[#333F48] flex items-center justify-center text-white text-[12px] font-semibold shrink-0">
{s.name?.split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="text-[13px] font-medium text-[#111827] truncate">{s.name}</div>
<div className="text-[11px] text-[#6B7280] truncate">{s.role} · <span className="text-[#0A39DF] font-medium">{s.profile_tier}</span></div>
</div>
<span className={`text-[10px] px-2 py-0.5 rounded-full font-medium ${s.status === 'active' ? 'bg-[#ECFDF5] text-[#065F46]' : 'bg-[#FFFBEB] text-[#92400E]'}`}>{s.status}</span>
</div>
))}
</div>
</motion.div>
</div>
</div>
);
}

64
src/pages/Analytics.jsx Normal file
View File

@@ -0,0 +1,64 @@
import React from 'react';
import { motion } from 'framer-motion';
import { useApplications, useInterviews, useStaff, useJobPostings } from '@/lib/krowHooks';
import { Loader2, BarChart3 } from 'lucide-react';
import ImpactMetrics from '@/components/krow/ImpactMetrics';
import AnalyticsMetrics from '@/components/krow/AnalyticsMetrics';
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 { AssistantTrigger } from '@/components/ai-assistant';
export default function Analytics() {
const { data: applications = [], isLoading: appsLoading } = useApplications();
const { data: interviews = [], isLoading: intLoading } = useInterviews();
const { data: staff = [], isLoading: staffLoading } = useStaff();
const { data: postings = [] } = useJobPostings();
if (appsLoading || intLoading || staffLoading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 text-[#9CA3AF] animate-spin" />
</div>
);
}
return (
<div className="space-y-6">
<motion.div
initial={{ opacity: 0, y: -12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="flex items-center gap-3"
>
<div className="w-10 h-10 rounded-xl bg-[#0A39DF] flex items-center justify-center shadow-lg shadow-blue-500/20">
<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>
</div>
<div className="ml-auto">
<AssistantTrigger />
</div>
</motion.div>
<ImpactMetrics applications={applications} interviews={interviews} staff={staff} />
<AnalyticsMetrics applications={applications} interviews={interviews} staff={staff} />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<PipelineFunnel applications={applications} />
<ScoreDistribution applications={applications} />
</div>
<div>
<h2 className="text-[11px] font-bold text-[#9CA3AF] tracking-wider mb-3 font-heading">INTERVIEW COMPLETION</h2>
<InterviewAnalytics applications={applications} interviews={interviews} />
</div>
<HiringAnalytics applications={applications} staff={staff} />
</div>
);
}

158
src/pages/Apply.jsx Normal file
View File

@@ -0,0 +1,158 @@
import React, { useState, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { ChevronRight, Clock, Mic, Gauge, ArrowLeft } from 'lucide-react';
import { useJobPostings, useCreateApplication } from '@/lib/krowHooks';
import ApplyIdentityCard from '@/components/krow/ApplyIdentityCard';
import AIInterviewModal from '@/components/krow/AIInterviewModal';
import { useWorkerProfile } from '@/lib/krowHooks';
import { toast } from 'react-hot-toast';
export default function Apply() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const jobId = searchParams.get('job');
const { data: postings = [], isLoading } = useJobPostings();
const createApplication = useCreateApplication();
const { data: profile, isLoading: profileLoading } = useWorkerProfile();
const [selectedJob, setSelectedJob] = useState(null);
const [showForm, setShowForm] = useState(false);
const [interviewApp, setInterviewApp] = useState(null);
const [starting, setStarting] = useState(false);
const openPositions = postings.filter(p => p.status === 'active');
// Deep-link from invitation: auto-select job if ?job=<id> is in URL
useEffect(() => {
if (jobId && !isLoading) {
const job = openPositions.find(p => p.id === jobId);
if (job && job.id !== selectedJob?.id) {
setSelectedJob(job);
setShowForm(true);
}
}
}, [jobId, isLoading, openPositions, selectedJob]);
// Auto-build a JobApplication from the worker's KROW Identity — no manual form.
const handleStartInterview = async () => {
if (!selectedJob || !profile) return;
setStarting(true);
try {
const formData = {
applicant_name: profile.full_name || '',
email: profile.email || '',
phone: profile.phone || '',
years_experience: profile.experience_years || 0,
english_level: 'basic',
certifications: profile.certifications || [],
availability: profile.availability || [],
skills: profile.skills || [],
companies_worked: (profile.experience || []).map(e => e.company).filter(Boolean),
professional_summary: profile.personality || '',
cover_letter: '',
resume_url: profile.resume_url || '',
selfie_url: profile.selfie_url || '',
certification_files: [],
};
const app = await createApplication.mutateAsync({
...formData,
job_posting_id: selectedJob.id,
job_title: selectedJob.title,
status: 'applied',
});
toast.success('Application created from your KROW Identity!');
setShowForm(false);
setInterviewApp({ ...app, job_posting_id: selectedJob.id });
} catch (e) {
toast.error('Failed to start application');
} finally {
setStarting(false);
}
};
return (
<div className="max-w-3xl mx-auto space-y-6 px-4 sm:px-0">
{/* 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
</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>
<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.
</p>
<div className="flex items-center justify-center gap-2 flex-wrap">
{[
{ icon: Clock, label: '5 minutes' },
{ icon: Mic, label: 'Voice or Text' },
{ icon: Gauge, label: 'Instant Score' },
].map(b => (
<span key={b.label} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-[#E5E7EB] text-[12px] text-[#374151] font-medium">
<b.icon className="w-3.5 h-3.5 text-[#6B7280]" />
{b.label}
</span>
))}
</div>
<button onClick={() => navigate('/owliver')} className="mt-5 inline-flex items-center gap-2 bg-[#0A39DF] hover:bg-[#082EB4] 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" />
</button>
</div>
{showForm && selectedJob ? (
/* KROW Identity preview — auto-applies from profile, then AI interview */
<div className="space-y-4">
<ApplyIdentityCard
profile={profile}
job={selectedJob}
submitting={starting || createApplication.isPending}
onStart={handleStartInterview}
onBuildIdentity={() => navigate((profile?.profile_completion || 0) < 40 ? '/owliver' : '/identity')}
/>
<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
</button>
</div>
) : (
/* Position List */
<div>
<p className="text-[13px] font-medium text-[#374151] mb-3">Choose a position:</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 => (
<button
key={job.id}
onClick={() => { setSelectedJob(job); setShowForm(true); }}
className="w-full flex items-center gap-3 sm:gap-4 bg-white border border-[#E5E7EB] hover:border-[#D1D5DB] hover:shadow-sm rounded-xl p-4 transition-all text-left"
>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-[#111827] text-[14px] sm:text-[15px] truncate">{job.title}</h3>
<p className="text-[12px] text-[#6B7280] truncate">{job.role_category} • {job.location}</p>
<span className="sm:hidden text-[12px] font-bold text-[#111827]">${job.pay_range_min}–${job.pay_range_max}/hr</span>
</div>
<span className="hidden sm:block font-bold text-[#111827] text-[14px] whitespace-nowrap">${job.pay_range_min}–${job.pay_range_max}/hr</span>
<ChevronRight className="w-5 h-5 text-[#9CA3AF] shrink-0" />
</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>
</div>
)}
{/* AI Interview after applying */}
{interviewApp && (
<AIInterviewModal
open={true}
onClose={() => { setInterviewApp(null); navigate('/'); }}
application={interviewApp}
job={selectedJob}
conductor="Owliver"
/>
)}
</div>
);
}

176
src/pages/Candidates.jsx Normal file
View File

@@ -0,0 +1,176 @@
import React, { useState } from 'react';
import { useApplications, useJobPostings, useScreenAllCandidates, useUpdateApplication } from '@/lib/krowHooks';
import { base44 } from '@/api/base44Client';
import CandidateCard from '@/components/krow/CandidateCard';
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 { AssistantTrigger } from '@/components/ai-assistant';
export default function Candidates() {
const { data: applications = [], isLoading } = useApplications();
const { data: postings = [] } = useJobPostings();
const screenAll = useScreenAllCandidates();
const [search, setSearch] = useState('');
const [stageFilter, setStageFilter] = useState('all');
const [scoreFilter, setScoreFilter] = useState('all');
const [positionFilter, setPositionFilter] = useState('all');
const [interviewApp, setInterviewApp] = useState(null);
const [scheduleApp, setScheduleApp] = useState(null);
const [messageApp, setMessageApp] = useState(null);
const updateApp = useUpdateApplication();
const handleDecline = async (app) => {
try {
await updateApp.mutateAsync({ id: app.id, data: { status: 'rejected' } });
toast.success(`${app.applicant_name} declined`);
} catch (e) {
toast.error('Failed to decline candidate');
}
};
const handleDelete = async (app) => {
try {
await base44.entities.JobApplication.delete(app.id);
toast.success(`${app.applicant_name} deleted`);
} catch (e) {
toast.error('Failed to delete candidate');
}
};
const postingById = Object.fromEntries(postings.map(p => [p.id, p]));
const resolveTitle = (a) => a.job_title || postingById[a.job_posting_id]?.title || '';
const sorted = [...applications].sort((a, b) => (b.ai_score || 0) - (a.ai_score || 0));
const positionOptions = Array.from(new Set(applications.map(resolveTitle).filter(Boolean))).sort();
const filtered = sorted.filter(a => {
const title = resolveTitle(a).toLowerCase();
const q = search.toLowerCase();
const matchesSearch = !search ||
a.applicant_name?.toLowerCase().includes(q) ||
a.email?.toLowerCase().includes(q) ||
title.includes(q);
const matchesStage = stageFilter === 'all' || a.status === stageFilter;
const matchesPosition = positionFilter === 'all' || resolveTitle(a) === positionFilter;
const matchesScore = scoreFilter === 'all' ||
(scoreFilter === 'high' && a.ai_score >= 80) ||
(scoreFilter === 'mid' && a.ai_score >= 60 && a.ai_score < 80) ||
(scoreFilter === 'low' && a.ai_score > 0 && a.ai_score < 60);
return matchesSearch && matchesStage && matchesPosition && matchesScore;
});
const handleAction = (app) => {
if (app.status === 'interview') {
setInterviewApp(app);
} else {
setScheduleApp(app);
}
};
const handleScreenAll = async () => {
const unscreened = applications.filter(a => a.status === 'applied');
if (unscreened.length === 0) {
toast('All candidates have been screened');
return;
}
const firstJob = postings.find(p => p.id === unscreened[0].job_posting_id);
try {
await screenAll.mutateAsync({ applications: unscreened, job: firstJob });
toast.success(`Screened ${unscreened.length} candidates`);
} catch (e) {
toast.error('Screening failed');
}
};
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<div>
<h1 className="text-[22px] font-bold text-[#111827]">All Candidates</h1>
</div>
<div className="flex items-center gap-3">
<span className="text-[13px] text-[#6B7280]">{applications.length} candidates</span>
<button
onClick={handleScreenAll}
disabled={screenAll.isPending}
className="inline-flex items-center gap-1.5 bg-[#0A39DF] hover:bg-[#082EB4] disabled:opacity-50 text-white text-[13px] font-medium px-3 py-2 rounded-lg transition-colors"
>
{screenAll.isPending ? 'Screening...' : 'AI Screen All'}
</button>
<AssistantTrigger />
</div>
</div>
{/* Filters */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3">
<div className="relative flex-1">
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search candidates..."
className="w-full h-10 pl-4 pr-4 rounded-lg border border-[#E5E7EB] bg-white text-[13px] focus:border-[#0A39DF] 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-[#0A39DF] 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>
</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-[#0A39DF] focus:outline-none max-w-[200px] truncate">
<option value="all">All Positions</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-[#0A39DF] 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>
</select>
</div>
{/* List */}
{isLoading ? (
<div className="space-y-3">
{[...Array(5)].map((_, i) => <div key={i} className="h-20 bg-white border border-[#E5E7EB] rounded-xl animate-pulse" />)}
</div>
) : filtered.length === 0 ? (
<div className="text-center py-16">
<p className="text-[14px] text-[#6B7280]">No candidates match your filters</p>
</div>
) : (
<div className="space-y-3">
{filtered.map((app, idx) => (
<CandidateCard
key={app.id}
application={app}
jobTitle={resolveTitle(app)}
rank={idx + 1}
onAction={handleAction}
onMessage={setMessageApp}
onCall={setMessageApp}
onSchedule={setScheduleApp}
onDecline={handleDecline}
onDelete={handleDelete}
/>
))}
</div>
)}
{/* Modals */}
{interviewApp && (
<AIInterviewModal open={true} onClose={() => setInterviewApp(null)} application={interviewApp} job={postings.find(p => p.id === interviewApp.job_posting_id)} />
)}
{scheduleApp && (
<ScheduleInterviewModal open={true} onClose={() => setScheduleApp(null)} application={scheduleApp} />
)}
{messageApp && (
<MessageCandidateModal open={true} onClose={() => setMessageApp(null)} application={messageApp} />
)}
</div>
);
}

114
src/pages/CourseDetail.jsx Normal file
View File

@@ -0,0 +1,114 @@
import React, { useState } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { useCourse, useCourses, useLearningPaths, useWorkerProfile, useJobPostings } from '@/lib/krowHooks';
import { recommendNextCourse, recommendJobs } from '@/lib/krowScore';
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';
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' };
const TYPE_HINT = {
roleplay: 'Owliver asks, you answer in a short conversation.',
photo: 'Upload a photo of your work.',
video: 'Record a short clip — Owliver grades your technique.',
photo_identify: 'Tap hazards on a photo — Owliver judges what you found.',
};
export default function CourseDetail() {
const { id } = useParams();
const navigate = useNavigate();
const { data: course, isLoading } = useCourse(id);
const { data: profile } = useWorkerProfile();
const { data: allCourses = [] } = useCourses();
const { data: paths = [] } = useLearningPaths();
const { data: postings = [] } = useJobPostings();
const [passed, setPassed] = useState(false);
if (isLoading) return <div className="flex justify-center py-20"><Loader2 className="w-6 h-6 animate-spin text-[#0A39DF]" /></div>;
if (!course) return <div className="py-20 text-center text-[#6B7280]">Challenge not found. <Link to="/university" className="text-[#0A39DF] underline">Back to KROW Forge</Link></div>;
const isVerified = (profile?.capabilities || []).some((c) => c.skill === (course.proof_skill || course.title) && c.status === 'verified');
const showRecs = isVerified || passed;
const { course: nextCourse, path } = showRecs ? recommendNextCourse(profile || {}, paths, allCourses) : {};
const jobRecs = showRecs ? recommendJobs(profile || {}, postings, 3) : [];
const type = course?.challenge?.type || 'roleplay';
const Icon = TYPE_ICON[type] || MessageSquare;
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>
{/* Header */}
<div className="rounded-3xl bg-[#0A39DF] text-white p-6 relative overflow-hidden">
<div aria-hidden className="absolute -right-10 -top-10 w-36 h-36 rounded-full bg-[#F9E547]/20 blur-2xl" />
<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>}
</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">
<span className="inline-flex items-center gap-1"><Zap className="w-3.5 h-3.5 text-[#F9E547]" /> {course.xp} XP</span>
<span className="inline-flex items-center gap-1"><Clock className="w-3.5 h-3.5" /> {course.estimated_minutes} min</span>
{course.badge_reward && <span className="inline-flex items-center gap-1"><Award className="w-3.5 h-3.5 text-[#F9E547]" /> {course.badge_reward}</span>}
</div>
</div>
{/* How you'll prove it */}
<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-[#EFF6FF] flex items-center justify-center shrink-0"><Icon className="w-6 h-6 text-[#0A39DF]" /></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-[15px] font-semibold text-[#0F172A]">{TYPE_LABEL[type]}</p>
<p className="text-[12px] text-[#64748B]">{TYPE_HINT[type]}</p>
</div>
</div>
{/* 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-[#0A39DF]" /><p className="text-[13px] font-semibold text-[#0F172A]">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>
</div>
)}
{course.video_url && (
<div className="bg-black rounded-2xl overflow-hidden aspect-video"><video src={course.video_url} controls className="w-full h-full" /></div>
)}
<ChallengeRunner course={course} profile={profile} onPassed={() => setPassed(true)} />
{showRecs && (
<div className="space-y-3">
{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>
)}
{nextCourse && (
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-4">
<p className="text-[12px] font-semibold text-[#111827] mb-3">{path ? `Next on your path to ${path.target_role || path.name}` : 'Recommended next challenge'}</p>
<UniversityCourseCard course={nextCourse} profile={profile} />
</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-[#0A39DF]" /> 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> : (
<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-[#0A39DF] 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-[#0A39DF]">{r.match}% match</span>
</Link>
))}</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,321 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Sparkles, ChevronDown, ChevronUp, Sliders, Loader2, Star } from 'lucide-react';
import { useCreateJobPosting, useUpdateJobPosting, useGenerateJobDescription, useRoleCategories, useCreateRoleCategory } from '@/lib/krowHooks';
import { toast } from 'react-hot-toast';
const ROLE_CATEGORIES = ['Server', 'Bartender', 'Chef', 'Security', 'Picker', 'Event Staff', 'Other'];
const CERT_OPTIONS = ['Food Handler Card', 'ServSafe', 'ABC License', 'TIPS Certified', 'CPR/First Aid', 'Background Check Cleared'];
const ENGLISH_LEVELS = [
{ value: 'basic', label: 'Basic' },
{ value: 'conversational', label: 'Conversational' },
{ value: 'fluent', label: 'Fluent' },
{ value: 'native', label: 'Native' },
];
const CRITERIA_LABELS = {
experience: 'Experience',
english: 'English',
reliability: 'Reliability',
certifications: 'Certifications',
availability: 'Availability',
};
export default function CreatePosition() {
const navigate = useNavigate();
const createJob = useCreateJobPosting();
const updateJob = useUpdateJobPosting();
const generateDesc = useGenerateJobDescription();
const [form, setForm] = useState({
title: '',
role_category: 'Server',
custom_requirements: '',
physical_requirements: '',
leadership_expectations: '',
attendance_expectations: '',
min_experience_years: 0,
english_required: 'basic',
location: '',
pay_range_min: '',
pay_range_max: '',
certifications_required: [],
vetting_criteria: { experience: 25, english: 20, reliability: 20, certifications: 20, availability: 15 },
});
const { data: customCategories = [] } = useRoleCategories();
const createRoleCategory = useCreateRoleCategory();
const [newCategory, setNewCategory] = useState('');
const [showWeights, setShowWeights] = useState(false);
const allCategories = [...new Set([...ROLE_CATEGORIES, ...customCategories.map(c => c.name)])];
const [draftId, setDraftId] = useState(null);
const [aiResult, setAiResult] = useState(null);
const update = (field, value) => setForm({ ...form, [field]: value });
const toggleCert = (cert) => {
update('certifications_required', form.certifications_required.includes(cert)
? form.certifications_required.filter(c => c !== cert)
: [...form.certifications_required, cert]);
};
const updateWeight = (key, value) => {
update('vetting_criteria', { ...form.vetting_criteria, [key]: parseInt(value) || 0 });
};
const totalWeight = Object.values(form.vetting_criteria).reduce((a, b) => a + b, 0);
const handleGenerateDescription = async () => {
if (!form.title) {
toast.error('Please enter a job title first');
return;
}
try {
// Create draft if not exists
let id = draftId;
if (!id) {
const draft = await createJob.mutateAsync({ ...form, status: 'draft', pay_range_min: Number(form.pay_range_min) || 0, pay_range_max: Number(form.pay_range_max) || 0 });
id = draft.id;
setDraftId(id);
}
const result = await generateDesc.mutateAsync({ data: { ...form, pay_range_min: Number(form.pay_range_min) || 0, pay_range_max: Number(form.pay_range_max) || 0 }, draftId: id });
setAiResult(result);
toast.success('Job description generated!');
} catch (e) {
toast.error('AI generation failed. Please try again.');
}
};
const handleSave = async (status) => {
if (!form.title) {
toast.error('Job title is required');
return;
}
if (totalWeight !== 100) {
toast.error('Vetting weights must total 100%');
return;
}
try {
const payload = {
...form,
status,
pay_range_min: Number(form.pay_range_min) || 0,
pay_range_max: Number(form.pay_range_max) || 0,
...(aiResult ? { description: aiResult.description, responsibilities: aiResult.responsibilities, qualifications: aiResult.qualifications, nice_to_haves: aiResult.nice_to_haves, ai_generated: true } : {}),
};
let createdId = draftId;
if (draftId) {
await updateJob.mutateAsync({ id: draftId, data: payload });
} else {
const created = await createJob.mutateAsync(payload);
createdId = created?.id;
}
toast.success(status === 'active' ? 'Job posting published!' : 'Saved as draft');
if (status === 'active' && createdId) {
navigate(`/positions/${createdId}`);
} else {
navigate('/positions');
}
} catch (e) {
toast.error('Failed to save');
}
};
return (
<div className="max-w-3xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<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>
</div>
<button onClick={() => navigate('/positions')} className="text-[13px] text-[#6B7280] hover:text-[#111827]">Cancel</button>
</div>
{/* Card */}
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-6 md:p-8 space-y-6">
{/* Section Header */}
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg bg-[#EFF6FF] flex items-center justify-center">
<Sparkles className="w-4 h-4 text-[#0A39DF]" />
</div>
<h2 className="text-[16px] font-semibold text-[#111827]">Create Job Posting</h2>
</div>
{/* 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>
<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-[#0A39DF] 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-[#0A39DF] focus:outline-none" />
</div>
</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-[#0A39DF] focus:outline-none" />
<button
onClick={async () => {
if (newCategory.trim()) {
const name = newCategory.trim();
await createRoleCategory.mutateAsync({ name });
update('role_category', name);
setNewCategory('');
toast.success('Role category added');
}
}}
disabled={createRoleCategory.isPending || !newCategory.trim()}
className="px-4 bg-[#0A39DF] text-white text-[13px] font-medium rounded-lg hover:bg-[#082EB4] disabled:opacity-50 whitespace-nowrap"
>
+ 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-[#0A39DF] focus:outline-none resize-none" />
</div>
{/* Physical / Leadership / Attendance Expectations */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="text-[13px] font-medium text-[#374151]">Physical Requirements</label>
<input value={form.physical_requirements} onChange={(e) => update('physical_requirements', e.target.value)} placeholder="e.g. Lift 50 lbs, stand 8 hrs" className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] bg-white px-3 text-[13px] focus:border-[#0A39DF] focus:outline-none" />
</div>
<div>
<label className="text-[13px] font-medium text-[#374151]">Leadership Expectations</label>
<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-[#0A39DF] focus:outline-none" />
</div>
<div>
<label className="text-[13px] font-medium text-[#374151]">Attendance Expectations</label>
<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-[#0A39DF] 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>
<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-[#0A39DF] focus:outline-none" />
</div>
<div>
<label className="text-[13px] font-medium text-[#374151]">English Level</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-[#0A39DF] 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-[#0A39DF] 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>
<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-[#0A39DF] focus:outline-none" />
</div>
<div>
<label className="text-[13px] font-medium text-[#374151]">Pay Max ($/hr)</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-[#0A39DF] focus:outline-none" />
</div>
</div>
{/* Certifications */}
<div>
<label className="text-[13px] font-medium text-[#374151]">Required Certifications</label>
<div className="flex flex-wrap gap-2 mt-1.5">
{CERT_OPTIONS.map(cert => (
<button
key={cert}
type="button"
onClick={() => toggleCert(cert)}
className={`px-3 py-1.5 rounded-full text-[12px] font-medium border transition-all ${
form.certifications_required.includes(cert) ? 'bg-[#0A39DF] border-[#0A39DF] text-white' : 'border-[#E5E7EB] text-[#6B7280] hover:border-[#D1D5DB]'
}`}
>
{cert}
</button>
))}
</div>
</div>
{/* AI Generate Button */}
<button
onClick={handleGenerateDescription}
disabled={generateDesc.isPending || createJob.isPending}
className="w-full bg-[#0A39DF] hover:bg-[#082EB4] 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...</>
) : (
<><Star className="w-4 h-4" /> Generate Job Description with AI</>
)}
</button>
{/* AI Result Preview */}
{aiResult && (
<div className="rounded-xl border border-[#DBEAFE] bg-[#EFF6FF] 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
</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>
<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>
</div>
)}
</div>
)}
{/* AI Vetting Weights */}
<div className="border-t border-[#E5E7EB] pt-4">
<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>
{showWeights ? <ChevronUp className="w-4 h-4 text-[#9CA3AF]" /> : <ChevronDown className="w-4 h-4 text-[#9CA3AF]" />}
</button>
{showWeights && (
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4">
{Object.entries(form.vetting_criteria).map(([key, val]) => (
<div key={key}>
<div className="flex items-center justify-between mb-1">
<label className="text-[12px] text-[#374151]">{CRITERIA_LABELS[key]}</label>
<span className={`text-[12px] font-medium ${totalWeight === 100 ? 'text-[#059669]' : 'text-[#F59E0B]'}`}>{val}%</span>
</div>
<input type="range" min="0" max="100" value={val} onChange={(e) => updateWeight(key, e.target.value)} className="w-full accent-[#0A39DF]" />
</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>
{totalWeight !== 100 && ' — adjust to reach 100%'}
</div>
</div>
)}
</div>
{/* 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
</button>
<button onClick={() => handleSave('active')} disabled={createJob.isPending || updateJob.isPending} className="bg-[#0A39DF] hover:bg-[#082EB4] text-white font-medium py-3 rounded-xl text-[14px] transition-colors disabled:opacity-50">
Publish Job Posting
</button>
</div>
</div>
</div>
);
}

754
src/pages/DesignSystem.jsx Normal file
View File

@@ -0,0 +1,754 @@
import React, { useState } from 'react';
import {
Activity, AlertCircle, Award, Brain, Briefcase, Calendar, CheckCircle2, Clock,
Download, Inbox, MoreHorizontal, Pencil, Plus, Star, Trash2, TrendingUp, Users,
} from 'lucide-react';
import { Bar, BarChart, CartesianGrid, Tooltip, XAxis, YAxis } from 'recharts';
import {
ActivityCard, Alert, Avatar, AvatarGroup, Badge, Breadcrumb, BreadcrumbItem,
BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator, Button,
ChartContainer, ChartTooltip, Checkbox, ConfirmModal, DataTable, Drawer,
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
EmptyState, Field, FilterBar, Grid, IconButton, InlineLoading, Input, KpiCard,
LoadingState, MetricCard, Modal, MultiSelect, PageHeader, PageSection,
Pagination, Popover, PopoverContent, PopoverTrigger, ProgressBar, ProgressRing,
ProgressSteps, RadioGroup, RadioGroupItem, ScoreMeter, SearchInput,
SectionHeader, SegmentedToggle, Select, SelectContent, SelectItem,
SelectTrigger, SelectValue, SkeletonCard, SkeletonKpi, SkeletonList,
SkeletonTable, SkeletonText, Spinner, Stack, StatusBadge, Surface,
Tabs, Textarea, Timeline, Toggle, Tooltip as DsTooltip, TooltipContent,
TooltipProvider, TooltipTrigger, toast,
} from '@/components/ds';
import { AXIS_PROPS, CHART_TONES } from '@/components/ds';
/**
* Design system reference.
*
* A live specimen sheet for every component, variant and state. It exists so
* design decisions are reviewable side by side rather than hunted across
* feature pages, and so a regression in a shared component is visible in one
* place. Not linked from the product navigation — reach it at /design-system.
*/
function Spec({ title, description, children, className }) {
return (
<Surface variant="solid" radius="xl" padding="lg" className={className}>
<SectionHeader title={title} subtitle={description} />
<div className="space-y-4">{children}</div>
</Surface>
);
}
function Row({ label, children }) {
return (
<div className="flex flex-wrap items-center gap-3">
{label && <span className="text-caption text-ink-4 w-24 shrink-0">{label}</span>}
<div className="flex flex-wrap items-center gap-2">{children}</div>
</div>
);
}
const TABLE_ROWS = [
{ id: 1, name: 'Chef Antoine Dubois', role: 'Executive Chef', score: 97, status: 'hired', exp: 12 },
{ id: 2, name: 'Marcus Williams', role: 'Event Security', score: 94, status: 'hired', exp: 5 },
{ id: 3, name: 'Sofia Mendez', role: 'Event Server', score: 89, status: 'ai_screened', exp: 4 },
{ id: 4, name: 'Tanya Cruz', role: 'Event Server', score: 83, status: 'interview', exp: 3 },
{ id: 5, name: 'Jordan Blake', role: 'Bartender', score: 72, status: 'interview', exp: 3 },
{ id: 6, name: 'Sam Okafor', role: 'Bartender', score: 28, status: 'ai_screened', exp: 0 },
];
const CHART_DATA = [
{ label: '0–39', count: 1 },
{ label: '40–59', count: 0 },
{ label: '60–79', count: 3 },
{ label: '80–100', count: 5 },
];
export default function DesignSystem() {
const [tab, setTab] = useState('foundation');
const [search, setSearch] = useState('');
const [multi, setMulti] = useState(['Bartender']);
const [filters, setFilters] = useState({ stage: 'all', position: [] });
const [modalOpen, setModalOpen] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
const [toggled, setToggled] = useState(true);
const [segment, setSegment] = useState('30d');
const [selected, setSelected] = useState([]);
const [step, setStep] = useState(1);
const [page, setPage] = useState(2);
const TABS = [
{ value: 'foundation', label: 'Foundation' },
{ value: 'controls', label: 'Controls', count: 12 },
{ value: 'feedback', label: 'Feedback' },
{ value: 'data', label: 'Data' },
];
return (
<Stack space="xl">
<PageHeader
title="Design System"
subtitle="Every shared component, variant and state — the foundation all pages build on."
icon={Award}
size="display"
breadcrumb={
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem><BreadcrumbLink href="/">KROW Forge</BreadcrumbLink></BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem><BreadcrumbPage>Design System</BreadcrumbPage></BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
}
actions={<Button variant="outline" size="sm"><Download />Export tokens</Button>}
>
<Tabs tabs={TABS} value={tab} onChange={setTab} layoutGroupId="ds" />
</PageHeader>
{/* ── Foundation ──────────────────────────────────────────────────── */}
{tab === 'foundation' && (
<Stack space="lg">
<Spec title="Colour" description="Brand, semantic and ink scales. All resolve to CSS variables.">
<div className="space-y-4">
<Row label="Brand">
{[
['krow-blue', '#0A39DF'], ['krow-blue-dark', '#082EB4'], ['krow-yellow', '#F9E547'],
['krow-yellow-pale', '#F8E08E'], ['krow-navy', '#333F48'], ['krow-mint', '#D1E0D7'],
].map(([name, hex]) => (
<div key={name} className="text-center">
<div className="w-16 h-12 rounded-lg border border-border shadow-xs" style={{ background: hex }} />
<p className="text-[10px] text-ink-3 mt-1">{name}</p>
<p className="text-[9px] text-ink-4 font-mono">{hex}</p>
</div>
))}
</Row>
<Row label="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}`} />
<p className="text-[10px] text-ink-3 mt-1">{c.replace('bg-', '')}</p>
</div>
))}
</Row>
<Row label="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}`} />
<p className="text-[10px] text-ink-3 mt-1">{c.replace('bg-', '')}</p>
</div>
))}
</Row>
</div>
</Spec>
<Spec title="Typography" description="The named scale. Pages use these instead of arbitrary pixel values.">
<div className="space-y-2">
{[
['text-display-lg', 'Display Large'], ['text-display', 'Display'],
['text-title-lg', 'Title Large'], ['text-title', 'Title'],
['text-body-lg', 'Body Large'], ['text-body', 'Body'],
['text-body-sm', 'Body Small'], ['text-caption', 'Caption'],
['text-overline uppercase', 'Overline'],
].map(([cls, label]) => (
<div key={cls} className="flex items-baseline gap-4 border-b border-border pb-2 last:border-0">
<span className="text-caption text-ink-4 font-mono w-40 shrink-0">{cls}</span>
<span className={`${cls} font-heading text-ink-1`}>{label}</span>
</div>
))}
</div>
</Spec>
<Spec title="Elevation & radius" description="A four-step shadow scale plus the brand and panel treatments.">
<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}`}>
<span className="text-[10px] text-ink-3 font-mono">{s.replace('shadow-', '')}</span>
</div>
))}
</Row>
<Row label="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>
</div>
))}
</Row>
</Spec>
<Spec title="Surface" description="The one container. Every card in the app is one of these.">
<Grid cols={3}>
{['glass', 'solid', 'subtle', 'sunken', 'outline', 'panel'].map((v) => (
<Surface key={v} variant={v} radius="xl" padding="default">
<p className={`text-body-sm font-semibold ${v === 'panel' ? 'text-panel-foreground' : 'text-ink-1'}`}>{v}</p>
<p className={`text-caption mt-1 ${v === 'panel' ? 'text-panel-muted' : 'text-ink-3'}`}>
variant=&quot;{v}&quot;
</p>
</Surface>
))}
</Grid>
</Spec>
<Spec title="Layout" description="Grid presets that avoid orphan cards at every breakpoint.">
{[6, 4, 3, 2].map((cols) => (
<div key={cols}>
<p className="text-caption text-ink-4 mb-2">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" />
))}
</Grid>
</div>
))}
</Spec>
</Stack>
)}
{/* ── 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>
</Row>
<Row label="Size">
<Button size="xs">XS</Button>
<Button size="sm">Small</Button>
<Button>Default</Button>
<Button size="lg">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>
<Row label="State">
<Button loading>Loading</Button>
<Button disabled>Disabled</Button>
<Button><Plus />With icon</Button>
<Button variant="link">Link style</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>
</Spec>
<Spec title="Text input" description="Field owns the label, hint and error wiring.">
<Grid cols={2}>
<Field label="Full name" required hint="As it appears on their ID.">
<Input placeholder="Chef Antoine Dubois" />
</Field>
<Field label="Email" error="That email is already on file.">
<Input defaultValue="antoine@" />
</Field>
<Field label="Search">
<SearchInput value={search} onChange={setSearch} placeholder="Search candidates…" />
</Field>
<Field label="Disabled">
<Input disabled placeholder="Not editable" />
</Field>
<Field label="Notes" className="sm:col-span-2">
<Textarea placeholder="Anything the hiring manager should know…" rows={3} />
</Field>
</Grid>
</Spec>
<Spec title="Selection" description="Single select, multi select, and the boolean controls.">
<Grid cols={2}>
<Field label="Role category">
<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>
</SelectContent>
</Select>
</Field>
<Field label="Required certifications" hint="Chips are removable inline.">
<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"
/>
</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.">
<Checkbox defaultChecked />
</Field>
<Field label="Require certification upload" inline>
<Checkbox />
</Field>
<RadioGroup defaultValue="fluent" className="space-y-2 pt-2">
{['basic', 'conversational', 'fluent', 'native'].map((level) => (
<div key={level} className="flex items-center gap-2.5">
<RadioGroupItem value={level} id={`lvl-${level}`} />
<label htmlFor={`lvl-${level}`} className="text-body-sm text-ink-2 capitalize cursor-pointer">
{level}
</label>
</div>
))}
</RadioGroup>
</div>
<div className="space-y-4">
<Toggle
label="Auto-screen new applicants"
description="Score every application the moment it arrives."
checked={toggled}
onCheckedChange={setToggled}
/>
<Toggle label="Email me a daily digest" checked={false} onCheckedChange={() => {}} />
<Toggle label="Disabled setting" checked={false} onCheckedChange={() => {}} disabled />
<div className="pt-1">
<p className="text-caption text-ink-4 mb-2">Segmented</p>
<SegmentedToggle
options={[{ value: '7d', label: '7 days' }, { value: '30d', label: '30 days' }, { value: '90d', label: '90 days' }]}
value={segment}
onChange={setSegment}
ariaLabel="Date range"
/>
</div>
</div>
</div>
</Spec>
<Spec title="Navigation" description="Tabs, breadcrumbs, pagination and step progress.">
<Row label="Pill tabs">
<Tabs tabs={TABS} value={tab} onChange={setTab} layoutGroupId="ds-demo-pill" />
</Row>
<Row label="Underline">
<Tabs
variant="underline"
tabs={[{ value: 'all', label: 'All', count: 22 }, { value: 'screened', label: 'Screened', count: 14 }, { value: 'hired', label: 'Hired', count: 3 }]}
value="all"
onChange={() => {}}
layoutGroupId="ds-demo-underline"
/>
</Row>
<div className="pt-2">
<p className="text-caption text-ink-4 mb-3">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>
</div>
</div>
<div className="pt-2">
<Pagination page={page} pageCount={12} onPageChange={setPage} totalItems={287} pageSize={25} onPageSizeChange={() => {}} />
</div>
</Spec>
<Spec title="Overlays" description="Modal, drawer, dropdown, popover and tooltip.">
<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>
<DropdownMenu>
<DropdownMenuTrigger asChild><Button variant="outline">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 className="text-destructive focus:text-destructive">
<Trash2 className="w-4 h-4 mr-2" />Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Popover>
<PopoverTrigger asChild><Button variant="outline">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>
</PopoverContent>
</Popover>
<TooltipProvider>
<DsTooltip>
<TooltipTrigger asChild><Button variant="ghost">Hover me</Button></TooltipTrigger>
<TooltipContent>Tooltips use the same radius and shadow</TooltipContent>
</DsTooltip>
</TooltipProvider>
</Row>
</Spec>
</Stack>
)}
{/* ── 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">
{['default', 'soft', 'accent', 'navy', 'success', 'warning', 'destructive', 'info', 'neutral', 'outline'].map((v) => (
<Badge key={v} variant={v}>{v}</Badge>
))}
</Row>
<Row label="Application">
{['applied', 'ai_screened', 'shortlisted', 'interview', 'hired', 'rejected'].map((s) => (
<StatusBadge key={s} status={s} dot />
))}
</Row>
<Row label="Posting">
{['active', 'draft', 'paused', 'closed'].map((s) => <StatusBadge key={s} status={s} />)}
</Row>
<Row label="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.
</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>
<Alert tone="destructive" title="Certification expired" action={<Button size="sm" variant="destructive">Request update</Button>}>
Marcus Williams&apos; Guard Card lapsed on 12 July.
</Alert>
</Spec>
<Spec title="Toast" description="Wraps react-hot-toast so styling and duration live in one place.">
<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.promise(
new Promise((r) => setTimeout(r, 1800)),
{ loading: 'Screening 8 candidates…', success: 'Screened 8 candidates', error: 'Screening failed' }
)}
>
Promise
</Button>
</Row>
</Spec>
<Spec title="Empty state" description="Distinguishes nothing-yet from nothing-matching.">
<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."
action={{ label: 'Copy apply link', onClick: () => toast.success('Link copied') }}
size="sm"
/>
</Surface>
<Surface variant="subtle" padding="none" radius="xl">
<EmptyState
icon={Inbox}
variant="filtered"
title="No matching candidates"
description="No one matches the current filters. Try widening the score range."
action={{ label: 'Clear filters', onClick: () => {} }}
size="sm"
/>
</Surface>
</Grid>
</Spec>
<Spec title="Loading" description="Skeletons mirror final geometry; spinners are for unknown shapes.">
<Row label="Spinner">
<Spinner size="xs" /><Spinner size="sm" /><Spinner /><Spinner size="lg" />
<InlineLoading message="Screening candidates…" />
</Row>
<Grid cols={4} className="pt-2">
<SkeletonKpi /><SkeletonKpi /><SkeletonKpi /><SkeletonKpi />
</Grid>
<Grid cols={2}>
<SkeletonCard />
<Surface variant="solid" radius="xl" padding="default"><SkeletonList rows={3} /></Surface>
</Grid>
<Surface variant="solid" radius="xl" padding="none" className="overflow-hidden">
<SkeletonTable rows={3} columns={4} />
</Surface>
<Surface variant="solid" radius="xl" padding="default">
<SkeletonText lines={3} />
</Surface>
<Row label="Region"><LoadingState message="Loading analytics…" size="sm" /></Row>
</Spec>
</Stack>
)}
{/* ── Data ────────────────────────────────────────────────────────── */}
{tab === 'data' && (
<Stack space="lg">
<PageSection title="KPI cards">
<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} />
</Grid>
</PageSection>
<PageSection title="Metric cards">
<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" />
</Grid>
</PageSection>
<Spec title="Progress" description="Bars, rings and score meters share the same band thresholds.">
<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 />
</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} />
</div>
</Grid>
<Row label="Rings">
<ProgressRing value={97} caption="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={28} tone="score" size={72} />
</Row>
</Spec>
<Spec title="Avatar" description="Photo when available, initials otherwise. Never a broken image.">
<Row label="Sizes">
{['xs', 'sm', 'default', 'lg', 'xl'].map((s) => (
<Avatar key={s} name="Chef Antoine Dubois" size={s} />
))}
</Row>
<Row label="Tones">
<Avatar name="Marcus Williams" tone="brand" />
<Avatar name="Sofia Mendez" tone="navy" />
<Avatar name="Tanya Cruz" tone="soft" />
<Avatar name="Sam Okafor" tone="neutral" />
<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">
<Avatar name="Online Person" status="online" />
<Avatar name="Busy Person" status="busy" />
<Avatar name="Away Person" status="offline" />
</Row>
<Row label="Group">
<AvatarGroup
people={[
{ name: 'Chef Antoine Dubois' }, { name: 'Marcus Williams' },
{ name: 'Sofia Mendez' }, { name: 'Tanya Cruz' },
{ name: 'Jordan Blake' }, { name: 'Sam Okafor' },
]}
max={4}
/>
</Row>
</Spec>
<Spec title="Filter bar" description="Declarative filters with active chips and a clear-all.">
<FilterBar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Search candidates…"
values={filters}
onChange={setFilters}
filters={[
{
key: 'stage', label: 'Stage', type: 'select',
options: [
{ value: 'all', label: 'All stages' }, { value: 'applied', label: 'Applied' },
{ value: 'ai_screened', label: 'AI Screened' }, { value: 'hired', label: 'Hired' },
],
},
{ 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>}
/>
</Spec>
<PageSection title="Data table">
<DataTable
columns={[
{ key: 'name', header: 'Candidate', sortable: true, cell: (r) => (
<div className="flex items-center gap-2.5">
<Avatar name={r.name} size="sm" />
<span className="font-medium text-ink-1">{r.name}</span>
</div>
) },
{ key: 'role', header: 'Role', sortable: true, hideBelow: 'sm' },
{ key: 'exp', header: 'Experience', sortable: true, align: 'right', hideBelow: 'md', cell: (r) => `${r.exp} yr` },
{ key: 'status', header: 'Stage', cell: (r) => <StatusBadge status={r.status} size="sm" /> },
{ key: 'score', header: 'Score', sortable: true, align: 'right', cell: (r) => (
<span className="font-heading font-bold text-krow-blue tabular-nums">{r.score}</span>
) },
]}
rows={TABLE_ROWS}
defaultSort={{ key: 'score', direction: 'desc' }}
selectedIds={selected}
onSelectionChange={setSelected}
pageSize={5}
caption="Candidates by AI score"
onRowClick={(r) => toast.info(`Opened ${r.name}`)}
/>
</PageSection>
<Spec title="Chart container" description="Owns the title, height, legend, loading and empty states.">
<Grid cols={2}>
<ChartContainer
title="Candidate Scores"
subtitle="9 candidates screened"
headline={76}
headlineLabel="Avg score"
height={220}
legend={[
{ label: '0–39', color: CHART_TONES.navy, value: 1 },
{ label: '60–79', color: CHART_TONES.accentPale, value: 3 },
{ label: '80–100', color: CHART_TONES.brand, value: 5 },
]}
>
<BarChart data={CHART_DATA}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} vertical={false} />
<XAxis dataKey="label" {...AXIS_PROPS} />
<YAxis {...AXIS_PROPS} allowDecimals={false} />
<Tooltip content={<ChartTooltip />} />
<Bar dataKey="count" name="Candidates" fill={CHART_TONES.brand} radius={[6, 6, 0, 0]} />
</BarChart>
</ChartContainer>
<ChartContainer title="Interview Volume" subtitle="No interviews in this window" height={220} isEmpty />
</Grid>
</Spec>
<Spec title="Timeline & activity" description="Event sequences and feed rows.">
<Grid cols={2}>
<div>
<p className="text-caption text-ink-4 mb-3">Timeline</p>
<Timeline
items={[
{ id: 1, title: 'Applied', description: 'Executive Chef – Catering', timestamp: 'Jul 24', icon: Calendar, tone: 'neutral' },
{ id: 2, title: 'AI screened', description: 'Scored 97 — Excellent Match', timestamp: 'Jul 24', icon: Brain, tone: 'brand', status: 'ai_screened' },
{ id: 3, title: 'Interviewed', description: 'Verdict: hire', timestamp: 'Jul 25', icon: CheckCircle2, tone: 'success' },
{ id: 4, title: 'Hired', description: 'Onboarding started', timestamp: 'Jul 25', icon: Award, tone: 'success', status: 'hired', current: true },
]}
/>
</div>
<div>
<p className="text-caption text-ink-4 mb-3">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
icon={AlertCircle}
iconTone="warning"
title="Interview flagged"
description="Three responses returned in under 8 seconds."
timestamp="Aug 3"
trailing={<Badge variant="warning" size="sm">Review</Badge>}
variant="card"
/>
</div>
</div>
</Grid>
</Spec>
</Stack>
)}
{/* Overlay instances */}
<Modal
open={modalOpen}
onOpenChange={setModalOpen}
title="Schedule interview"
description="Owliver will run the interview and score it automatically."
icon={Calendar}
footer={
<>
<Button variant="outline" onClick={() => setModalOpen(false)}>Cancel</Button>
<Button onClick={() => { setModalOpen(false); toast.success('Interview scheduled'); }}>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>
</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"
icon={AlertCircle}
onConfirm={() => { setConfirmOpen(false); toast.success('Candidate declined'); }}
/>
<Drawer
open={drawerOpen}
onOpenChange={setDrawerOpen}
title="Chef Antoine Dubois"
description="Executive Chef – Catering · Scored 97"
icon={Star}
footer={
<>
<Button variant="outline" onClick={() => setDrawerOpen(false)}>Close</Button>
<Button onClick={() => { setDrawerOpen(false); toast.success('Shortlisted'); }}>Shortlist</Button>
</>
}
>
<div className="space-y-5">
<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>
<div className="flex gap-1.5 mt-2">
<StatusBadge status="hired" size="sm" />
<StatusBadge status="elite" size="sm" />
</div>
</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} />
</div>
</div>
</Drawer>
</Stack>
);
}

View File

@@ -0,0 +1,38 @@
import React from 'react';
import { useWorkerProfile, useJobPostings, useCourses, useLearningPaths } from '@/lib/krowHooks';
import { recommendJobs, recommendNextCourse } from '@/lib/krowScore';
import TalentHero from '@/components/krow/talent/TalentHero';
import IncreaseMarketValue from '@/components/krow/talent/IncreaseMarketValue';
import CareerGrowth from '@/components/krow/talent/CareerGrowth';
import MarketOffers from '@/components/krow/talent/MarketOffers';
import ExperienceNetwork from '@/components/krow/talent/ExperienceNetwork';
import { Loader2 } from 'lucide-react';
import { AssistantTrigger } from '@/components/ai-assistant';
export default function EmployeeDashboard() {
const { data: profile, isLoading } = useWorkerProfile();
const { data: postings = [] } = useJobPostings();
const { data: courses = [] } = useCourses();
const { data: paths = [] } = useLearningPaths();
if (isLoading || !profile) {
return <div className="flex justify-center py-20"><Loader2 className="w-6 h-6 animate-spin text-[#0A39DF]" /></div>;
}
const jobRecs = recommendJobs(profile, postings, 6);
const { course: nextCourse } = recommendNextCourse(profile, paths, courses);
const openOwliver = () => window.dispatchEvent(new CustomEvent('krow:open-owliver'));
return (
<div className="space-y-6">
<div className="flex justify-end">
<AssistantTrigger />
</div>
<TalentHero profile={profile} jobRecs={jobRecs} nextCourse={nextCourse} openOwliver={openOwliver} />
<MarketOffers profile={profile} jobRecs={jobRecs} />
<IncreaseMarketValue courses={courses} />
<CareerGrowth profile={profile} />
<ExperienceNetwork profile={profile} />
</div>
);
}

226
src/pages/HiredHistory.jsx Normal file
View File

@@ -0,0 +1,226 @@
import React, { useState, useMemo } from 'react';
import { useStaff, useJobPostings } from '@/lib/krowHooks';
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';
const TIER_STYLES = {
Skilled: 'bg-[#EFF6FF] text-[#1E40AF]',
'Cross-Trained': 'bg-[#F0F4F8] text-[#1E56C1]',
Beginner: 'bg-[#F3F4F6] text-[#6B7280]',
};
const STATUS_STYLES = {
active: 'bg-[#ECFDF5] text-[#047857]',
onboarding: 'bg-[#FFF7ED] text-[#B45309]',
inactive: 'bg-[#F3F4F6] text-[#6B7280]',
};
function formatDate(d) {
try {
return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
} catch { return ''; }
}
function Stars({ count, size = 14 }) {
return (
<div className="flex items-center gap-0.5">
{Array.from({ length: 5 }).map((_, i) => (
<Star
key={i}
style={{ width: size, height: size }}
className={i < count ? 'text-[#F59E0B] fill-[#F59E0B]' : 'text-[#E5E7EB]'}
/>
))}
</div>
);
}
export default function HiredHistory() {
const { data: staff = [], isLoading } = useStaff();
const { data: postings = [] } = useJobPostings();
const [search, setSearch] = useState('');
const [tierFilter, setTierFilter] = useState('all');
const [reviewFilter, setReviewFilter] = useState('all');
const [rateStaff, setRateStaff] = useState(null);
const [reviewerName, setReviewerName] = useState('');
React.useEffect(() => {
base44.auth.me().then((u) => setReviewerName(u.full_name || '')).catch(() => {});
}, []);
const postingById = useMemo(() => Object.fromEntries(postings.map(p => [p.id, p])), [postings]);
const filtered = staff.filter((s) => {
const q = search.toLowerCase();
const matchesSearch = !search ||
s.name?.toLowerCase().includes(q) ||
s.email?.toLowerCase().includes(q) ||
(s.role || '').toLowerCase().includes(q);
const matchesTier = tierFilter === 'all' || s.profile_tier === tierFilter;
const hasReview = (s.client_rating || 0) > 0;
const matchesReview = reviewFilter === 'all' ||
(reviewFilter === 'rated' && hasReview) ||
(reviewFilter === 'pending' && !hasReview);
return matchesSearch && matchesTier && matchesReview;
});
const sorted = [...filtered].sort((a, b) => new Date(b.created_date) - new Date(a.created_date));
const ratedCount = staff.filter((s) => (s.client_rating || 0) > 0).length;
const avgRating = ratedCount > 0
? (staff.reduce((sum, s) => sum + (s.client_rating || 0), 0) / ratedCount).toFixed(1)
: '—';
const endorsedCount = staff.filter((s) => (s.endorsed_skills || []).length > 0).length;
return (
<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>
</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} />
</div>
</div>
{/* Retention metrics */}
<RetentionMetrics staff={staff} />
{/* Filters */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#9CA3AF]" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search hires by name, email, or role..."
className="w-full h-10 pl-9 pr-4 rounded-lg border border-[#E5E7EB] bg-white text-[13px] focus:border-[#0A39DF] 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-[#0A39DF] 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>
</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-[#0A39DF] focus:outline-none">
<option value="all">All Reviews</option>
<option value="rated">Rated</option>
<option value="pending">Pending Review</option>
</select>
</div>
{/* List */}
{isLoading ? (
<div className="space-y-3">
{[...Array(4)].map((_, i) => <div key={i} className="h-28 bg-white border border-[#E5E7EB] rounded-xl animate-pulse" />)}
</div>
) : 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>
</div>
) : (
<div className="space-y-3">
{sorted.map((s) => {
const jobTitle = s.role || postingById[s.job_posting_id]?.title || 'Staff';
const rating = s.client_rating || 0;
const skills = s.endorsed_skills || [];
return (
<div key={s.id} className="bg-white border border-[#E5E7EB] rounded-xl p-4">
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="w-11 h-11 rounded-full bg-[#EFF6FF] text-[#0A39DF] flex items-center justify-center font-bold text-[14px] shrink-0">
{(s.name || '?').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="text-[14px] font-bold text-[#111827] truncate">{s.name}</h3>
{s.profile_tier && (
<span className={`text-[10px] font-semibold px-2 py-0.5 rounded-full ${TIER_STYLES[s.profile_tier] || TIER_STYLES.Beginner}`}>{s.profile_tier}</span>
)}
<span className={`text-[10px] font-semibold px-2 py-0.5 rounded-full ${STATUS_STYLES[s.status] || STATUS_STYLES.inactive}`}>{s.status}</span>
</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.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>
</div>
</div>
<div className="flex items-center gap-3 shrink-0">
<div className="text-right">
{rating > 0 ? (
<div className="flex items-center gap-2 justify-end">
<Stars count={rating} />
<span className="text-[13px] font-bold text-[#F59E0B]">{rating.toFixed(1)}</span>
</div>
) : (
<span className="text-[11px] text-[#9CA3AF] italic">Not yet rated</span>
)}
{s.review_date && <p className="text-[10px] text-[#9CA3AF] mt-0.5">Reviewed {formatDate(s.review_date)}</p>}
</div>
<button
onClick={() => setRateStaff(s)}
className="inline-flex items-center gap-1.5 bg-[#0A39DF] hover:bg-[#082EB4] text-white text-[12px] font-medium px-3 py-2 rounded-lg transition-colors"
>
<Award className="w-3.5 h-3.5" />
{rating > 0 ? 'Update' : 'Rate & Endorse'}
</button>
</div>
</div>
{(skills.length > 0 || s.endorsement_text) && (
<div className="mt-3 pt-3 border-t border-[#F3F4F6] space-y-2">
{skills.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{skills.map((sk, i) => (
<span key={i} className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-[#F0F4F8] text-[#1E56C1] text-[11px] font-medium">
<BadgeCheck className="w-3 h-3" />
{sk}
</span>
))}
</div>
)}
{s.endorsement_text && (
<p className="text-[12px] text-[#374151] italic leading-relaxed">"{s.endorsement_text}"</p>
)}
{s.reviewer_name && (
<p className="text-[10px] text-[#9CA3AF]">— {s.reviewer_name}</p>
)}
</div>
)}
</div>
);
})}
</div>
)}
{rateStaff && (
<RateEndorseModal
open={true}
onClose={() => setRateStaff(null)}
staff={rateStaff}
reviewerName={reviewerName}
/>
)}
</div>
);
}
function StatPill({ label, value, accent }) {
return (
<div className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-[#F0F4F8] border border-[#E5E7EB]">
<span className="text-[11px] font-semibold text-[#1E56C1]">{label}</span>
<span className="text-[11px] text-[#5D759B]">·</span>
<span className={`text-[12px] font-bold ${accent ? 'text-[#F59E0B]' : 'text-[#111827]'}`}>{value}</span>
</div>
);
}

193
src/pages/KrowIdentity.jsx Normal file
View File

@@ -0,0 +1,193 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useWorkerProfile, useUpdateWorkerProfile, useCertifications } from '@/lib/krowHooks';
import { recalcProfilePatch } from '@/lib/krowScore';
import { base44 } from '@/api/base44Client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
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';
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 navigate = useNavigate();
const { data: profile, isLoading } = useWorkerProfile();
const update = useUpdateWorkerProfile();
const { data: certs = [] } = useCertifications();
const [form, setForm] = useState(null);
const [skillInput, setSkillInput] = useState('');
const [langInput, setLangInput] = useState('');
const [uploadingSelfie, setUploadingSelfie] = useState(false);
useEffect(() => {
if (profile && !form) {
setForm({
full_name: profile.full_name || '',
phone: profile.phone || '',
address: profile.address || '',
career_goals: profile.career_goals || '',
skills: profile.skills || [],
languages: profile.languages || [],
availability: profile.availability || [],
certifications: profile.certifications || [],
selfie_url: profile.selfie_url || '',
});
}
}, [profile, form]);
if (isLoading || !profile || !form) {
return <div className="flex justify-center py-20"><Loader2 className="w-6 h-6 animate-spin text-[#0A39DF]" /></div>;
}
const set = (k, v) => setForm({ ...form, [k]: v });
const certOptions = certs.length > 0 ? certs.map(c => c.name) : DEFAULT_CERTS;
const toggleCert = (c) => set('certifications', form.certifications.includes(c) ? form.certifications.filter(x => x !== c) : [...form.certifications, c]);
const toggleAvail = (a) => set('availability', form.availability.includes(a) ? form.availability.filter(a2 => a2 !== a) : [...form.availability, a]);
const handleSelfie = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setUploadingSelfie(true);
try {
const { file_url } = await base44.integrations.Core.UploadFile({ file });
set('selfie_url', file_url);
} catch {}
setUploadingSelfie(false);
e.target.value = '';
};
const save = async () => {
if (!form.full_name.trim()) { toast.error('Your name is required.'); return; }
if (!form.selfie_url) { toast.error('Add a selfie to complete your identity.'); return; }
try {
const merged = { ...profile, ...form };
const patch = { ...form, ...recalcProfilePatch(merged) };
await update.mutateAsync({ id: profile.id, data: patch });
toast.success('KROW Identity created. Now let Owliver learn about you.');
navigate('/owliver');
} catch {
toast.error('Could not save identity.');
}
};
return (
<div className="max-w-2xl mx-auto space-y-6">
{/* Living identity card — the experience, not the explanation */}
<KrowIdentityCard form={form} />
{/* Career Passport — verified credentials that make up the KROW Identity */}
<CareerPassport profile={profile} />
{/* Selfie + name */}
<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]" />
) : (
<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>
{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
</button>
) : (
<label className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-[#0A39DF] hover:bg-[#082EB4] text-white text-[12px] font-medium cursor-pointer">
{uploadingSelfie ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Camera className="w-3.5 h-3.5" />}
{uploadingSelfie ? 'Uploading...' : 'Take selfie'}
<input type="file" accept="image/*" capture="user" onChange={handleSelfie} disabled={uploadingSelfie} className="hidden" />
</label>
)}
</div>
</div>
<div>
<Label className="text-[13px] text-[#374151]">Full Name *</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>
</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-[#0A39DF] focus:outline-none resize-none" placeholder="e.g. Line cook with 4 years in fast-casual kitchens. Fast, clean, reliable." />
</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>
<div className="flex gap-2 mt-1.5">
<Input value={skillInput} onChange={e => setSkillInput(e.target.value)} placeholder="Add a skill..." 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>
</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>
<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"
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>
</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>
<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>
))}
</div>
</div>
{/* Certifications */}
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-5 sm:p-6">
<Label className="text-[13px] text-[#374151]">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-[#0A39DF] border-[#0A39DF] text-white' : 'border-[#E5E7EB] text-[#6B7280] hover:border-[#D1D5DB]'}`}>{c}</button>
))}
</div>
</div>
<Button onClick={save} disabled={update.isPending} className="w-full h-11 text-[14px] bg-[#0A39DF] hover:bg-[#082EB4]">
{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" />
</Button>
</div>
);
}
function Chips({ items, onRemove, tone = 'gray' }) {
if (!items?.length) return null;
const cls = tone === 'blue' ? 'bg-[#EFF6FF] text-[#1E40AF] border-[#DBEAFE]' : 'bg-[#F3F4F6] text-[#374151]';
return (
<div className="flex flex-wrap gap-1.5 mt-2">
{items.map((s, i) => (
<span key={i} className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-[12px] border ${cls}`}>
{s}
<button type="button" onClick={() => onRemove(i)} className="text-[#9CA3AF] hover:text-[#EF4444]"><X className="w-3 h-3" /></button>
</span>
))}
</div>
);
}

200
src/pages/Overview.jsx Normal file
View File

@@ -0,0 +1,200 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
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 { AssistantTrigger } from '@/components/ai-assistant';
const METRICS = [
{ icon: Briefcase, label: 'Open Positions', bg: 'bg-[#0A39DF]', glow: 'shadow-blue-600/20' },
{ icon: Users, label: 'Total Candidates', bg: 'bg-[#333F48]', glow: 'shadow-slate-600/20' },
{ icon: Brain, label: 'AI Screened', bg: 'bg-[#0A39DF]', glow: 'shadow-blue-600/20' },
{ icon: Clock, label: 'Pending Screen', bg: 'bg-[#F9E547]', glow: 'shadow-yellow-400/30' },
{ icon: TrendingUp, label: 'Avg AI Score', bg: 'bg-[#333F48]', glow: 'shadow-slate-600/20' },
{ icon: Award, label: 'Hired', bg: 'bg-[#0A39DF]', glow: 'shadow-blue-600/20' },
];
function MetricCard({ icon: Icon, value, label, bg, glow, index }) {
const isYellow = bg === 'bg-[#F9E547]';
return (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.06, duration: 0.4, ease: 'easeOut' }}
whileHover={{ y: -4, transition: { duration: 0.2 } }}
className="glass-card border border-white/60 rounded-2xl p-4 shadow-sm hover:shadow-lg transition-shadow"
>
<div className={`w-10 h-10 rounded-xl ${bg} flex items-center justify-center shadow-lg ${glow} mb-3`}>
<Icon className={`w-5 h-5 ${isYellow ? 'text-[#0A39DF]' : 'text-white'}`} />
</div>
<motion.div
key={value}
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: index * 0.06 + 0.2, type: 'spring', stiffness: 200 }}
className="text-3xl font-heading font-bold gradient-text"
>
{value}
</motion.div>
<div className="text-[11px] text-[#9CA3AF] font-medium tracking-wide mt-0.5">{label}</div>
</motion.div>
);
}
export default function Overview() {
const navigate = useNavigate();
const { data: postings = [] } = useJobPostings();
const { data: applications = [] } = useApplications();
const values = [
postings.filter(p => p.status === 'active').length,
applications.length,
applications.filter(a => ['ai_screened', 'interview', 'hired', 'shortlisted'].includes(a.status)).length,
applications.filter(a => a.status === 'applied').length,
(() => { const s = applications.filter(a => a.ai_score > 0); return s.length ? Math.round(s.reduce((sum, a) => sum + a.ai_score, 0) / s.length) : 0; })(),
applications.filter(a => a.status === 'hired').length,
];
const topCandidates = [...applications.filter(a => a.ai_score > 0)].sort((a, b) => b.ai_score - a.ai_score).slice(0, 5);
const activePostings = postings.filter(p => p.status === 'active').slice(0, 5);
const statsForPosting = (postingId) => {
const apps = applications.filter(a => a.job_posting_id === postingId);
return {
applied: apps.length,
screened: apps.filter(a => a.status !== 'applied').length,
};
};
return (
<div className="space-y-8">
{/* Hero greeting */}
<motion.div
initial={{ opacity: 0, y: -12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="flex items-center justify-between flex-wrap gap-4"
>
<div>
<h1 className="text-3xl font-heading font-bold text-[#111827]">
Good day. <span className="gradient-text">Let's hire smarter.</span>
</h1>
<p className="text-[14px] text-[#6B7280] mt-1">AI-powered hiring · No quizzes · Real readiness</p>
</div>
<AssistantTrigger />
</motion.div>
{/* Metrics Grid */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
{METRICS.map((m, i) => (
<MetricCard key={m.label} icon={m.icon} value={values[i]} label={m.label} bg={m.bg} glow={m.glow} index={i} />
))}
</div>
{/* Top Candidates + Open Positions */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Top Candidates */}
<motion.div
initial={{ opacity: 0, x: -16 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.5 }}
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>
<button onClick={() => navigate('/candidates')} className="text-[12px] text-[#0A39DF] hover:underline inline-flex items-center gap-1 font-medium">
View all <ChevronRight className="w-3 h-3" />
</button>
</div>
<div className="space-y-1">
{topCandidates.length === 0 && (
<div className="text-center py-10">
<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>
</div>
)}
{topCandidates.map((app, idx) => (
<motion.div
key={app.id}
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.6 + idx * 0.06 }}
className="flex items-center gap-3 p-2.5 rounded-xl hover:bg-white/50 transition-colors cursor-pointer"
onClick={() => navigate('/candidates')}
>
<span className="text-[13px] font-heading font-bold text-[#9CA3AF] w-7">{idx + 1}</span>
<div className="w-9 h-9 rounded-full bg-[#0A39DF] flex items-center justify-center text-white text-[12px] font-semibold shrink-0">
{app.applicant_name?.split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="text-[13px] font-medium text-[#111827] truncate">{app.applicant_name}</div>
<div className="text-[11px] text-[#6B7280] truncate">{app.job_title || 'Applicant'}</div>
</div>
<div className="flex items-center gap-2">
<div className="w-16 h-1.5 rounded-full bg-gray-100 overflow-hidden">
<div className="h-full bg-[#0A39DF] rounded-full" style={{ width: `${app.ai_score}%` }} />
</div>
<span className="text-[14px] font-heading font-bold gradient-text w-7 text-right">{app.ai_score}</span>
</div>
</motion.div>
))}
</div>
</motion.div>
{/* Open Positions */}
<motion.div
initial={{ opacity: 0, x: 16 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.5 }}
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>
<button onClick={() => navigate('/positions')} className="text-[12px] text-[#0A39DF] hover:underline inline-flex items-center gap-1 font-medium">
View all <ChevronRight className="w-3 h-3" />
</button>
</div>
<div className="space-y-1">
{activePostings.length === 0 && (
<div className="text-center py-10">
<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>
</div>
)}
{activePostings.map((posting, idx) => {
const stats = statsForPosting(posting.id);
return (
<motion.div
key={posting.id}
initial={{ opacity: 0, x: 8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.6 + idx * 0.06 }}
onClick={() => navigate(`/positions/${posting.id}`)}
className="flex items-center gap-3 p-2.5 rounded-xl hover:bg-white/50 transition-colors cursor-pointer group"
>
<span className="text-[13px] font-heading font-bold text-[#9CA3AF] w-7">{idx + 1}</span>
<div className="w-1 h-9 rounded-full bg-[#0A39DF] 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>
<ChevronRight className="w-4 h-4 text-[#D1D5DB] group-hover:text-[#0A39DF] transition-colors" />
</motion.div>
);
})}
</div>
</motion.div>
</div>
{/* Candidate Pipeline */}
<KROWCandidateLeaderboardReal applications={applications} />
</div>
);
}

563
src/pages/Owliver.jsx Normal file
View File

@@ -0,0 +1,563 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useWorkerProfile, useUpdateWorkerProfile } from '@/lib/krowHooks';
import { recalcProfilePatch } from '@/lib/krowScore';
import { owliverQuestion, buildProfileFromConversation } from '@/lib/krowAi';
import { Button } from '@/components/ui/button';
import { Mic, MicOff, Volume2, VolumeX, Loader2, ArrowRight, Send, Sparkles, RefreshCw } from 'lucide-react';
import { cn } from '@/lib/utils';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
const TOTAL_SECONDS = 300; // 5 minutes
const QUESTION_COUNT = 6;
const AVAIL_TOKENS = ['Weekdays', 'Weekends', 'Evenings', 'Mornings', 'Overnight', 'On-Call'];
// Owliver's wisdom — Career DNA: a living professional identity, built from signals not claims
const LEARNS = [
'Identity — who they are',
'Skills — what they can do',
'Experience — what they proved',
'Learning speed — how fast they grow',
'Reliability — can employers trust them',
'Communication — can they explain',
'Leadership — do people follow them',
'Adaptability — can they pivot',
'Work style — builder, operator, leader',
'Culture match — where they thrive',
'Reputation — verified over time',
];
const UI = {
en: {
badge: 'Step 2 · Meet Owliver',
title: 'Talk to Owliver',
subtitle: 'Not forms. Just a 5-minute conversation. Owliver learns who you are and builds your profile automatically.',
hi: (n) => `Hi ${n}, I'm Owliver`,
introP: "I'll ask a few friendly questions. Just speak naturally — there are no wrong answers.",
banner: "Just a casual chat — no right or wrong answers. I'll turn it into your profile for you.",
speaks: 'Owliver speaks',
muted: 'Muted',
start: 'Start talking',
question: 'Question',
of: 'of',
skipped: 'skipped',
listening: 'Listening…',
placeholder: 'Type your answer or tap the mic to speak',
speak: 'Speak',
stop: 'Stop',
skip: 'Skip',
send: 'Send',
building1: 'Owliver is building your profile…',
building2: 'Turning our conversation into your KROW Identity.',
done: 'Owliver built your profile',
seeId: 'See my KROW ID',
yrs: 'yrs',
dash: '—',
notEnoughTitle: "Owliver didn't catch enough",
notEnoughBody: "I didn't get much from our chat — try again and answer a couple of questions so I can build your profile.",
chatAgain: "Let's chat again",
goToProfile: 'Go to my profile',
labels: { desired: 'Desired role', experience: 'Experience', transport: 'Transportation', salary: 'Salary', leadership: 'Leadership', skills: 'Skills', languages: 'Languages', industries: 'Industries', availability: 'Availability', strengths: 'Strengths', workingOn: 'Working on', personality: 'Personality', identity: 'Identity', learningSpeed: 'Learning speed', reliability: 'Reliability', communication: 'Communication', leadershipIndex: 'Leadership index', adaptability: 'Adaptability', workStyle: 'Work style', cultureMatch: 'Culture match', reputation: 'Reputation baseline' },
dnaHeader: 'Career DNA',
dnaSub: 'Built from this conversation — signals, not claims. It grows with every verified shift, certification, and review.',
},
es: {
badge: 'Paso 2 · Conoce a Owliver',
title: 'Habla con Owliver',
subtitle: 'No formularios. Solo una conversación de 5 minutos. Owliver aprende quién eres y crea tu perfil automáticamente.',
hi: (n) => `Hola ${n}, soy Owliver`,
introP: 'Te haré algunas preguntas amigables. Solo habla naturalmente — no hay respuestas incorrectas.',
banner: 'Solo una charla casual — no hay respuestas correctas ni incorrectas. Lo convierto en tu perfil por ti.',
speaks: 'Owliver habla',
muted: 'Silenciado',
start: 'Empezar a hablar',
question: 'Pregunta',
of: 'de',
skipped: 'omitido',
listening: 'Escuchando…',
placeholder: 'Escribe tu respuesta o toca el micrófono para hablar',
speak: 'Hablar',
stop: 'Detener',
skip: 'Saltar',
send: 'Enviar',
building1: 'Owliver está creando tu perfil…',
building2: 'Convirtiendo nuestra conversación en tu KROW Identity.',
done: 'Owliver creó tu perfil',
seeId: 'Ver mi KROW ID',
yrs: 'años',
dash: '—',
notEnoughTitle: 'Owliver no captó suficiente',
notEnoughBody: 'No obtuve mucho de nuestra charla — intenta de nuevo y responde un par de preguntas para que pueda crear tu perfil.',
chatAgain: 'Charlemos de nuevo',
goToProfile: 'Ir a mi perfil',
labels: { desired: 'Rol deseado', experience: 'Experiencia', transport: 'Transporte', salary: 'Salario', leadership: 'Liderazgo', skills: 'Habilidades', languages: 'Idiomas', industries: 'Industrias', availability: 'Disponibilidad', strengths: 'Fortalezas', workingOn: 'Por mejorar', personality: 'Personalidad', identity: 'Identidad', learningSpeed: 'Velocidad de aprendizaje', reliability: 'Confiabilidad', communication: 'Comunicación', leadershipIndex: 'Índice de liderazgo', adaptability: 'Adaptabilidad', workStyle: 'Estilo de trabajo', cultureMatch: 'Encaje cultural', reputation: 'Reputación base' },
dnaHeader: 'Career DNA',
dnaSub: 'Creado a partir de esta conversación — señales, no afirmaciones. Crece con cada turno, certificación y reseña verificados.',
},
};
export default function Owliver({ compact = false }) {
const navigate = useNavigate();
const { data: profile, isLoading } = useWorkerProfile();
const updateProfile = useUpdateWorkerProfile();
const [phase, setPhase] = useState('intro'); // intro | active | building | done
const [messages, setMessages] = useState([]);
const [transcript, setTranscript] = useState('');
const [isListening, setIsListening] = useState(false);
const [isSpeaking, setIsSpeaking] = useState(false);
const [timeLeft, setTimeLeft] = useState(TOTAL_SECONDS);
const [extracted, setExtracted] = useState(null);
const [error, setError] = useState('');
const [ttsEnabled, setTtsEnabled] = useState(true);
const [language, setLanguage] = useState('en');
const recognitionRef = useRef(null);
const lastUserTime = useRef(null);
const timerRef = useRef(null);
const scrollRef = useRef(null);
const workerName = profile?.full_name || 'there';
const t = UI[language];
useEffect(() => {
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}, [messages, isSpeaking]);
// Warm owl voice — slightly higher pitch, slower, gentle
const speak = useCallback((text) => {
return new Promise((resolve) => {
if (!ttsEnabled || !window.speechSynthesis) { resolve(); return; }
window.speechSynthesis.cancel();
const u = new SpeechSynthesisUtterance(text);
const target = language === 'es' ? 'es' : 'en';
u.lang = language === 'es' ? 'es-ES' : 'en-US';
const voices = window.speechSynthesis.getVoices();
const langVoices = voices.filter(v => v.lang?.toLowerCase().startsWith(target));
if (langVoices.length > 0) {
const preferred =
langVoices.find(v => v.localService && v.lang.toLowerCase() === (language === 'es' ? 'es-es' : 'en-us')) ||
langVoices.find(v => v.localService) ||
langVoices.find(v => v.name.toLowerCase().includes('google')) ||
langVoices[0];
u.voice = preferred;
}
u.rate = 0.96;
u.pitch = 1.12;
u.onstart = () => setIsSpeaking(true);
u.onend = () => { setIsSpeaking(false); resolve(); };
u.onerror = () => { setIsSpeaking(false); resolve(); };
window.speechSynthesis.speak(u);
});
}, [ttsEnabled, language]);
const stopListening = useCallback(() => {
if (recognitionRef.current) { recognitionRef.current.stop(); recognitionRef.current = null; }
setIsListening(false);
}, []);
const askNext = useCallback(async (currentMessages) => {
const qNumber = currentMessages.filter(m => m.role === 'assistant').length + 1;
if (qNumber > QUESTION_COUNT) {
await finishChat(currentMessages);
return;
}
try {
const question = await owliverQuestion(currentMessages, workerName, qNumber, language);
const aiMsg = { role: 'assistant', content: question, timestamp: new Date().toISOString() };
const newMessages = [...currentMessages, aiMsg];
setMessages(newMessages);
lastUserTime.current = Date.now();
await speak(question);
} catch (e) {
setError('Owliver lost his train of thought. Try again.');
}
}, [workerName, speak, language]);
const startListening = useCallback(() => {
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SR) { setError('Voice input not supported here — type your answer below.'); return; }
// Yield Owliver's voice so the mic picks up the user, not Owliver.
if (window.speechSynthesis) window.speechSynthesis.cancel();
setIsSpeaking(false);
const base = transcript;
if (!base) setTranscript('');
setError('');
const rec = new SR();
rec.continuous = true;
rec.interimResults = true;
rec.lang = language === 'es' ? 'es-ES' : 'en-US';
rec.onresult = (event) => {
let text = '';
for (let i = 0; i < event.results.length; i++) text += event.results[i][0].transcript;
setTranscript((base ? base + ' ' : '') + text.trim());
};
rec.onerror = (event) => { if (event.error !== 'no-speech' && event.error !== 'aborted') setError(`Mic error: ${event.error}`); setIsListening(false); };
rec.onend = () => setIsListening(false);
recognitionRef.current = rec;
rec.start();
setIsListening(true);
}, [language, transcript]);
const submitAnswer = useCallback(() => {
if (!transcript.trim()) return;
stopListening();
const userMsg = { role: 'user', content: transcript.trim(), timestamp: new Date().toISOString() };
const newMessages = [...messages, userMsg];
setMessages(newMessages);
setTranscript('');
setTimeout(() => askNext(newMessages), 300);
}, [transcript, messages, stopListening, askNext]);
const skipAnswer = useCallback(() => {
stopListening();
const userMsg = { role: 'user', content: language === 'es' ? '(Prefiero saltar esta)' : "(I'd rather skip this one)", timestamp: new Date().toISOString(), skipped: true };
const newMessages = [...messages, userMsg];
setMessages(newMessages);
setTranscript('');
setTimeout(() => askNext(newMessages), 250);
}, [messages, stopListening, askNext, language]);
const normalizeAvail = (arr) => {
if (!Array.isArray(arr)) return [];
return arr.map(a => {
const hit = AVAIL_TOKENS.find(t => t.toLowerCase() === String(a).toLowerCase());
return hit || a;
});
};
const finishChat = useCallback(async (finalMessages) => {
setPhase('building');
stopListening();
if (window.speechSynthesis) window.speechSynthesis.cancel();
// Only send real (non-skipped) answers to the profile builder.
const realMessages = (finalMessages || []).filter(m => !(m.role === 'user' && m.skipped));
const realAnswers = realMessages.filter(m => m.role === 'user');
if (realAnswers.length === 0) {
setPhase('notenough');
return;
}
try {
const result = await buildProfileFromConversation(realMessages, workerName, language);
const patch = {
career_goals: result.career_goals || '',
desired_position: result.desired_position || '',
current_position: result.current_position || '',
experience_years: result.experience_years || 0,
experience: result.experience || [],
skills: result.skills || [],
languages: result.languages || [],
availability: normalizeAvail(result.availability),
transportation: result.transportation || '',
certifications: result.certifications || [],
personality: result.personality || '',
strengths: result.strengths || [],
weaknesses: result.weaknesses || [],
industries: result.industries || [],
salary_expectations: result.salary_expectations || '',
leadership_potential: result.leadership_potential || 0,
communication_style: result.communication_style || '',
};
const merged = { ...(profile || {}), ...patch };
const fullPatch = { ...patch, ...recalcProfilePatch(merged) };
if (profile?.id) {
await updateProfile.mutateAsync({ id: profile.id, data: fullPatch });
}
setExtracted({ ...result, availability: normalizeAvail(result.availability) });
setPhase('done');
} catch (e) {
setError('Owliver could not finish building your profile. Try again.');
setPhase('active');
}
}, [workerName, profile, updateProfile, language, stopListening]);
const startChat = useCallback(() => {
setPhase('active');
setTimeLeft(TOTAL_SECONDS);
setMessages([]);
askNext([]);
}, [askNext]);
// Timer
useEffect(() => {
if (phase !== 'active') return;
timerRef.current = setInterval(() => {
setTimeLeft((prev) => {
if (prev <= 1) {
clearInterval(timerRef.current);
stopListening();
setMessages((cur) => { if (cur.length > 0) finishChat(cur); return cur; });
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(timerRef.current);
}, [phase, finishChat, stopListening]);
// Cleanup on unmount
useEffect(() => {
return () => {
stopListening();
if (window.speechSynthesis) window.speechSynthesis.cancel();
if (timerRef.current) clearInterval(timerRef.current);
};
}, [stopListening]);
if (isLoading) {
return <div className="flex justify-center py-24"><Loader2 className="w-6 h-6 animate-spin text-[#0A39DF]" /></div>;
}
return (
<div className={cn('max-w-2xl mx-auto space-y-6', compact && 'h-full flex flex-col')}>
{/* Hero — page only */}
{!compact && (
<div className="rounded-2xl bg-[#0A39DF] p-6 sm:p-8 text-white">
<span className="inline-flex items-center gap-1.5 text-[11px] font-medium tracking-wide opacity-90 bg-white/15 px-2.5 py-1 rounded-full">
🦉 {t.badge}
</span>
<h1 className="text-[26px] sm:text-[30px] font-bold mt-3">{t.title}</h1>
<p className="text-[13px] opacity-90 mt-1.5 max-w-md">
{t.subtitle}
</p>
</div>
)}
{/* Intro */}
{phase === 'intro' && (
compact ? (
<div className="flex flex-col h-full justify-between">
<p className="text-[13px] text-[#6B7280]">{t.introP}</p>
<div className="space-y-3 mt-4">
<div className="flex items-center justify-between flex-wrap gap-3">
<button onClick={() => setTtsEnabled(!ttsEnabled)} className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[#0A39DF]">
{ttsEnabled ? <Volume2 className="w-4 h-4" /> : <VolumeX className="w-4 h-4" />} {ttsEnabled ? t.speaks : t.muted}
</button>
<div className="inline-flex rounded-full border border-[#E5E7EB] overflow-hidden">
<button onClick={() => setLanguage('en')} className={cn('px-3 py-1 text-[12px] font-semibold', language === 'en' ? 'bg-[#0A39DF] text-white' : 'bg-white text-[#6B7280]')}>EN</button>
<button onClick={() => setLanguage('es')} className={cn('px-3 py-1 text-[12px] font-semibold', language === 'es' ? 'bg-[#0A39DF] text-white' : 'bg-white text-[#6B7280]')}>ES</button>
</div>
</div>
<Button onClick={startChat} className="w-full h-12 text-[14px] bg-[#0A39DF] hover:bg-[#082EB4]">
<Mic className="w-4 h-4 mr-2" /> {t.start}
</Button>
</div>
</div>
) : (
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-6 space-y-5">
<div className="flex items-center gap-4">
<OwliverAvatar className="w-16 h-16" />
<div>
<h2 className="text-[16px] font-semibold text-[#111827]">{t.hi(workerName.split(' ')[0])}</h2>
<p className="text-[13px] text-[#6B7280]">{t.introP}</p>
</div>
</div>
<div className="flex items-center gap-2 text-[12px] text-[#6B7280] bg-[#F9FAFB] rounded-lg px-3 py-2.5">
<Sparkles className="w-4 h-4 text-[#F59E0B] shrink-0" />
<span>{t.banner}</span>
</div>
<div className="flex items-center justify-between flex-wrap gap-3">
<button onClick={() => setTtsEnabled(!ttsEnabled)} className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[#0A39DF]">
{ttsEnabled ? <Volume2 className="w-4 h-4" /> : <VolumeX className="w-4 h-4" />} {ttsEnabled ? t.speaks : t.muted}
</button>
<div className="inline-flex rounded-full border border-[#E5E7EB] overflow-hidden">
<button onClick={() => setLanguage('en')} className={cn('px-3 py-1 text-[12px] font-semibold', language === 'en' ? 'bg-[#0A39DF] text-white' : 'bg-white text-[#6B7280]')}>EN</button>
<button onClick={() => setLanguage('es')} className={cn('px-3 py-1 text-[12px] font-semibold', language === 'es' ? 'bg-[#0A39DF] text-white' : 'bg-white text-[#6B7280]')}>ES</button>
</div>
</div>
<Button onClick={startChat} className="w-full h-12 text-[14px] bg-[#0A39DF] hover:bg-[#082EB4]">
<Mic className="w-4 h-4 mr-2" /> {t.start}
</Button>
</div>
)
)}
{/* Active conversation */}
{phase === 'active' && (
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-5 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<OwliverAvatar className="w-9 h-9" rounded="rounded-xl" />
<span className="text-[14px] font-semibold text-[#111827]">Owliver</span>
</div>
<span className="text-[12px] font-medium text-[#6B7280] px-2.5 py-1 rounded-lg bg-[#F3F4F6]">
{t.question} {Math.min(messages.filter(m => m.role === 'assistant').length, QUESTION_COUNT)} {t.of} {QUESTION_COUNT}
</span>
</div>
<div className="h-1.5 w-full rounded-full bg-[#F3F4F6] overflow-hidden">
<div className="h-full bg-[#0A39DF] transition-all duration-500" style={{ width: `${Math.min(100, (messages.filter(m => m.role === 'user').length / QUESTION_COUNT) * 100)}%` }} />
</div>
<div ref={scrollRef} className="h-[320px] overflow-y-auto space-y-3 p-1">
{messages.map((msg, i) => (
<div key={i} className={cn('flex', msg.role === 'user' ? 'justify-end' : 'justify-start')}>
{msg.skipped ? (
<span className="text-[11px] text-[#9CA3AF] italic px-1">{t.skipped}</span>
) : (
<div className={cn(
'max-w-[82%] rounded-2xl px-4 py-2.5 text-[14px] leading-snug',
msg.role === 'user' ? 'bg-[#0A39DF] text-white rounded-br-sm' : 'bg-[#F3F4F6] text-[#111827] rounded-bl-sm'
)}>
<p>{msg.content}</p>
</div>
)}
</div>
))}
{isSpeaking && (
<div className="flex justify-start">
<div className="bg-[#F3F4F6] rounded-2xl rounded-bl-sm px-4 py-3 flex gap-1">
<span className="w-2 h-2 bg-[#9CA3AF] rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-2 h-2 bg-[#9CA3AF] rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-2 h-2 bg-[#9CA3AF] rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
</div>
)}
</div>
{error && <p className="text-[12px] text-[#EF4444]">{error}</p>}
<div className="border-t border-[#E5E7EB] pt-3">
<textarea
value={transcript}
onChange={(e) => setTranscript(e.target.value)}
placeholder={isListening ? t.listening : t.placeholder}
className="w-full min-h-[60px] p-3 text-[14px] rounded-lg border border-[#E5E7EB] focus:border-[#0A39DF] focus:outline-none resize-none"
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submitAnswer(); } }}
/>
<div className="flex items-center justify-between gap-2 mt-2">
<button
onClick={isListening ? stopListening : startListening}
className={cn(
'inline-flex items-center gap-2 px-4 py-2.5 rounded-xl text-[13px] font-semibold transition-colors',
isListening ? 'bg-[#FEE2E2] text-[#991B1B]' : 'bg-[#0A39DF] text-white hover:bg-[#082EB4]'
)}
>
{isListening ? <MicOff className="w-4 h-4" /> : <Mic className="w-4 h-4" />}
{isListening ? t.stop : t.speak}
</button>
<div className="flex items-center gap-2">
<button
onClick={skipAnswer}
className="inline-flex items-center gap-1.5 px-3 py-2.5 rounded-xl text-[13px] font-medium text-[#6B7280] hover:bg-[#F3F4F6] transition-colors"
>
{t.skip} <ArrowRight className="w-3.5 h-3.5" />
</button>
<Button onClick={submitAnswer} disabled={!transcript.trim()} className="bg-[#0A39DF] hover:bg-[#082EB4]">
<Send className="w-4 h-4 mr-1.5" /> {t.send}
</Button>
</div>
</div>
</div>
</div>
)}
{/* Not enough info */}
{phase === 'notenough' && (
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-8 text-center space-y-4">
<OwliverAvatar className="w-16 h-16 mx-auto" />
<h2 className="text-[18px] font-semibold text-[#111827]">{t.notEnoughTitle}</h2>
<p className="text-[13px] text-[#6B7280] max-w-sm mx-auto">{t.notEnoughBody}</p>
<div className="flex flex-col sm:flex-row gap-2 justify-center pt-2">
<Button onClick={startChat} className="bg-[#0A39DF] hover:bg-[#082EB4]">
<RefreshCw className="w-4 h-4 mr-2" /> {t.chatAgain}
</Button>
<Button variant="outline" onClick={() => navigate('/me')}>
{t.goToProfile} <ArrowRight className="w-4 h-4 ml-1" />
</Button>
</div>
</div>
)}
{/* Building */}
{phase === 'building' && (
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-10 text-center">
<OwliverAvatar className="w-20 h-20 mx-auto mb-4 animate-pulse" />
<Loader2 className="w-7 h-7 text-[#0A39DF] animate-spin mx-auto mb-3" />
<p className="text-[14px] font-medium text-[#111827]">{t.building1}</p>
<p className="text-[12px] text-[#6B7280] mt-1">{t.building2}</p>
</div>
)}
{/* Done */}
{phase === 'done' && extracted && (
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-6 space-y-5">
<div className="text-center">
<OwliverAvatar className="w-16 h-16 mx-auto mb-2" />
<h2 className="text-[18px] font-semibold text-[#111827]">{t.done}</h2>
{extracted.summary && <p className="text-[13px] text-[#6B7280] mt-1.5 italic">"{extracted.summary}"</p>}
</div>
<div className="grid grid-cols-2 gap-3 text-[12px]">
<SummaryField label={t.labels.desired} value={extracted.desired_position} empty={t.dash} />
<SummaryField label={t.labels.experience} value={extracted.experience_years ? `${extracted.experience_years} ${t.yrs}` : ''} empty={t.dash} />
<SummaryField label={t.labels.transport} value={extracted.transportation} empty={t.dash} />
<SummaryField label={t.labels.salary} value={extracted.salary_expectations} empty={t.dash} />
<SummaryField label={t.labels.leadership} value={extracted.leadership_potential ? `${extracted.leadership_potential}/100` : ''} full empty={t.dash} />
<SummaryField label={t.labels.skills} value={(extracted.skills || []).join(', ')} full empty={t.dash} />
<SummaryField label={t.labels.languages} value={(extracted.languages || []).join(', ')} full empty={t.dash} />
<SummaryField label={t.labels.industries} value={(extracted.industries || []).join(', ')} full empty={t.dash} />
<SummaryField label={t.labels.availability} value={(extracted.availability || []).join(', ')} full empty={t.dash} />
<SummaryField label={t.labels.strengths} value={(extracted.strengths || []).join(', ')} full empty={t.dash} />
<SummaryField label={t.labels.workingOn} value={(extracted.weaknesses || []).join(', ')} full empty={t.dash} />
<SummaryField label={t.labels.personality} value={extracted.personality} full empty={t.dash} />
</div>
{extracted.career_dna && (
<div className="rounded-xl border border-[#0A39DF]/15 bg-[#EFF6FF]/60 p-4 space-y-3">
<div>
<p className="text-[13px] font-bold text-[#0A39DF] flex items-center gap-1.5"><Sparkles className="w-3.5 h-3.5" /> {t.dnaHeader}</p>
<p className="text-[11px] text-[#6B7280] mt-0.5">{t.dnaSub}</p>
</div>
{extracted.career_dna.identity && (
<p className="text-[13px] font-medium text-[#0F172A] italic">"{extracted.career_dna.identity}"</p>
)}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
<DnaScore label={t.labels.learningSpeed} value={extracted.career_dna.learning_speed} />
<DnaScore label={t.labels.reliability} value={extracted.career_dna.reliability} />
<DnaScore label={t.labels.communication} value={extracted.career_dna.communication_score} />
<DnaScore label={t.labels.leadershipIndex} value={extracted.career_dna.leadership_index} />
<DnaScore label={t.labels.adaptability} value={extracted.career_dna.adaptability_score} />
<DnaScore label={t.labels.reputation} value={extracted.career_dna.reputation_score} />
</div>
{extracted.career_dna.work_style && (
<SummaryField label={t.labels.workStyle} value={extracted.career_dna.work_style} full empty={t.dash} />
)}
{(extracted.career_dna.culture_match || []).length > 0 && (
<SummaryField label={t.labels.cultureMatch} value={extracted.career_dna.culture_match.join(', ')} full empty={t.dash} />
)}
</div>
)}
<Button onClick={() => navigate('/me')} className="w-full h-11 bg-[#0A39DF] hover:bg-[#082EB4]">
<Sparkles className="w-4 h-4 mr-2" /> {t.seeId} <ArrowRight className="w-4 h-4 ml-1" />
</Button>
</div>
)}
</div>
);
}
function SummaryField({ label, value, full, empty = '—' }) {
const has = value && String(value).trim();
return (
<div className={cn('rounded-lg bg-[#F9FAFB] p-2.5', full && 'col-span-2')}>
<div className="text-[10px] text-[#6B7280] uppercase tracking-wide">{label}</div>
<div className="text-[12px] text-[#111827] font-medium mt-0.5">{has ? value : empty}</div>
</div>
);
}
function DnaScore({ label, value }) {
const v = typeof value === 'number' ? value : 0;
const tone = v >= 75 ? 'text-[#16A34A]' : v >= 50 ? 'text-[#0A39DF]' : v > 0 ? 'text-[#D97706]' : 'text-[#9CA3AF]';
return (
<div className="rounded-lg bg-white p-2.5 border border-[#E5E7EB]">
<div className="text-[10px] text-[#6B7280] uppercase tracking-wide">{label}</div>
<div className={cn('text-[14px] font-bold mt-0.5', tone)}>{v > 0 ? v : '—'}</div>
</div>
);
}

View File

@@ -0,0 +1,307 @@
import React, { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useJobPosting, useApplications, useScreenAllCandidates, useUpdateJobPosting, useHireCandidate, useUpdateApplication, useCreateApplication } from '@/lib/krowHooks';
import { base44 } from '@/api/base44Client';
import CandidateCard from '@/components/krow/CandidateCard';
import AIInterviewModal from '@/components/krow/AIInterviewModal';
import ScheduleInterviewModal from '@/components/krow/ScheduleInterviewModal';
import MessageCandidateModal from '@/components/krow/MessageCandidateModal';
import InviteApplicantsModal from '@/components/krow/InviteApplicantsModal';
import SuggestedTalent from '@/components/krow/SuggestedTalent';
import { toast } from 'react-hot-toast';
import { cn } from '@/lib/utils';
import { UserPlus } from 'lucide-react';
const STATUS_ACTIONS = [
{ status: 'active', label: 'Hiring', color: 'bg-[#0A39DF] text-white shadow-sm' },
{ status: 'paused', label: 'Paused', color: 'bg-[#0A39DF] text-white shadow-sm' },
{ status: 'closed', label: 'Closed', color: 'bg-[#0A39DF] text-white shadow-sm' },
];
export default function PositionDetail() {
const { id } = useParams();
const navigate = useNavigate();
const { data: job, isLoading } = useJobPosting(id);
const { data: applications = [] } = useApplications(id);
const screenAll = useScreenAllCandidates();
const updateJob = useUpdateJobPosting();
const hireCandidate = useHireCandidate();
const updateApp = useUpdateApplication();
const createApp = useCreateApplication();
const [interviewApp, setInterviewApp] = useState(null);
const [scheduleApp, setScheduleApp] = useState(null);
const [messageApp, setMessageApp] = useState(null);
const [inviteOpen, setInviteOpen] = useState(false);
// Turn a matched pool talent into an applicant so they enter the pipeline
const admitTalent = async (profile, status = 'applied') => {
const existing = jobApps.find((a) => a.email && profile.email && a.email.toLowerCase() === profile.email.toLowerCase());
if (existing) return existing;
return createApp.mutateAsync({
applicant_name: profile.full_name,
email: profile.email || '',
phone: profile.phone || '',
years_experience: profile.experience_years || 0,
skills: profile.skills || [],
availability: profile.availability || [],
certifications: profile.certifications || [],
companies_worked: (profile.experience || []).map((e) => e.company).filter(Boolean),
selfie_url: profile.selfie_url || '',
professional_summary: profile.personality || profile.career_goals || '',
cover_letter: '',
job_posting_id: id,
job_title: job.title,
status,
ai_score: profile.krow_score || 0,
});
};
const handleSuggestedContact = async (profile) => {
try {
const app = await admitTalent(profile);
setMessageApp(app);
} catch (e) {
toast.error('Could not open talent');
}
};
const handleSuggestedSchedule = async (profile) => {
try {
const app = await admitTalent(profile, 'interview');
setScheduleApp(app);
} catch (e) {
toast.error('Could not schedule talent');
}
};
const handleSuggestedHire = async (profile) => {
try {
const app = await admitTalent(profile, 'interview');
await hireCandidate.mutateAsync({ application: app, job });
toast.success(`${profile.full_name} hired from the talent pool!`);
} catch (e) {
toast.error('Failed to hire');
}
};
const handleSuggestedHireWrapped = (profile) => handleSuggestedHire(profile);
const jobApps = applications.filter(a => a.job_posting_id === id);
const sortedApps = [...jobApps].sort((a, b) => (b.ai_score || 0) - (a.ai_score || 0));
const stats = {
applied: jobApps.length,
screened: jobApps.filter(a => a.status !== 'applied').length,
hired: jobApps.filter(a => a.status === 'hired').length,
};
const handleStatusChange = async (newStatus) => {
try {
await updateJob.mutateAsync({ id, data: { status: newStatus } });
toast.success(`Position ${newStatus === 'active' ? 'is now hiring' : newStatus}`);
} catch (e) {
toast.error('Failed to update status');
}
};
const handleScreenAll = async () => {
const unscreened = jobApps.filter(a => a.status === 'applied');
if (unscreened.length === 0) {
toast('All applicants have been screened');
return;
}
try {
await screenAll.mutateAsync({ applications: unscreened, job });
toast.success(`Screened ${unscreened.length} applicants`);
} catch (e) {
toast.error('Screening failed');
}
};
const handleHire = async (app) => {
try {
await hireCandidate.mutateAsync({ application: app, job });
toast.success(`${app.applicant_name} hired! Staff record created.`);
} catch (e) {
toast.error('Failed to hire');
}
};
const handleAction = (app) => {
if (app.status === 'interview') {
setInterviewApp(app);
} else {
setScheduleApp(app);
}
};
const handleDecline = async (app) => {
try {
await updateApp.mutateAsync({ id: app.id, data: { status: 'rejected' } });
toast.success(`${app.applicant_name} declined`);
} catch (e) {
toast.error('Failed to decline candidate');
}
};
const handleDelete = async (app) => {
try {
await base44.entities.JobApplication.delete(app.id);
toast.success(`${app.applicant_name} deleted`);
} catch (e) {
toast.error('Failed to delete candidate');
}
};
if (isLoading) {
return <div className="flex items-center justify-center py-20"><div className="w-6 h-6 border-3 border-[#E5E7EB] border-t-[#0A39DF] rounded-full animate-spin" /></div>;
}
if (!job) {
return <div className="text-center py-20"><p className="text-[14px] text-[#6B7280]">Position not found</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
</button>
{/* Header Card */}
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-6">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h1 className="text-[22px] font-bold text-[#111827]">{job.title}</h1>
{job.ai_generated && (
<span className="px-2 py-0.5 rounded-full bg-[#EFF6FF] text-[#0A39DF] text-[11px] font-medium">
AI Built
</span>
)}
</div>
<div className="flex items-center gap-4 text-[13px] text-[#6B7280] flex-wrap">
<span className="capitalize">{job.role_category}</span>
{job.location && <span>{job.location}</span>}
{job.pay_range_min != null && <span className="font-medium text-[#111827]">${job.pay_range_min}–{job.pay_range_max}/hr</span>}
</div>
</div>
{/* Status controls */}
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={() => setInviteOpen(true)}
className="inline-flex items-center gap-1.5 bg-[#F9E547] text-[#0A39DF] 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
</button>
{STATUS_ACTIONS.map(s => (
<button
key={s.status}
onClick={() => handleStatusChange(s.status)}
disabled={updateJob.isPending}
className={cn(
'px-4 py-1.5 rounded-full text-[12px] font-semibold transition-all disabled:opacity-50',
job.status === s.status
? s.color
: 'border border-[#E5E7EB] bg-white text-[#6B7280] hover:border-[#0A39DF] hover:text-[#0A39DF]'
)}
>
{s.label}
</button>
))}
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-4 mt-5 pt-5 border-t border-[#F3F4F6]">
<div>
<div className="text-2xl font-bold text-[#111827]">{stats.applied}</div>
<div className="text-[11px] text-[#9CA3AF] tracking-wide">APPLIED</div>
</div>
<div>
<div className="text-2xl font-bold text-[#111827]">{stats.screened}</div>
<div className="text-[11px] text-[#9CA3AF] tracking-wide">SCREENED</div>
</div>
<div>
<div className="text-2xl font-bold text-[#0A39DF]">{stats.hired}</div>
<div className="text-[11px] text-[#9CA3AF] tracking-wide">HIRED</div>
</div>
</div>
</div>
{/* Suggested Talent — proactive matching, no waiting for applications */}
{job.status === 'active' && (
<SuggestedTalent
job={job}
onContact={handleSuggestedContact}
onSchedule={handleSuggestedSchedule}
onHire={handleSuggestedHireWrapped}
/>
)}
{/* AI Description */}
{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
</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>
<ul className="text-[13px] text-[#6B7280] list-disc list-inside space-y-1">
{job.responsibilities.map((r, i) => <li key={i}>{r}</li>)}
</ul>
</div>
)}
</div>
)}
{/* 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>
<button
onClick={handleScreenAll}
disabled={screenAll.isPending}
className="inline-flex items-center gap-1.5 bg-[#0A39DF] hover:bg-[#082EB4] disabled:opacity-50 text-white text-[12px] font-medium px-3 py-1.5 rounded-lg transition-colors"
>
{screenAll.isPending ? 'Screening...' : 'AI Screen All'}
</button>
</div>
{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>
</div>
) : (
<div className="space-y-3">
{sortedApps.map((app, idx) => (
<CandidateCard
key={app.id}
application={app}
rank={idx + 1}
onAction={handleAction}
onMessage={setMessageApp}
onCall={setMessageApp}
onSchedule={setScheduleApp}
onDecline={handleDecline}
onDelete={handleDelete}
onHire={handleHire}
/>
))}
</div>
)}
</div>
{/* Modals */}
{interviewApp && <AIInterviewModal open={true} onClose={() => setInterviewApp(null)} application={interviewApp} job={job} />}
{scheduleApp && <ScheduleInterviewModal open={true} onClose={() => setScheduleApp(null)} application={scheduleApp} />}
{messageApp && <MessageCandidateModal open={true} onClose={() => setMessageApp(null)} application={messageApp} />}
{inviteOpen && <InviteApplicantsModal open={true} onClose={() => setInviteOpen(false)} job={job} />}
</div>
);
}

95
src/pages/Positions.jsx Normal file
View File

@@ -0,0 +1,95 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Search, Plus } from 'lucide-react';
import { useJobPostings, useApplications } from '@/lib/krowHooks';
import { base44 } from '@/api/base44Client';
import KROWJobPostingCard from '@/components/krow/KROWJobPostingCard';
import CertificationManager from '@/components/krow/CertificationManager';
export default function Positions() {
const navigate = useNavigate();
const { data: postings = [], isLoading } = useJobPostings();
const { data: applications = [] } = useApplications();
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [isAdmin, setIsAdmin] = useState(false);
useEffect(() => {
base44.auth.me().then(u => setIsAdmin(u?.role === 'admin')).catch(() => {});
}, []);
const filtered = postings.filter(p => {
const matchesSearch = !search || p.title.toLowerCase().includes(search.toLowerCase()) || p.role_category?.toLowerCase().includes(search.toLowerCase());
const matchesStatus = statusFilter === 'all' || p.status === statusFilter;
return matchesSearch && matchesStatus;
});
const statsForPosting = (postingId) => {
const apps = applications.filter(a => a.job_posting_id === postingId);
return {
applied: apps.length,
screened: apps.filter(a => a.status !== 'applied').length,
hired: apps.filter(a => a.status === 'hired').length,
};
};
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>
</div>
{/* Action Bar */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#9CA3AF]" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search positions..."
className="w-full h-10 pl-10 pr-4 rounded-lg border border-[#E5E7EB] bg-white text-[13px] focus:border-[#2563EB] focus:outline-none"
/>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="h-10 px-4 rounded-lg border border-[#E5E7EB] bg-white text-[13px] focus:border-[#2563EB] focus:outline-none"
>
<option value="all">All Statuses</option>
<option value="active">Hiring</option>
<option value="draft">Draft</option>
<option value="paused">Paused</option>
<option value="closed">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-10 rounded-lg transition-colors whitespace-nowrap"
>
<Plus className="w-4 h-4" /> New Position
</button>
</div>
{/* Grid */}
{isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[...Array(6)].map((_, i) => <div key={i} className="h-48 bg-white border border-[#E5E7EB] rounded-xl animate-pulse" />)}
</div>
) : filtered.length === 0 ? (
<div className="text-center py-16">
<p className="text-[14px] text-[#6B7280] mb-3">No positions found</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
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filtered.map(posting => (
<KROWJobPostingCard key={posting.id} posting={posting} stats={statsForPosting(posting.id)} />
))}
</div>
)}
</div>
);
}

130
src/pages/Profile.jsx Normal file
View File

@@ -0,0 +1,130 @@
import React, { useEffect, useState } from 'react';
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';
const EVENT_LABELS = {
signup: 'Signed up',
login: 'Logged in',
logout: 'Logged out',
create_position: 'Created a position',
screen_candidate: 'Screened a candidate',
hire_candidate: 'Hired a candidate',
apply_job: 'Applied for a job',
start_interview: 'Started an interview',
};
export default function Profile() {
const [user, setUser] = useState(null);
const [activities, setActivities] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
(async () => {
try {
const me = await base44.auth.me();
setUser(me);
const acts = await base44.entities.UserActivity.filter(
{ user_email: me.email },
'-created_date',
20
);
setActivities(acts);
} catch {}
setLoading(false);
})();
}, []);
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<div className="w-8 h-8 border-4 border-slate-200 border-t-[#0A39DF] rounded-full animate-spin" />
</div>
);
}
if (!user) {
return <div className="text-center py-20 text-[#6B7280]">Unable to load profile.</div>;
}
const initials = (user.full_name || user.email || 'U')
.split(' ')
.map((p) => p[0])
.join('')
.slice(0, 2)
.toUpperCase();
return (
<div className="max-w-3xl mx-auto">
{/* Profile header */}
<div className="flex items-center gap-4 mb-8">
<div className="w-20 h-20 rounded-full bg-[#0A39DF] flex items-center justify-center text-white text-2xl font-bold">
{initials}
</div>
<div>
<h1 className="text-2xl font-bold text-[#111827]">{user.full_name || 'User'}</h1>
<p className="text-[#6B7280]">{user.email}</p>
</div>
</div>
{/* Info grid */}
<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
</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
</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
</div>
<div className="text-[#111827] font-semibold">
{user.created_date ? new Date(user.created_date).toLocaleDateString() : '—'}
</div>
</Card>
</div>
{/* 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
</h2>
{activities.length === 0 ? (
<p className="text-[#9CA3AF] text-sm">No recent activity.</p>
) : (
<div className="space-y-1">
{activities.map((a) => (
<div key={a.id} className="flex items-center justify-between py-2.5 border-b border-[#F3F4F6]">
<div>
<span className="text-[#111827] text-sm font-medium">{EVENT_LABELS[a.event_type] || a.event_type}</span>
{a.details && <span className="text-[#9CA3AF] text-xs ml-2">{a.details}</span>}
</div>
<span className="text-[#9CA3AF] text-xs whitespace-nowrap ml-4">
{a.created_date ? new Date(a.created_date).toLocaleString() : ''}
</span>
</div>
))}
</div>
)}
</div>
{/* Logout */}
<Button
onClick={() => base44.auth.logout('/')}
variant="outline"
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
</Button>
</div>
);
}

129
src/pages/TalentPool.jsx Normal file
View File

@@ -0,0 +1,129 @@
import React, { useState } from 'react';
import { useWorkerProfiles } from '@/lib/krowHooks';
import TalentPoolCard from '@/components/krow/TalentPoolCard';
import TalentDetailModal from '@/components/krow/TalentDetailModal';
import MessageCandidateModal from '@/components/krow/MessageCandidateModal';
import { Users } from 'lucide-react';
export default function TalentPool() {
const { data: profiles = [], isLoading } = useWorkerProfiles();
const [search, setSearch] = useState('');
const [scoreFilter, setScoreFilter] = useState('all');
const [contactProfile, setContactProfile] = useState(null);
const [viewProfile, setViewProfile] = useState(null);
const filtered = profiles.filter((p) => {
const q = search.toLowerCase();
const matchesSearch = !search ||
p.full_name?.toLowerCase().includes(q) ||
p.email?.toLowerCase().includes(q) ||
(p.skills || []).some((s) => s.toLowerCase().includes(q)) ||
p.desired_position?.toLowerCase().includes(q) ||
p.current_position?.toLowerCase().includes(q);
const score = p.krow_score || 0;
const matchesScore =
scoreFilter === 'all' ||
(scoreFilter === 'elite' && score >= 90) ||
(scoreFilter === 'excellent' && score >= 75 && score < 90) ||
(scoreFilter === 'solid' && score >= 60 && score < 75) ||
(scoreFilter === 'building' && score > 0 && score < 60);
return matchesSearch && matchesScore;
});
const bandCounts = {
elite: profiles.filter((p) => (p.krow_score || 0) >= 90).length,
excellent: profiles.filter((p) => (p.krow_score || 0) >= 75 && (p.krow_score || 0) < 90).length,
solid: profiles.filter((p) => (p.krow_score || 0) >= 60 && (p.krow_score || 0) < 75).length,
building: profiles.filter((p) => (p.krow_score || 0) > 0 && (p.krow_score || 0) < 60).length,
};
const contactApp = contactProfile
? { applicant_name: contactProfile.full_name, email: contactProfile.email, phone: contactProfile.phone }
: null;
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-9 h-9 rounded-xl bg-[#EFF6FF] flex items-center justify-center">
<Users className="w-5 h-5 text-[#0A39DF]" />
</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>
</div>
</div>
<span className="text-[13px] text-[#6B7280]">{profiles.length} talents</span>
</div>
{/* Band summary */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[
{ key: 'elite', label: 'Elite', color: '#0A39DF' },
{ key: 'excellent', label: 'Excellent', color: '#16A34A' },
{ key: 'solid', label: 'Solid', color: '#2563EB' },
{ key: 'building', label: 'Building', color: '#D97706' },
].map((b) => (
<div key={b.key} className="bg-white border border-[#E5E7EB] rounded-xl p-3">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: b.color }} />
<span className="text-[11px] text-[#6B7280] font-medium">{b.label}</span>
</div>
<p className="text-[22px] font-bold text-[#111827] mt-1">{bandCounts[b.key]}</p>
</div>
))}
</div>
{/* Filters */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3">
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by name, skill, or desired role..."
className="flex-1 h-10 px-4 rounded-lg border border-[#E5E7EB] bg-white text-[13px] focus:border-[#0A39DF] focus:outline-none"
/>
<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-[#0A39DF] 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>
</select>
</div>
{/* List */}
{isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{[...Array(6)].map((_, i) => <div key={i} className="h-36 bg-white border border-[#E5E7EB] rounded-xl animate-pulse" />)}
</div>
) : filtered.length === 0 ? (
<div className="text-center py-16">
<p className="text-[14px] text-[#6B7280]">No talent matches your filters</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{filtered.map((p) => (
<TalentPoolCard key={p.id} profile={p} onContact={setContactProfile} onView={setViewProfile} />
))}
</div>
)}
{viewProfile && (
<TalentDetailModal
open={true}
onClose={() => setViewProfile(null)}
profile={viewProfile}
onContact={(profile) => { setViewProfile(null); setContactProfile(profile); }}
/>
)}
{contactApp && (
<MessageCandidateModal open={true} onClose={() => setContactProfile(null)} application={contactApp} />
)}
</div>
);
}

131
src/pages/University.jsx Normal file
View File

@@ -0,0 +1,131 @@
import React, { useState, useMemo } from 'react';
import { useCourses, useWorkerProfile, useEvidenceList } from '@/lib/krowHooks';
import UniversityCourseCard from '@/components/krow/UniversityCourseCard';
import CapabilityTimeline from '@/components/krow/proving/CapabilityTimeline';
import BadgeChip from '@/components/krow/BadgeChip';
import { Loader2, Search, Lock } from 'lucide-react';
const DIFFS = [
{ v: 'all', label: 'All' },
{ v: 'beginner', label: 'Beginner' },
{ v: 'intermediate', label: 'Intermediate' },
{ v: 'advanced', label: 'Advanced' },
];
function LockCard({ course, profile }) {
const req = course.unlock_requirements || {};
const shifts = Number(profile?.shifts_completed) || 0;
const rel = Number(profile?.reliability_score) || 0;
const have = (profile?.earned_badges || []).map((b) => b.name);
const bars = [];
if (req.min_shifts) bars.push({ label: 'Shifts', val: Math.min(1, shifts / req.min_shifts), text: `${shifts}/${req.min_shifts}` });
if (req.min_reliability) bars.push({ label: 'Reliability', val: Math.min(1, rel / req.min_reliability), text: `${rel}/${req.min_reliability}` });
(req.required_badges || []).forEach((b) => bars.push({ label: b, val: have.includes(b) ? 1 : 0, text: have.includes(b) ? 'Earned' : 'Missing' }));
return (
<div className="rounded-2xl border border-[#E5E7EB] bg-[#F8FAFC] p-4">
<div className="flex items-center gap-2 mb-3">
<Lock className="w-4 h-4 text-[#94A3B8]" />
<h3 className="text-[14px] font-semibold text-[#0F172A]">{course.title}</h3>
</div>
<div className="space-y-2">
{bars.map((b) => (
<div key={b.label}>
<div className="flex justify-between text-[11px] mb-1">
<span className="text-[#64748B]">{b.label}</span>
<span className={`font-medium ${b.val >= 1 ? 'text-[#16A34A]' : 'text-[#64748B]'}`}>{b.text}</span>
</div>
<div className="h-1.5 rounded-full bg-[#E2E8F0] overflow-hidden">
<div className={`h-full rounded-full ${b.val >= 1 ? 'bg-[#16A34A]' : 'bg-[#0A39DF]'}`} style={{ width: `${Math.round(b.val * 100)}%` }} />
</div>
</div>
))}
</div>
</div>
);
}
export default function University() {
const { data: profile } = useWorkerProfile();
const { data: courses = [], isLoading: loadingCourses } = useCourses();
const { data: evidence = [] } = useEvidenceList(profile?.email);
const [search, setSearch] = useState('');
const [diffFilter, setDiffFilter] = useState('all');
const completedIds = useMemo(() => new Set((profile?.completed_courses || []).map((c) => c.course_id)), [profile]);
const activeCourses = courses.filter((c) => c.status === 'active');
const filtered = activeCourses.filter((c) => {
const ms = !search || c.title?.toLowerCase().includes(search.toLowerCase()) || c.category?.toLowerCase().includes(search.toLowerCase());
const md = diffFilter === 'all' || c.difficulty === diffFilter;
return ms && md;
});
const lockedGoals = activeCourses.filter((c) => c.unlock_requirements && !completedIds.has(c.id));
const evidenceBySkill = (evidence || []).reduce((m, e) => { (m[e.skill] = m[e.skill] || []).push(e); return m; }, {});
if (!profile) return <div className="flex justify-center py-20"><Loader2 className="w-6 h-6 animate-spin text-[#0A39DF]" /></div>;
return (
<div className="max-w-5xl mx-auto">
<CapabilityTimeline profile={profile} compact />
{/* Badges */}
{profile.earned_badges?.length > 0 && (
<section className="pt-6">
<div className="flex items-center justify-between mb-3">
<h2 className="text-[15px] font-semibold text-[#0F172A]">Badges with proof</h2>
<span className="text-[11px] text-[#94A3B8]">{profile.earned_badges.length} earned</span>
</div>
<div className="flex flex-wrap gap-2">
{profile.earned_badges.map((b, i) => {
const proofCount = (evidenceBySkill[b.name] || (evidenceBySkill[Object.keys(evidenceBySkill).find((k) => k.toLowerCase().includes((b.name || '').toLowerCase().split(' ')[0]))] || [])).length;
return <BadgeChip key={i} name={`${b.name}${proofCount ? ` · ${proofCount} proof${proofCount > 1 ? 's' : ''}` : ''}`} level={b.level} earned_date={b.earned_date} />;
})}
</div>
</section>
)}
{/* Locked goals */}
{lockedGoals.length > 0 && (
<section className="pt-8">
<h2 className="text-[15px] font-semibold text-[#0F172A] mb-3">Earn these next</h2>
<div className="grid sm:grid-cols-2 gap-3">
{lockedGoals.map((c) => <LockCard key={c.id} course={c} profile={profile} />)}
</div>
</section>
)}
{/* Challenges */}
<section className="pt-8">
<div className="flex items-center justify-between gap-3 flex-wrap mb-4">
<h2 className="text-[15px] font-semibold text-[#0F172A]">Proving Challenges</h2>
<div className="flex items-center gap-2">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[#94A3B8]" />
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search" className="pl-8 pr-3 h-9 text-[13px] rounded-full border border-[#E2E8F0] focus:border-[#0A39DF] focus:outline-none w-32" />
</div>
<div className="flex items-center gap-1 bg-[#F1F5F9] rounded-full p-1">
{DIFFS.map((d) => (
<button
key={d.v}
onClick={() => setDiffFilter(d.v)}
className={`px-2.5 h-7 rounded-full text-[11px] font-medium transition-all ${diffFilter === d.v ? 'bg-white text-[#0A39DF] shadow-sm' : 'text-[#64748B] hover:text-[#0F172A]'}`}
>
{d.label}
</button>
))}
</div>
</div>
</div>
{loadingCourses ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">{[...Array(6)].map((_, i) => <div key={i} className="h-36 rounded-2xl bg-[#F1F5F9] animate-pulse" />)}</div>
) : filtered.length === 0 ? (
<p className="text-[13px] text-[#94A3B8] py-10 text-center">No challenges match your filter.</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{filtered.map((c) => <UniversityCourseCard key={c.id} course={c} completed={completedIds.has(c.id)} profile={profile} />)}
</div>
)}
</section>
</div>
);
}

View File

@@ -0,0 +1,36 @@
import React from 'react';
import { useUserActivity } from '@/lib/krowHooks';
import ActivityMetrics from '@/components/krow/ActivityMetrics';
import ActivityCharts from '@/components/krow/ActivityCharts';
import ActivityLogTable from '@/components/krow/ActivityLogTable';
import { Loader2 } from 'lucide-react';
import { AssistantTrigger } from '@/components/ai-assistant';
export default function UserTracking() {
const { data: activities = [], isLoading } = useUserActivity();
if (isLoading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 text-[#9CA3AF] animate-spin" />
</div>
);
}
return (
<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>
<p className="text-[13px] text-[#6B7280] mt-0.5">
Sign-ups, logins, and key actions across your platform
</p>
</div>
<AssistantTrigger />
</div>
<ActivityMetrics activities={activities} />
<ActivityCharts activities={activities} />
<ActivityLogTable activities={activities} />
</div>
);
}

View File

@@ -0,0 +1,26 @@
import React from 'react';
import { useWorkerProfile } from '@/lib/krowHooks';
import IdentityCareerCard from '@/components/krow/talent/IdentityCareerCard';
import CareerPassport from '@/components/krow/talent/CareerPassport';
import { Loader2 } from 'lucide-react';
export default function WorkerProfile() {
const { data: profile, isLoading } = useWorkerProfile();
if (isLoading || !profile) {
return <div className="flex justify-center py-20"><Loader2 className="w-6 h-6 animate-spin text-[#0A39DF]" /></div>;
}
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>
</div>
<IdentityCareerCard profile={profile} />
<div className="mt-6">
<CareerPassport profile={profile} />
</div>
</div>
);
}