ui changes
This commit is contained in:
124
src/pages/admin/Candidates.jsx
Normal file
124
src/pages/admin/Candidates.jsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Brain } from 'lucide-react';
|
||||
import { Button, SearchInput, toast } from '@/components/ds';
|
||||
import { useApplications, useInterviews, useJobPostings } from '@/lib/krowHooks';
|
||||
import { AdminPage, Toolbar } from '@/pages/admin/_shell';
|
||||
import { FilterSelect } from '@/pages/admin/Positions';
|
||||
import CandidateCard from '@/components/krow/CandidateCard';
|
||||
import KrowAiChat from '@/components/krow/KrowAiChat';
|
||||
|
||||
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 [search, setSearch] = useState('');
|
||||
const [position, setPosition] = useState('all');
|
||||
const [stage, setStage] = useState('all');
|
||||
const [band, setBand] = useState('all');
|
||||
const [sort, setSort] = useState('score');
|
||||
|
||||
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.status !== stage) return false;
|
||||
if (!SCORE_BANDS[band](a.ai_score || 0)) 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."
|
||||
actions={
|
||||
<Button variant="outline" size="sm" shape="rounded" onClick={() => toast.info('Batch screening is disabled in the demo')}>
|
||||
<Brain aria-hidden="true" /> Screen unscored
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<KrowAiChat page="candidates" />
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user