Initial commit
This commit is contained in:
163
src/pages/AdminDashboard.jsx
Normal file
163
src/pages/AdminDashboard.jsx
Normal 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
64
src/pages/Analytics.jsx
Normal 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
158
src/pages/Apply.jsx
Normal 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
176
src/pages/Candidates.jsx
Normal 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
114
src/pages/CourseDetail.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
321
src/pages/CreatePosition.jsx
Normal file
321
src/pages/CreatePosition.jsx
Normal 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
754
src/pages/DesignSystem.jsx
Normal 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="{v}"
|
||||
</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' 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>
|
||||
);
|
||||
}
|
||||
38
src/pages/EmployeeDashboard.jsx
Normal file
38
src/pages/EmployeeDashboard.jsx
Normal 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
226
src/pages/HiredHistory.jsx
Normal 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
193
src/pages/KrowIdentity.jsx
Normal 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
200
src/pages/Overview.jsx
Normal 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
563
src/pages/Owliver.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
307
src/pages/PositionDetail.jsx
Normal file
307
src/pages/PositionDetail.jsx
Normal 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
95
src/pages/Positions.jsx
Normal 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
130
src/pages/Profile.jsx
Normal 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
129
src/pages/TalentPool.jsx
Normal 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
131
src/pages/University.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
src/pages/UserTracking.jsx
Normal file
36
src/pages/UserTracking.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
src/pages/WorkerProfile.jsx
Normal file
26
src/pages/WorkerProfile.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user