feat(upload): support multiple CV uploads concurrently with progress list
This commit is contained in:
parent
5fe330f9fb
commit
2776ac5a1d
2 changed files with 224 additions and 71 deletions
|
|
@ -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,21 +62,36 @@ 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
|
||||||
|
const initialStatuses = fileList.map((file) => ({
|
||||||
|
name: file.name,
|
||||||
|
status: "uploading" as const,
|
||||||
|
}));
|
||||||
|
setUploadStatuses(initialStatuses);
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
setUploadError(null);
|
setUploadError(null);
|
||||||
setUploadSuccess(null);
|
setUploadSuccess(null);
|
||||||
|
|
||||||
|
const uploadPromises = fileList.map(async (file, index) => {
|
||||||
|
if (file.type !== "application/pdf") {
|
||||||
|
setUploadStatuses((prev) =>
|
||||||
|
prev.map((status, idx) =>
|
||||||
|
idx === index
|
||||||
|
? { ...status, status: "error" as const, errorMessage: "Only PDF files are allowed" }
|
||||||
|
: status
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
|
|
||||||
|
|
@ -83,15 +105,30 @@ export default function CandidatesPage() {
|
||||||
throw new Error(errData.error || "Failed to process CV");
|
throw new Error(errData.error || "Failed to process CV");
|
||||||
}
|
}
|
||||||
|
|
||||||
const resData = await res.json();
|
await res.json();
|
||||||
setUploadSuccess(`CV for ${resData.candidateName || file.name} successfully parsed!`);
|
setUploadStatuses((prev) =>
|
||||||
fetchCandidates();
|
prev.map((status, idx) =>
|
||||||
|
idx === index
|
||||||
|
? { ...status, status: "success" as const }
|
||||||
|
: status
|
||||||
|
)
|
||||||
|
);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setUploadError(err instanceof Error ? err.message : "Error uploading CV");
|
const msg = err instanceof Error ? err.message : "Error uploading CV";
|
||||||
} finally {
|
setUploadStatuses((prev) =>
|
||||||
setUploading(false);
|
prev.map((status, idx) =>
|
||||||
e.target.value = "";
|
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 ? (
|
||||||
|
|
|
||||||
|
|
@ -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,21 +206,35 @@ 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) => ({
|
||||||
|
name: file.name,
|
||||||
|
status: "uploading" as const,
|
||||||
|
}));
|
||||||
|
setUploadStatuses(initialStatuses);
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
setUploadError(null);
|
setUploadError(null);
|
||||||
setUploadSuccess(null);
|
setUploadSuccess(null);
|
||||||
|
|
||||||
|
const uploadPromises = fileList.map(async (file, index) => {
|
||||||
|
if (file.type !== "application/pdf") {
|
||||||
|
setUploadStatuses((prev) =>
|
||||||
|
prev.map((status, idx) =>
|
||||||
|
idx === index
|
||||||
|
? { ...status, status: "error" as const, errorMessage: "Only PDF files are allowed" }
|
||||||
|
: status
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
formData.append("jobId", selectedJob.id);
|
formData.append("jobId", selectedJob.id);
|
||||||
|
|
@ -224,8 +249,28 @@ export default function JobsPage() {
|
||||||
throw new Error(errData.error || "Failed to process CV");
|
throw new Error(errData.error || "Failed to process CV");
|
||||||
}
|
}
|
||||||
|
|
||||||
const resData = await res.json();
|
await res.json();
|
||||||
setUploadSuccess(`CV for ${resData.candidateName || file.name} successfully parsed and linked!`);
|
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);
|
||||||
|
|
||||||
// Refresh matches for current job
|
// Refresh matches for current job
|
||||||
const matchesRes = await fetch(`/api/candidates?jobId=${selectedJob.id}`);
|
const matchesRes = await fetch(`/api/candidates?jobId=${selectedJob.id}`);
|
||||||
|
|
@ -233,13 +278,8 @@ export default function JobsPage() {
|
||||||
const matchesData = await matchesRes.json();
|
const matchesData = await matchesRes.json();
|
||||||
setMatches(matchesData);
|
setMatches(matchesData);
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
|
||||||
setUploadError(err instanceof Error ? err.message : "Error uploading CV");
|
|
||||||
} finally {
|
|
||||||
setUploading(false);
|
|
||||||
// Clear file input
|
|
||||||
e.target.value = "";
|
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 */}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue