Spaces:
Running
Running
| /** | |
| * JobForm.jsx | |
| * The main HR input form: job details, filters, and drag-and-drop resume upload. | |
| */ | |
| import { useState, useRef, useCallback } from 'react'; | |
| import { | |
| Briefcase, FileText, Star, Clock, GraduationCap, Award, | |
| MapPin, Globe, Upload, X, ChevronDown, ChevronUp, Zap, Users | |
| } from 'lucide-react'; | |
| export default function JobForm({ onSubmit, loading }) { | |
| // ββ Job Details βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const [jobTitle, setJobTitle] = useState(''); | |
| const [jobDescription, setJobDescription] = useState(''); | |
| const [requiredSkills, setRequiredSkills] = useState(''); | |
| const [experienceYears, setExperienceYears] = useState(''); | |
| const [education, setEducation] = useState(''); | |
| const [certifications, setCertifications] = useState(''); | |
| const [topN, setTopN] = useState(10); | |
| // ββ Filters ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const [preferredLocation, setPreferredLocation] = useState(''); | |
| const [preferredLanguages, setPreferredLanguages] = useState(''); | |
| const [filtersOpen, setFiltersOpen] = useState(false); | |
| // ββ Resume Upload ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const [files, setFiles] = useState([]); | |
| const [resumeText, setResumeText] = useState(''); | |
| const [dragging, setDragging] = useState(false); | |
| const fileInputRef = useRef(null); | |
| // ββ Drag and Drop handlers βββββββββββββββββββββββββββββββββββββββββββββββ | |
| const handleDrop = useCallback((e) => { | |
| e.preventDefault(); | |
| setDragging(false); | |
| const dropped = Array.from(e.dataTransfer.files).filter(f => f.type === 'application/pdf'); | |
| setFiles(prev => { | |
| const names = new Set(prev.map(f => f.name)); | |
| return [...prev, ...dropped.filter(f => !names.has(f.name))]; | |
| }); | |
| }, []); | |
| const handleDragOver = (e) => { e.preventDefault(); setDragging(true); }; | |
| const handleDragLeave = () => setDragging(false); | |
| const handleFileChange = (e) => { | |
| const selected = Array.from(e.target.files).filter(f => f.type === 'application/pdf'); | |
| setFiles(prev => { | |
| const names = new Set(prev.map(f => f.name)); | |
| return [...prev, ...selected.filter(f => !names.has(f.name))]; | |
| }); | |
| e.target.value = ''; | |
| }; | |
| const removeFile = (name) => setFiles(prev => prev.filter(f => f.name !== name)); | |
| // ββ Form submit ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const handleSubmit = (e) => { | |
| e.preventDefault(); | |
| if (!files.length && !resumeText.trim()) { | |
| alert('Please upload at least one resume PDF or paste resume text.'); | |
| return; | |
| } | |
| const formData = new FormData(); | |
| formData.append('job_title', jobTitle); | |
| formData.append('job_description', jobDescription); | |
| formData.append('required_skills', requiredSkills); | |
| formData.append('experience_years', experienceYears || '0'); | |
| formData.append('education', education); | |
| formData.append('certifications', certifications); | |
| formData.append('top_n', topN); | |
| formData.append('preferred_location', preferredLocation); | |
| formData.append('preferred_languages', preferredLanguages); | |
| formData.append('resume_text', resumeText); | |
| // Append all PDF files | |
| if (files.length > 0) { | |
| files.forEach(f => formData.append('resumes', f)); | |
| } else { | |
| // Append a blank file so FastAPI 'resumes' field isn't empty | |
| formData.append('resumes', new Blob([]), 'placeholder.pdf'); | |
| } | |
| onSubmit(formData); | |
| }; | |
| return ( | |
| <form onSubmit={handleSubmit} className="space-y-6 animate-fade-in"> | |
| {/* ββ Header ββ */} | |
| <div className="flex items-center justify-between"> | |
| <div> | |
| <h2 className="text-2xl font-bold text-white">New Screening</h2> | |
| <p className="text-slate-400 text-sm mt-1">Configure job requirements and upload candidate resumes</p> | |
| </div> | |
| <div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-primary-900/30 border border-primary-800/40"> | |
| <Zap size={14} className="text-primary-400" /> | |
| <span className="text-xs text-primary-300 font-medium">AI-Powered</span> | |
| </div> | |
| </div> | |
| {/* ββ Job Details Card ββ */} | |
| <div className="card p-6 space-y-5"> | |
| <h3 className="section-header"> | |
| <Briefcase size={18} className="text-primary-400" /> | |
| Job Details | |
| </h3> | |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-5"> | |
| {/* Job Title */} | |
| <div> | |
| <label className="label" htmlFor="job-title">Job Title *</label> | |
| <input | |
| id="job-title" | |
| className="input-field" | |
| placeholder="e.g. Senior Software Engineer" | |
| value={jobTitle} | |
| onChange={e => setJobTitle(e.target.value)} | |
| required | |
| /> | |
| </div> | |
| {/* Top N */} | |
| <div> | |
| <label className="label" htmlFor="top-n"> | |
| <Users size={14} className="inline mr-1.5" /> | |
| Top N Candidates | |
| </label> | |
| <input | |
| id="top-n" | |
| type="number" | |
| min={1} | |
| max={100} | |
| className="input-field" | |
| value={topN} | |
| onChange={e => setTopN(parseInt(e.target.value) || 10)} | |
| /> | |
| </div> | |
| </div> | |
| {/* Job Description */} | |
| <div> | |
| <label className="label" htmlFor="job-description"> | |
| <FileText size={14} className="inline mr-1.5" /> | |
| Job Description * | |
| </label> | |
| <textarea | |
| id="job-description" | |
| className="input-field min-h-[120px] resize-y" | |
| placeholder="Paste the full job description here..." | |
| value={jobDescription} | |
| onChange={e => setJobDescription(e.target.value)} | |
| required | |
| /> | |
| </div> | |
| {/* Required Skills */} | |
| <div> | |
| <label className="label" htmlFor="required-skills"> | |
| <Star size={14} className="inline mr-1.5" /> | |
| Required Skills * | |
| <span className="text-slate-600 font-normal ml-2">(comma separated)</span> | |
| </label> | |
| <input | |
| id="required-skills" | |
| className="input-field" | |
| placeholder="e.g. Python, React, SQL, Machine Learning" | |
| value={requiredSkills} | |
| onChange={e => setRequiredSkills(e.target.value)} | |
| required | |
| /> | |
| </div> | |
| <div className="grid grid-cols-1 md:grid-cols-3 gap-5"> | |
| {/* Experience */} | |
| <div> | |
| <label className="label" htmlFor="experience-years"> | |
| <Clock size={14} className="inline mr-1.5" /> | |
| Experience (years) | |
| </label> | |
| <input | |
| id="experience-years" | |
| type="number" | |
| min={0} | |
| className="input-field" | |
| placeholder="e.g. 3" | |
| value={experienceYears} | |
| onChange={e => setExperienceYears(e.target.value)} | |
| /> | |
| </div> | |
| {/* Education */} | |
| <div> | |
| <label className="label" htmlFor="education"> | |
| <GraduationCap size={14} className="inline mr-1.5" /> | |
| Required Education | |
| </label> | |
| <select | |
| id="education" | |
| className="input-field" | |
| value={education} | |
| onChange={e => setEducation(e.target.value)} | |
| > | |
| <option value="">Any</option> | |
| <option value="certificate">Certificate / Diploma</option> | |
| <option value="bachelor">Bachelor's Degree</option> | |
| <option value="master">Master's Degree</option> | |
| <option value="phd">PhD / Doctorate</option> | |
| </select> | |
| </div> | |
| {/* Certifications */} | |
| <div> | |
| <label className="label" htmlFor="certifications"> | |
| <Award size={14} className="inline mr-1.5" /> | |
| Required Certifications | |
| </label> | |
| <input | |
| id="certifications" | |
| className="input-field" | |
| placeholder="e.g. AWS Certified, PMP" | |
| value={certifications} | |
| onChange={e => setCertifications(e.target.value)} | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| {/* ββ Optional Filters ββ */} | |
| <div className="card overflow-hidden"> | |
| <button | |
| type="button" | |
| onClick={() => setFiltersOpen(v => !v)} | |
| className="w-full flex items-center justify-between p-5 hover:bg-slate-800/50 transition-colors" | |
| > | |
| <h3 className="section-header"> | |
| <MapPin size={18} className="text-primary-400" /> | |
| Filters & Bonus Factors | |
| <span className="ml-2 text-xs text-slate-500 font-normal">(5% weight)</span> | |
| </h3> | |
| {filtersOpen ? <ChevronUp size={18} className="text-slate-500" /> : <ChevronDown size={18} className="text-slate-500" />} | |
| </button> | |
| {filtersOpen && ( | |
| <div className="px-6 pb-6 grid grid-cols-1 md:grid-cols-2 gap-5 border-t border-slate-800 pt-5 animate-fade-in"> | |
| <div> | |
| <label className="label" htmlFor="preferred-location"> | |
| <MapPin size={14} className="inline mr-1.5" /> | |
| Preferred Location | |
| </label> | |
| <input | |
| id="preferred-location" | |
| className="input-field" | |
| placeholder="e.g. Karachi, Pakistan" | |
| value={preferredLocation} | |
| onChange={e => setPreferredLocation(e.target.value)} | |
| /> | |
| </div> | |
| <div> | |
| <label className="label" htmlFor="preferred-languages"> | |
| <Globe size={14} className="inline mr-1.5" /> | |
| Preferred Languages | |
| <span className="text-slate-600 font-normal ml-1">(comma separated)</span> | |
| </label> | |
| <input | |
| id="preferred-languages" | |
| className="input-field" | |
| placeholder="e.g. English, Urdu" | |
| value={preferredLanguages} | |
| onChange={e => setPreferredLanguages(e.target.value)} | |
| /> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| {/* ββ Resume Upload Card ββ */} | |
| <div className="card p-6 space-y-5"> | |
| <h3 className="section-header"> | |
| <Upload size={18} className="text-primary-400" /> | |
| Resume Upload | |
| </h3> | |
| {/* Drop Zone */} | |
| <div | |
| onDrop={handleDrop} | |
| onDragOver={handleDragOver} | |
| onDragLeave={handleDragLeave} | |
| onClick={() => fileInputRef.current.click()} | |
| className={`border-2 border-dashed rounded-xl p-10 text-center cursor-pointer transition-all duration-300 ${ | |
| dragging | |
| ? 'border-primary-500 bg-primary-900/20 drop-active' | |
| : 'border-slate-700 hover:border-slate-600 hover:bg-slate-800/30' | |
| }`} | |
| > | |
| <input | |
| ref={fileInputRef} | |
| type="file" | |
| id="resume-upload" | |
| multiple | |
| accept=".pdf" | |
| className="hidden" | |
| onChange={handleFileChange} | |
| /> | |
| <div className="flex flex-col items-center gap-3"> | |
| <div className={`w-14 h-14 rounded-2xl flex items-center justify-center transition-colors ${ | |
| dragging ? 'bg-primary-600/30' : 'bg-slate-800' | |
| }`}> | |
| <Upload size={26} className={dragging ? 'text-primary-400' : 'text-slate-500'} /> | |
| </div> | |
| <div> | |
| <p className="text-slate-300 font-medium"> | |
| {dragging ? 'Drop PDFs here!' : 'Drag & drop PDF resumes'} | |
| </p> | |
| <p className="text-slate-500 text-sm mt-1">or click to browse Β· Multiple files supported</p> | |
| </div> | |
| </div> | |
| </div> | |
| {/* File list */} | |
| {files.length > 0 && ( | |
| <div className="space-y-2 animate-fade-in"> | |
| <p className="text-slate-400 text-sm font-medium">{files.length} file(s) selected</p> | |
| <div className="max-h-48 overflow-y-auto space-y-1.5 pr-1"> | |
| {files.map(f => ( | |
| <div | |
| key={f.name} | |
| className="flex items-center justify-between px-4 py-2.5 bg-slate-800 rounded-lg border border-slate-700" | |
| > | |
| <div className="flex items-center gap-2.5 min-w-0"> | |
| <FileText size={16} className="text-primary-400 flex-shrink-0" /> | |
| <span className="text-sm text-slate-300 truncate">{f.name}</span> | |
| <span className="text-xs text-slate-600 flex-shrink-0"> | |
| {(f.size / 1024).toFixed(0)} KB | |
| </span> | |
| </div> | |
| <button | |
| type="button" | |
| onClick={(e) => { e.stopPropagation(); removeFile(f.name); }} | |
| className="ml-2 text-slate-600 hover:text-red-400 transition-colors flex-shrink-0" | |
| > | |
| <X size={16} /> | |
| </button> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {/* Text paste option */} | |
| <div> | |
| <label className="label" htmlFor="resume-text"> | |
| Or Paste Resume Text (optional) | |
| </label> | |
| <textarea | |
| id="resume-text" | |
| className="input-field min-h-[80px] resize-y" | |
| placeholder="Paste a plain-text resume here as an alternative or addition..." | |
| value={resumeText} | |
| onChange={e => setResumeText(e.target.value)} | |
| /> | |
| </div> | |
| </div> | |
| {/* ββ Submit Button ββ */} | |
| <button | |
| id="run-screening-btn" | |
| type="submit" | |
| disabled={loading} | |
| className="btn-primary w-full flex items-center justify-center gap-2.5 py-4 text-base" | |
| > | |
| {loading ? ( | |
| <> | |
| <svg className="animate-spin w-5 h-5" viewBox="0 0 24 24" fill="none"> | |
| <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /> | |
| <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z" /> | |
| </svg> | |
| Processing Resumes... | |
| </> | |
| ) : ( | |
| <> | |
| <Zap size={20} /> | |
| Run AI Screening | |
| </> | |
| )} | |
| </button> | |
| </form> | |
| ); | |
| } | |