Files
doormilxpress_astryx/src/pages/admin/Candidates.jsx
2026-08-14 17:33:16 +05:30

186 lines
7.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { SkillSurface } from '@/components/skills/SkillSurface';
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."
>
<SkillSurface page="candidates" placement="after-header" />
<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>
)}
<SkillSurface page="candidates" placement="before-footer" />
{/* 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>
);
}