feat(upload): support multiple CV uploads concurrently with progress list

This commit is contained in:
Gabriel Ramos 2026-06-10 12:25:01 -04:00
parent 5fe330f9fb
commit 2776ac5a1d
2 changed files with 224 additions and 71 deletions

View file

@ -28,12 +28,19 @@ interface Candidate {
created_at: string; created_at: string;
} }
interface UploadFileStatus {
name: string;
status: "uploading" | "success" | "error";
errorMessage?: string;
}
export default function CandidatesPage() { export default function CandidatesPage() {
const [candidates, setCandidates] = useState<Candidate[]>([]); const [candidates, setCandidates] = useState<Candidate[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null); const [uploadError, setUploadError] = useState<string | null>(null);
const [uploadSuccess, setUploadSuccess] = useState<string | null>(null); const [uploadSuccess, setUploadSuccess] = useState<string | null>(null);
const [uploadStatuses, setUploadStatuses] = useState<UploadFileStatus[]>([]);
const fetchCandidates = () => { const fetchCandidates = () => {
fetch("/api/candidates") fetch("/api/candidates")
@ -55,43 +62,73 @@ export default function CandidatesPage() {
fetchCandidates(); fetchCandidates();
}, []); }, []);
// Upload PDF CV in a vacuum // Upload PDF CVs in a vacuum
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const files = e.target.files;
if (!file) return; if (!files || files.length === 0) return;
if (file.type !== "application/pdf") { const fileList = Array.from(files);
setUploadError("Please upload a PDF file");
return;
}
try { // Set initial status
setUploading(true); const initialStatuses = fileList.map((file) => ({
setUploadError(null); name: file.name,
setUploadSuccess(null); status: "uploading" as const,
}));
setUploadStatuses(initialStatuses);
setUploading(true);
setUploadError(null);
setUploadSuccess(null);
const formData = new FormData(); const uploadPromises = fileList.map(async (file, index) => {
formData.append("file", file); if (file.type !== "application/pdf") {
setUploadStatuses((prev) =>
const res = await fetch("/candidates/api/parse-cv", { prev.map((status, idx) =>
method: "POST", idx === index
body: formData, ? { ...status, status: "error" as const, errorMessage: "Only PDF files are allowed" }
}); : status
)
if (!res.ok) { );
const errData = await res.json(); return;
throw new Error(errData.error || "Failed to process CV");
} }
const resData = await res.json(); try {
setUploadSuccess(`CV for ${resData.candidateName || file.name} successfully parsed!`); const formData = new FormData();
fetchCandidates(); formData.append("file", file);
} catch (err: unknown) {
setUploadError(err instanceof Error ? err.message : "Error uploading CV"); const res = await fetch("/candidates/api/parse-cv", {
} finally { method: "POST",
setUploading(false); body: formData,
e.target.value = ""; });
}
if (!res.ok) {
const errData = await res.json();
throw new Error(errData.error || "Failed to process CV");
}
await res.json();
setUploadStatuses((prev) =>
prev.map((status, idx) =>
idx === index
? { ...status, status: "success" as const }
: status
)
);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Error uploading CV";
setUploadStatuses((prev) =>
prev.map((status, idx) =>
idx === index
? { ...status, status: "error" as const, errorMessage: msg }
: status
)
);
}
});
await Promise.all(uploadPromises);
setUploading(false);
fetchCandidates();
e.target.value = "";
}; };
return ( return (
@ -115,10 +152,11 @@ export default function CandidatesPage() {
No job position will be associated initially, keeping the data isolated. No job position will be associated initially, keeping the data isolated.
</p> </p>
<label className="relative cursor-pointer bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded-md text-sm transition duration-200"> <label className="relative cursor-pointer bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded-md text-sm transition duration-200">
{uploading ? "Processing CV..." : "Upload CV File"} {uploading ? "Uploading CVs..." : "Upload CV Files"}
<input <input
type="file" type="file"
accept=".pdf" accept=".pdf"
multiple
onChange={handleFileUpload} onChange={handleFileUpload}
disabled={uploading} disabled={uploading}
className="hidden" className="hidden"
@ -134,6 +172,43 @@ export default function CandidatesPage() {
{uploadSuccess} {uploadSuccess}
</p> </p>
)} )}
{uploadStatuses.length > 0 && (
<div className="mt-4 w-full max-w-md border border-slate-200 rounded-md p-4 bg-slate-50 text-left">
<h4 className="text-xs font-semibold text-slate-900 mb-2 uppercase tracking-wider">
Upload Progress
</h4>
<ul className="divide-y divide-slate-200">
{uploadStatuses.map((item, idx) => (
<li key={idx} className="py-2 flex flex-col gap-1 text-xs">
<div className="flex items-center justify-between">
<span className="font-medium text-slate-700 truncate max-w-[250px]" title={item.name}>
{item.name}
</span>
{item.status === "uploading" && (
<span className="text-slate-600 font-semibold flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-slate-400 animate-pulse"></span>
Uploading...
</span>
)}
{item.status === "success" && (
<span className="text-green-600 font-semibold flex items-center gap-1">
Success
</span>
)}
{item.status === "error" && (
<span className="text-red-600 font-semibold flex items-center gap-1">
Error
</span>
)}
</div>
{item.errorMessage && (
<p className="text-red-600 font-normal mt-0.5">{item.errorMessage}</p>
)}
</li>
))}
</ul>
</div>
)}
</div> </div>
{loading ? ( {loading ? (

View file

@ -69,6 +69,12 @@ const skillsMatch = (candSkill: string, jobSkill: string): boolean => {
return sharedWords.length > 0; return sharedWords.length > 0;
}; };
interface UploadFileStatus {
name: string;
status: "uploading" | "success" | "error";
errorMessage?: string;
}
export default function JobsPage() { export default function JobsPage() {
const [jobs, setJobs] = useState<Job[]>([]); const [jobs, setJobs] = useState<Job[]>([]);
const [selectedJob, setSelectedJob] = useState<Job | null>(null); const [selectedJob, setSelectedJob] = useState<Job | null>(null);
@ -79,6 +85,7 @@ export default function JobsPage() {
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null); const [uploadError, setUploadError] = useState<string | null>(null);
const [uploadSuccess, setUploadSuccess] = useState<string | null>(null); const [uploadSuccess, setUploadSuccess] = useState<string | null>(null);
const [uploadStatuses, setUploadStatuses] = useState<UploadFileStatus[]>([]);
// Evaluation states // Evaluation states
const [evaluatingIds, setEvaluatingIds] = useState<Record<string, boolean>>({}); const [evaluatingIds, setEvaluatingIds] = useState<Record<string, boolean>>({});
@ -130,6 +137,10 @@ export default function JobsPage() {
useEffect(() => { useEffect(() => {
let active = true; let active = true;
Promise.resolve().then(() => {
if (active) setUploadStatuses([]);
});
if (!selectedJob) { if (!selectedJob) {
Promise.resolve().then(() => { Promise.resolve().then(() => {
if (active) setMatches([]); if (active) setMatches([]);
@ -195,51 +206,80 @@ export default function JobsPage() {
} }
}; };
// Upload PDF CV // Upload PDF CVs
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const files = e.target.files;
if (!file || !selectedJob) return; if (!files || files.length === 0 || !selectedJob) return;
if (file.type !== "application/pdf") { const fileList = Array.from(files);
setUploadError("Please upload a PDF file");
return;
}
try { const initialStatuses = fileList.map((file) => ({
setUploading(true); name: file.name,
setUploadError(null); status: "uploading" as const,
setUploadSuccess(null); }));
setUploadStatuses(initialStatuses);
setUploading(true);
setUploadError(null);
setUploadSuccess(null);
const formData = new FormData(); const uploadPromises = fileList.map(async (file, index) => {
formData.append("file", file); if (file.type !== "application/pdf") {
formData.append("jobId", selectedJob.id); setUploadStatuses((prev) =>
prev.map((status, idx) =>
const res = await fetch("/candidates/api/parse-cv", { idx === index
method: "POST", ? { ...status, status: "error" as const, errorMessage: "Only PDF files are allowed" }
body: formData, : status
}); )
);
if (!res.ok) { return;
const errData = await res.json();
throw new Error(errData.error || "Failed to process CV");
} }
const resData = await res.json(); try {
setUploadSuccess(`CV for ${resData.candidateName || file.name} successfully parsed and linked!`); const formData = new FormData();
formData.append("file", file);
formData.append("jobId", selectedJob.id);
// Refresh matches for current job const res = await fetch("/candidates/api/parse-cv", {
const matchesRes = await fetch(`/api/candidates?jobId=${selectedJob.id}`); method: "POST",
if (matchesRes.ok) { body: formData,
const matchesData = await matchesRes.json(); });
setMatches(matchesData);
if (!res.ok) {
const errData = await res.json();
throw new Error(errData.error || "Failed to process CV");
}
await res.json();
setUploadStatuses((prev) =>
prev.map((status, idx) =>
idx === index
? { ...status, status: "success" as const }
: status
)
);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Error uploading CV";
setUploadStatuses((prev) =>
prev.map((status, idx) =>
idx === index
? { ...status, status: "error" as const, errorMessage: msg }
: status
)
);
} }
} catch (err: unknown) { });
setUploadError(err instanceof Error ? err.message : "Error uploading CV");
} finally { await Promise.all(uploadPromises);
setUploading(false); setUploading(false);
// Clear file input
e.target.value = ""; // Refresh matches for current job
const matchesRes = await fetch(`/api/candidates?jobId=${selectedJob.id}`);
if (matchesRes.ok) {
const matchesData = await matchesRes.json();
setMatches(matchesData);
} }
e.target.value = "";
}; };
// Run deep AI evaluation via backend endpoint // Run deep AI evaluation via backend endpoint
@ -498,13 +538,14 @@ export default function JobsPage() {
Upload Candidate CV for this Vacancy (PDF) Upload Candidate CV for this Vacancy (PDF)
</h3> </h3>
<p className="text-xs text-slate-500 mb-4 max-w-md"> <p className="text-xs text-slate-500 mb-4 max-w-md">
Uploading a candidate CV parses the text and extracts their skills/profile in a vacuum. It associates them with this job, enabling you to check skills overlap before running the deep AI score model. Uploading a candidate CV parses the text and extracts their skills/profile. It associates them with this job, enabling you to check skills overlap before running the deep AI score model.
</p> </p>
<label className="relative cursor-pointer bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded-md text-sm transition duration-200"> <label className="relative cursor-pointer bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded-md text-sm transition duration-200">
{uploading ? "Processing CV..." : "Choose CV File"} {uploading ? "Uploading CVs..." : "Choose CV Files"}
<input <input
type="file" type="file"
accept=".pdf" accept=".pdf"
multiple
onChange={handleFileUpload} onChange={handleFileUpload}
disabled={uploading} disabled={uploading}
className="hidden" className="hidden"
@ -520,6 +561,43 @@ export default function JobsPage() {
{uploadSuccess} {uploadSuccess}
</p> </p>
)} )}
{uploadStatuses.length > 0 && (
<div className="mt-4 w-full max-w-md border border-slate-200 rounded-md p-4 bg-slate-50 text-left">
<h4 className="text-xs font-semibold text-slate-900 mb-2 uppercase tracking-wider">
Upload Progress
</h4>
<ul className="divide-y divide-slate-200">
{uploadStatuses.map((item, idx) => (
<li key={idx} className="py-2 flex flex-col gap-1 text-xs">
<div className="flex items-center justify-between">
<span className="font-medium text-slate-700 truncate max-w-[250px]" title={item.name}>
{item.name}
</span>
{item.status === "uploading" && (
<span className="text-slate-600 font-semibold flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-slate-400 animate-pulse"></span>
Uploading...
</span>
)}
{item.status === "success" && (
<span className="text-green-600 font-semibold flex items-center gap-1">
Success
</span>
)}
{item.status === "error" && (
<span className="text-red-600 font-semibold flex items-center gap-1">
Error
</span>
)}
</div>
{item.errorMessage && (
<p className="text-red-600 font-normal mt-0.5">{item.errorMessage}</p>
)}
</li>
))}
</ul>
</div>
)}
</div> </div>
{/* Matches List */} {/* Matches List */}