176 lines
7.4 KiB
JavaScript
176 lines
7.4 KiB
JavaScript
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>
|
|
);
|
|
} |