181 lines
7.2 KiB
JavaScript
181 lines
7.2 KiB
JavaScript
import React, { useMemo, useState } from 'react';
|
||
import { Button, SearchInput } from '@/components/ds';
|
||
import { useApplications, useInterviews, useJobPostings, useUpdateApplication, useHireCandidate } from '@/lib/krowHooks';
|
||
import { base44 } from '@/api/base44Client';
|
||
import { toast } from 'react-hot-toast';
|
||
import { AdminPage, Toolbar } from '@/pages/admin/_shell';
|
||
import { FilterSelect } from '@/pages/admin/Positions';
|
||
import CandidateCard from '@/components/krow/CandidateCard';
|
||
import AIInterviewModal from '@/components/krow/AIInterviewModal';
|
||
import ScheduleInterviewModal from '@/components/krow/ScheduleInterviewModal';
|
||
import MessageCandidateModal from '@/components/krow/MessageCandidateModal';
|
||
|
||
const SCORE_BANDS = {
|
||
all: () => true,
|
||
top: (s) => s >= 80,
|
||
strong: (s) => s >= 60 && s < 80,
|
||
weak: (s) => s > 0 && s < 60,
|
||
unscored: (s) => !s,
|
||
};
|
||
|
||
const SORTS = {
|
||
score: { label: 'Highest score', compare: (a, b) => (b.ai_score || 0) - (a.ai_score || 0) },
|
||
recent: { label: 'Recent activity', compare: (a, b) => new Date(b.updated_date) - new Date(a.updated_date) },
|
||
name: { label: 'Name A–Z', compare: (a, b) => a.applicant_name.localeCompare(b.applicant_name) },
|
||
experience: { label: 'Most experience', compare: (a, b) => (b.years_experience || 0) - (a.years_experience || 0) },
|
||
};
|
||
|
||
export default function AdminCandidates() {
|
||
const { data: applications = [], isLoading } = useApplications();
|
||
const { data: postings = [] } = useJobPostings();
|
||
const { data: interviews = [] } = useInterviews();
|
||
const updateApp = useUpdateApplication();
|
||
const hireCandidate = useHireCandidate();
|
||
|
||
const [search, setSearch] = useState('');
|
||
const [position, setPosition] = useState('all');
|
||
const [stage, setStage] = useState('all');
|
||
const [band, setBand] = useState('all');
|
||
const [sort, setSort] = useState('score');
|
||
|
||
const [interviewApp, setInterviewApp] = useState(null);
|
||
const [scheduleApp, setScheduleApp] = useState(null);
|
||
const [messageApp, setMessageApp] = useState(null);
|
||
|
||
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 handleHire = async (app) => {
|
||
try {
|
||
const job = postings.find(p => p.id === app.job_posting_id);
|
||
await hireCandidate.mutateAsync({ application: app, job });
|
||
toast.success(`${app.applicant_name} hired!`);
|
||
} catch (e) {
|
||
toast.error('Failed to hire candidate');
|
||
}
|
||
};
|
||
|
||
const handleAction = (app) => {
|
||
if (app.status === 'interview') {
|
||
setInterviewApp(app);
|
||
} else {
|
||
setScheduleApp(app);
|
||
}
|
||
};
|
||
|
||
const positions = useMemo(
|
||
() => [...new Set(applications.map((a) => a.job_title).filter(Boolean))].sort(),
|
||
[applications]
|
||
);
|
||
|
||
const filtered = useMemo(() => {
|
||
const q = search.toLowerCase();
|
||
return applications
|
||
.filter((a) => {
|
||
if (q && !`${a.applicant_name} ${a.email} ${a.job_title} ${(a.skills || []).join(' ')}`.toLowerCase().includes(q)) return false;
|
||
if (position !== 'all' && a.job_title !== position) return false;
|
||
if (stage !== 'all' && a.stage !== stage) return false;
|
||
if (band !== 'all' && !SCORE_BANDS[band](a.ai_score)) return false;
|
||
return true;
|
||
})
|
||
.sort(SORTS[sort].compare);
|
||
}, [applications, search, position, stage, band, sort]);
|
||
|
||
const isFiltered = Boolean(search) || [position, stage, band].some((v) => v !== 'all');
|
||
const clearFilters = () => { setSearch(''); setPosition('all'); setStage('all'); setBand('all'); };
|
||
|
||
const postingById = useMemo(() => Object.fromEntries(postings.map(p => [p.id, p])), [postings]);
|
||
const resolveTitle = (a) => a.job_title || postingById[a.job_posting_id]?.title || '';
|
||
|
||
return (
|
||
<AdminPage
|
||
title="Candidates"
|
||
subtitle="Review, compare and manage the candidate pipeline."
|
||
>
|
||
<Toolbar
|
||
search={<SearchInput value={search} onChange={setSearch} placeholder="Search candidates..." size="sm" />}
|
||
filters={
|
||
<>
|
||
<FilterSelect value={position} onChange={setPosition} label="Position"
|
||
options={[{ value: 'all', label: 'All positions' }, ...positions.map((p) => ({ value: p, label: p }))]} />
|
||
<FilterSelect value={stage} onChange={setStage} label="Stage" options={[
|
||
{ value: 'all', label: 'All stages' }, { value: 'applied', label: 'Applied' },
|
||
{ value: 'ai_screened', label: 'AI Screened' }, { value: 'interview', label: 'Interviewing' },
|
||
{ value: 'hired', label: 'Hired' }, { value: 'rejected', label: 'Declined' },
|
||
]} />
|
||
<FilterSelect value={band} onChange={setBand} label="Score" options={[
|
||
{ value: 'all', label: 'All scores' }, { value: 'top', label: '80+ Top' },
|
||
{ value: 'strong', label: '60–79 Strong' }, { value: 'weak', label: 'Under 60' },
|
||
{ value: 'unscored', label: 'Unscored' },
|
||
]} />
|
||
<FilterSelect value={sort} onChange={setSort} label="Sort"
|
||
options={Object.entries(SORTS).map(([value, s]) => ({ value, label: s.label }))} />
|
||
</>
|
||
}
|
||
meta={`${filtered.length} of ${applications.length} candidates${isFiltered ? ' · filtered' : ''}`}
|
||
/>
|
||
|
||
{isLoading ? (
|
||
<div className="space-y-3">
|
||
{[...Array(5)].map((_, i) => (
|
||
<div key={i} className="h-24 bg-white border border-[#E5E7EB] rounded-xl animate-pulse" />
|
||
))}
|
||
</div>
|
||
) : filtered.length === 0 ? (
|
||
<div className="text-center py-16">
|
||
<p className="text-body-sm text-ink-3">No candidates match your filters</p>
|
||
{isFiltered && (
|
||
<Button size="xs" variant="outline" className="mt-3" onClick={clearFilters}>
|
||
Clear filters
|
||
</Button>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="space-y-4">
|
||
{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}
|
||
onHire={handleHire}
|
||
/>
|
||
))}
|
||
</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} />
|
||
)}
|
||
</AdminPage>
|
||
);
|
||
}
|