Initial commit

This commit is contained in:
2026-08-07 23:53:15 +05:30
commit 90f14a73b2
222 changed files with 35000 additions and 0 deletions

View 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>
);
}