285 lines
12 KiB
JavaScript
285 lines
12 KiB
JavaScript
import React, { useState } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { cn } from '@/lib/utils';
|
|
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu';
|
|
import { MessageSquare, Phone, Calendar, X, Trash2, ChevronDown, UserCheck, Briefcase, ArrowRight } from 'lucide-react';
|
|
import { Image } from '@/components/ui/image';
|
|
import { getScoreBand } from '@/lib/talentHome';
|
|
import CandidateExpandedDetails from '@/components/krow/CandidateExpandedDetails';
|
|
import ScheduleInterviewModal from '@/components/krow/ScheduleInterviewModal';
|
|
import MessageCandidateModal from '@/components/krow/MessageCandidateModal';
|
|
import { toast } from 'react-hot-toast';
|
|
import { base44 } from '@/api/base44Client';
|
|
|
|
const STATUS_STYLES = {
|
|
hired: { badge: 'bg-[#E8F0FE] text-[#1A56DB]', label: 'Hired', card: 'bg-white border-[#E5E7EB]' },
|
|
ai_screened: { badge: 'bg-[#E8F0FE] text-[#1A56DB]', label: 'AI Screened', card: 'bg-white border-[#E5E7EB]' },
|
|
shortlisted: { badge: 'bg-[#E8F0FE] text-[#1A56DB]', label: 'Shortlisted', card: 'bg-white border-[#E5E7EB]' },
|
|
interview: { badge: 'bg-[#E8F0FE] text-[#1A56DB]', label: 'Interviewing', card: 'bg-white border-[#E5E7EB]' },
|
|
applied: { badge: 'bg-[#F3F4F6] text-[#6B7280]', label: 'Applied', card: 'bg-white border-[#E5E7EB]' },
|
|
rejected: { badge: 'bg-[#FEE2E2] text-[#991B1B]', label: 'Rejected', card: 'bg-white border-[#E5E7EB]' },
|
|
};
|
|
|
|
function getInitials(name) {
|
|
return name?.split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase() || '??';
|
|
}
|
|
|
|
const PORTRAIT_PHOTOS = [
|
|
'photo-1500648767731-7d3514ad8b31',
|
|
'photo-1531123897727-8f129e1688ce',
|
|
'photo-1573496359142-b8d87734a5a2',
|
|
'photo-1487412720507-e7ab37603c6f',
|
|
'photo-1502685104226-ee3a97a2f2c8',
|
|
'photo-1544005313-94ddf0286df2',
|
|
'photo-1494790108377-be9c29b29330',
|
|
'photo-1438761681033-6461ffad8d80',
|
|
'photo-1517841905240-472988babdf9',
|
|
'photo-1463453091185-61582044d556',
|
|
];
|
|
|
|
function getPortraitSeed(name) {
|
|
let h = 0;
|
|
for (let i = 0; i < (name || '').length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
|
|
return PORTRAIT_PHOTOS[h % PORTRAIT_PHOTOS.length];
|
|
}
|
|
|
|
function getScoreColor(score) {
|
|
if (score > 0) return '#0838E0';
|
|
return '#EF4444';
|
|
}
|
|
|
|
function ScoreRing({ score }) {
|
|
const color = getScoreColor(score);
|
|
const radius = 24;
|
|
const circumference = 2 * Math.PI * radius;
|
|
const offset = circumference - (score / 100) * circumference;
|
|
return (
|
|
<div className="relative w-14 h-14 flex items-center justify-center shrink-0">
|
|
<svg className="w-14 h-14 -rotate-90" viewBox="0 0 56 56">
|
|
<circle cx="28" cy="28" r={radius} fill="none" stroke="#F3F4F6" strokeWidth="4" />
|
|
<circle
|
|
cx="28" cy="28" r={radius} fill="none" stroke={color} strokeWidth="4"
|
|
strokeDasharray={circumference} strokeDashoffset={offset} strokeLinecap="round"
|
|
/>
|
|
</svg>
|
|
<span className="absolute text-[13px] font-bold text-[#111827]">{score}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function CandidateCard({ application, jobTitle, rank, onAction, onMessage, onCall, onSchedule, onDecline, onDelete, onHire }) {
|
|
const navigate = useNavigate();
|
|
const position = jobTitle || application.job_title || '';
|
|
const status = application.status || 'applied';
|
|
const style = STATUS_STYLES[status] || STATUS_STYLES.applied;
|
|
const score = application.ai_score || 0;
|
|
const canTakeAction = status !== 'hired' && status !== 'rejected';
|
|
const [expanded, setExpanded] = useState(false);
|
|
|
|
const [internalMessageOpen, setInternalMessageOpen] = useState(false);
|
|
const [internalScheduleOpen, setInternalScheduleOpen] = useState(false);
|
|
|
|
const handleMessage = (e) => {
|
|
e?.stopPropagation();
|
|
if (onMessage) {
|
|
onMessage(application);
|
|
} else {
|
|
setInternalMessageOpen(true);
|
|
}
|
|
};
|
|
|
|
const handleCall = (e) => {
|
|
e?.stopPropagation();
|
|
if (onCall) {
|
|
onCall(application);
|
|
} else if (application.phone) {
|
|
window.location.href = `tel:${application.phone}`;
|
|
} else {
|
|
setInternalMessageOpen(true);
|
|
}
|
|
};
|
|
|
|
const handleSchedule = (e) => {
|
|
e?.stopPropagation();
|
|
if (onSchedule) {
|
|
onSchedule(application);
|
|
} else {
|
|
setInternalScheduleOpen(true);
|
|
}
|
|
};
|
|
|
|
const handleDecline = async (e) => {
|
|
e?.stopPropagation();
|
|
if (onDecline) {
|
|
onDecline(application);
|
|
} else {
|
|
try {
|
|
await base44.entities.JobApplication.update(application.id, { status: 'rejected' });
|
|
toast.success(`${application.applicant_name} declined`);
|
|
} catch (err) {
|
|
toast.error('Failed to decline candidate');
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (e) => {
|
|
e?.stopPropagation();
|
|
if (onDelete) {
|
|
onDelete(application);
|
|
} else {
|
|
try {
|
|
await base44.entities.JobApplication.delete(application.id);
|
|
toast.success(`${application.applicant_name} deleted`);
|
|
} catch (err) {
|
|
toast.error('Failed to delete candidate');
|
|
}
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className={cn('border rounded-xl p-4 transition-all hover:shadow-sm', style.card)}>
|
|
<div
|
|
className="flex items-center gap-4 cursor-pointer"
|
|
onClick={() => setExpanded(!expanded)}
|
|
>
|
|
{/* Rank */}
|
|
<div className="flex flex-col items-center justify-center gap-0.5 shrink-0 w-8">
|
|
<span className="text-[18px] font-bold text-[#0838E0]">{rank}</span>
|
|
</div>
|
|
|
|
{/* Avatar — 88-110px target size */}
|
|
<div className="w-20 h-20 sm:w-24 sm:h-24 rounded-2xl bg-[#E5E7EB] flex items-center justify-center text-white font-semibold text-[15px] shrink-0 overflow-hidden shadow-sm">
|
|
<Image
|
|
src={application.selfie_url || `https://images.unsplash.com/${getPortraitSeed(application.applicant_name)}?w=200&h=200&fit=crop&crop=faces`}
|
|
alt={application.applicant_name}
|
|
className="w-full h-full object-cover"
|
|
fittingType="fill"
|
|
/>
|
|
</div>
|
|
|
|
{/* Info */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<h3 className="font-semibold text-[#111827] text-[14px] truncate">{application.applicant_name}</h3>
|
|
<span className={cn('px-2 py-0.5 rounded-full text-[10px] font-medium', style.badge)}>{style.label}</span>
|
|
{score > 0 && (() => {
|
|
const band = getScoreBand(score);
|
|
return (
|
|
<span
|
|
className="px-2 py-0.5 rounded-full text-[10px] font-bold text-white"
|
|
style={{ backgroundColor: band.color }}
|
|
>
|
|
{band.label}
|
|
</span>
|
|
);
|
|
})()}
|
|
</div>
|
|
<div className="flex items-center gap-3 mt-1 text-[11px] text-[#6B7280] flex-wrap">
|
|
{position && (
|
|
<span className="inline-flex items-center gap-1 font-medium text-[#0838E0]">
|
|
<Briefcase className="w-3 h-3" />
|
|
{position}
|
|
</span>
|
|
)}
|
|
{application.email && (
|
|
<span>{application.email}</span>
|
|
)}
|
|
{application.phone && (
|
|
<span>{application.phone}</span>
|
|
)}
|
|
{application.years_experience != null && (
|
|
<span>{application.years_experience}yr exp</span>
|
|
)}
|
|
</div>
|
|
{application.ai_match_label && (
|
|
<div className="mt-1.5">
|
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-[#0838E0] text-white text-[10px] font-bold">
|
|
+ {application.ai_match_label}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Score */}
|
|
{status !== 'applied' && <ScoreRing score={score} />}
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center gap-2 shrink-0" onClick={(e) => e.stopPropagation()}>
|
|
{/* The full record, at the route that already serves it.
|
|
Clicking the row expands a summary in place, which is the right
|
|
default while comparing people — but the complete profile had no
|
|
way in from this list at all, so the same candidate opened from a
|
|
recommendation and from here led to two different depths of the
|
|
same person. One link, one route, one profile. */}
|
|
<button
|
|
type="button"
|
|
onClick={() => navigate(`/admin/candidates/${application.id}`)}
|
|
className="hidden sm:inline-flex items-center gap-1 rounded-lg px-2.5 py-1.5 text-[12px] font-semibold text-[#0838E0]
|
|
transition-colors hover:bg-[#EEF3FE] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0838E0]/40"
|
|
>
|
|
View profile
|
|
<ArrowRight className="w-3 h-3" aria-hidden="true" />
|
|
</button>
|
|
|
|
{onAction && canTakeAction && (
|
|
<button
|
|
onClick={() => onAction(application)}
|
|
className="px-3 py-1.5 rounded-lg bg-[#0838E0] hover:bg-[#062BAF] text-white text-[12px] font-medium transition-colors whitespace-nowrap"
|
|
>
|
|
{status === 'interview' ? 'AI Interview' : 'Interview'}
|
|
</button>
|
|
)}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button className="inline-flex items-center gap-1 px-3 py-1.5 rounded-lg border border-[#E5E7EB] bg-white hover:bg-[#F9FAFB] text-[#374151] text-[12px] font-medium transition-colors">
|
|
Actions
|
|
<ChevronDown className="w-3.5 h-3.5" />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-52 p-1.5 bg-white border border-[#E5E7EB] shadow-xl rounded-xl">
|
|
<DropdownMenuItem onClick={handleMessage} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
|
|
<MessageSquare className="w-4 h-4 mr-2.5 text-[#0838E0]" />
|
|
<span className="text-[#374151] text-[13px] font-medium">Message</span>
|
|
</DropdownMenuItem>
|
|
|
|
<DropdownMenuItem onClick={handleCall} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
|
|
<Phone className="w-4 h-4 mr-2.5 text-[#059669]" />
|
|
<span className="text-[#059669] text-[13px] font-medium">Call</span>
|
|
</DropdownMenuItem>
|
|
|
|
<DropdownMenuItem onClick={handleSchedule} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
|
|
<Calendar className="w-4 h-4 mr-2.5 text-[#0838E0]" />
|
|
<span className="text-[#0838E0] text-[13px] font-medium">Set up interview</span>
|
|
</DropdownMenuItem>
|
|
|
|
{onHire && (
|
|
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onHire(application); }} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
|
|
<UserCheck className="w-4 h-4 mr-2.5 text-[#059669]" />
|
|
<span className="text-[#059669] text-[13px] font-medium">Hire candidate</span>
|
|
</DropdownMenuItem>
|
|
)}
|
|
|
|
<DropdownMenuItem onClick={handleDecline} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
|
|
<X className="w-4 h-4 mr-2.5 text-[#DC2626]" />
|
|
<span className="text-[#DC2626] text-[13px] font-medium">Decline candidate</span>
|
|
</DropdownMenuItem>
|
|
|
|
<DropdownMenuItem onClick={handleDelete} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
|
|
<Trash2 className="w-4 h-4 mr-2.5 text-[#DC2626]" />
|
|
<span className="text-[#DC2626] text-[13px] font-medium">Delete candidate</span>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
{expanded && <CandidateExpandedDetails application={application} />}
|
|
|
|
{internalScheduleOpen && (
|
|
<ScheduleInterviewModal open={true} onClose={() => setInternalScheduleOpen(false)} application={application} />
|
|
)}
|
|
{internalMessageOpen && (
|
|
<MessageCandidateModal open={true} onClose={() => setInternalMessageOpen(false)} application={application} />
|
|
)}
|
|
</div>
|
|
);
|
|
} |