Initial commit
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user