feat(theme): add dark mode & move language toggle

This commit is contained in:
Gabriel Ramos 2026-06-10 14:26:50 -04:00
parent 1a439e3bd0
commit 6c83fb6af7
8 changed files with 705 additions and 318 deletions

View file

@ -1,6 +1,7 @@
"use client"; "use client";
import React, { useState, useEffect, useRef, useCallback } from "react"; import React, { useState, useEffect, useRef, useCallback } from "react";
import { useApp } from "@/components/AppContext";
interface Score { interface Score {
id: string; id: string;
@ -215,7 +216,7 @@ export default function CandidatesPage() {
const searchInputRef = useRef<HTMLInputElement>(null); const searchInputRef = useRef<HTMLInputElement>(null);
// i18n Language Toggle State // i18n Language Toggle State
const [lang, setLang] = useState<"en" | "es">("en"); const { lang } = useApp();
const t = translations[lang]; const t = translations[lang];
// Delete & Duplicate States // Delete & Duplicate States
@ -479,11 +480,11 @@ export default function CandidatesPage() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{/* CV Uploader (Vacuum Ingestion) */} {/* CV Uploader (Vacuum Ingestion) */}
<div className="bg-white p-6 rounded-lg shadow-sm border border-slate-200 flex flex-col items-center justify-center text-center"> <div className="bg-white dark:bg-slate-900 p-6 rounded-lg shadow-sm border border-slate-200 dark:border-slate-800 flex flex-col items-center justify-center text-center">
<h3 className="text-sm font-semibold text-slate-900 mb-1"> <h3 className="text-sm font-semibold text-slate-900 dark:text-white mb-1">
{t.uploadCv} {t.uploadCv}
</h3> </h3>
<p className="text-xs text-slate-500 mb-4 max-w-md"> <p className="text-xs text-slate-500 dark:text-slate-400 mb-4 max-w-md">
{t.uploadCvDesc} {t.uploadCvDesc}
</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">
@ -499,36 +500,36 @@ export default function CandidatesPage() {
</label> </label>
{uploadStatuses.length > 0 && ( {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"> <div className="mt-4 w-full max-w-md border border-slate-200 dark:border-slate-800 rounded-md p-4 bg-slate-50 dark:bg-slate-800/50 text-left">
<h4 className="text-xs font-semibold text-slate-900 mb-2 uppercase tracking-wider"> <h4 className="text-xs font-semibold text-slate-900 dark:text-white mb-2 uppercase tracking-wider">
{t.uploadProgress} {t.uploadProgress}
</h4> </h4>
<ul className="divide-y divide-slate-200"> <ul className="divide-y divide-slate-200 dark:divide-slate-800">
{uploadStatuses.map((item, idx) => ( {uploadStatuses.map((item, idx) => (
<li key={idx} className="py-2 flex flex-col gap-1 text-xs"> <li key={idx} className="py-2 flex flex-col gap-1 text-xs">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="font-medium text-slate-700 truncate max-w-[250px]" title={item.name}> <span className="font-medium text-slate-700 dark:text-slate-300 truncate max-w-[250px]" title={item.name}>
{item.name} {item.name}
</span> </span>
{item.status === "uploading" && ( {item.status === "uploading" && (
<span className="text-slate-600 font-semibold flex items-center gap-1"> <span className="text-slate-600 dark:text-slate-400 font-semibold flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-slate-500 animate-pulse"></span> <span className="w-1.5 h-1.5 rounded-full bg-slate-500 animate-pulse"></span>
{lang === "es" ? "Cargando..." : "Uploading..."} {lang === "es" ? "Cargando..." : "Uploading..."}
</span> </span>
)} )}
{item.status === "success" && ( {item.status === "success" && (
<span className="text-green-700 font-semibold flex items-center gap-1"> <span className="text-green-700 dark:text-green-400 font-semibold flex items-center gap-1">
{t.success} {t.success}
</span> </span>
)} )}
{item.status === "error" && ( {item.status === "error" && (
<span className="text-red-700 font-semibold flex items-center gap-1"> <span className="text-red-700 dark:text-red-400 font-semibold flex items-center gap-1">
{t.error} {t.error}
</span> </span>
)} )}
</div> </div>
{item.errorMessage && ( {item.errorMessage && (
<p className="text-red-700 font-normal mt-0.5">{item.errorMessage}</p> <p className="text-red-700 dark:text-red-400 font-normal mt-0.5">{item.errorMessage}</p>
)} )}
</li> </li>
))} ))}
@ -538,29 +539,23 @@ export default function CandidatesPage() {
</div> </div>
{loading ? ( {loading ? (
<div className="bg-white p-6 rounded-lg shadow-sm border border-slate-200"> <div className="bg-white dark:bg-slate-900 p-6 rounded-lg shadow-sm border border-slate-200 dark:border-slate-800">
<p className="text-slate-500 text-sm">{t.loading}</p> <p className="text-slate-500 dark:text-slate-400 text-sm">{t.loading}</p>
</div> </div>
) : candidates.length === 0 ? ( ) : candidates.length === 0 ? (
<div className="bg-white p-6 rounded-lg shadow-sm border border-slate-200"> <div className="bg-white dark:bg-slate-900 p-6 rounded-lg shadow-sm border border-slate-200 dark:border-slate-800">
<p className="text-slate-500 text-sm"> <p className="text-slate-500 dark:text-slate-400 text-sm">
{t.noCandidatesFound} {t.noCandidatesFound}
</p> </p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* LEFT COLUMN: Sidebar (1/3 width) */} {/* LEFT COLUMN: Sidebar (1/3 width) */}
<div className="md:col-span-1 bg-white p-4 rounded-lg shadow-sm border border-slate-200 flex flex-col gap-4"> <div className="md:col-span-1 bg-white dark:bg-slate-900 p-4 rounded-lg shadow-sm border border-slate-200 dark:border-slate-800 flex flex-col gap-4">
<div className="flex items-center justify-between border-b border-slate-200 pb-3"> <div className="flex items-center justify-between border-b border-slate-200 dark:border-slate-800 pb-3">
<h2 className="text-lg font-bold text-slate-900"> <h2 className="text-lg font-bold text-slate-900 dark:text-white">
{t.candidatesTitle} {t.candidatesTitle}
</h2> </h2>
<button
onClick={() => setLang(lang === "en" ? "es" : "en")}
className="px-2.5 py-1 text-xs font-semibold rounded-md border border-slate-200 bg-white hover:bg-slate-50 text-slate-600 transition"
>
{lang === "en" ? "ESPAÑOL" : "ENGLISH"}
</button>
</div> </div>
{/* Search Input */} {/* Search Input */}
@ -571,13 +566,13 @@ export default function CandidatesPage() {
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t.searchPlaceholder} placeholder={t.searchPlaceholder}
className="w-full px-3 py-2 border border-slate-200 rounded-md text-slate-900 bg-white placeholder:text-slate-500 text-sm focus:outline-none" className="w-full px-3 py-2 border border-slate-200 dark:border-slate-700 rounded-md text-slate-900 dark:text-white bg-white dark:bg-slate-800 placeholder:text-slate-500 dark:placeholder:text-slate-400 text-sm focus:outline-none"
/> />
{/* Search Quick Actions (only show when searchQuery !== "") */} {/* Search Quick Actions (only show when searchQuery !== "") */}
{searchQuery !== "" && ( {searchQuery !== "" && (
<div className="flex flex-wrap items-center gap-1.5 pt-1"> <div className="flex flex-wrap items-center gap-1.5 pt-1">
<span className="text-xs text-slate-500 font-medium"> <span className="text-xs text-slate-500 dark:text-slate-400 font-medium">
{t.searchBy}: {t.searchBy}:
</span> </span>
<button <button
@ -585,7 +580,7 @@ export default function CandidatesPage() {
className={`px-2 py-0.5 text-xs rounded transition font-medium border ${ className={`px-2 py-0.5 text-xs rounded transition font-medium border ${
searchField === "name" searchField === "name"
? "bg-blue-600 text-white border-blue-600" ? "bg-blue-600 text-white border-blue-600"
: "bg-slate-50 text-slate-600 border-slate-200 hover:bg-slate-100" : "bg-slate-50 dark:bg-slate-850 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-800"
}`} }`}
> >
{lang === "en" ? "Name" : "Nombre"} {lang === "en" ? "Name" : "Nombre"}
@ -595,7 +590,7 @@ export default function CandidatesPage() {
className={`px-2 py-0.5 text-xs rounded transition font-medium border ${ className={`px-2 py-0.5 text-xs rounded transition font-medium border ${
searchField === "email" searchField === "email"
? "bg-blue-600 text-white border-blue-600" ? "bg-blue-600 text-white border-blue-600"
: "bg-slate-50 text-slate-600 border-slate-200 hover:bg-slate-100" : "bg-slate-50 dark:bg-slate-850 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-800"
}`} }`}
> >
{lang === "en" ? "Email" : "Correo"} {lang === "en" ? "Email" : "Correo"}
@ -605,7 +600,7 @@ export default function CandidatesPage() {
className={`px-2 py-0.5 text-xs rounded transition font-medium border ${ className={`px-2 py-0.5 text-xs rounded transition font-medium border ${
searchField === "skills" searchField === "skills"
? "bg-blue-600 text-white border-blue-600" ? "bg-blue-600 text-white border-blue-600"
: "bg-slate-50 text-slate-600 border-slate-200 hover:bg-slate-100" : "bg-slate-50 dark:bg-slate-850 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-800"
}`} }`}
> >
{lang === "en" ? "Skills" : "Habilidades"} {lang === "en" ? "Skills" : "Habilidades"}
@ -622,20 +617,20 @@ export default function CandidatesPage() {
onClick={() => setSelectedCandidate(candidate)} onClick={() => setSelectedCandidate(candidate)}
className={`w-full text-left p-3 rounded-md border text-sm transition duration-200 ${ className={`w-full text-left p-3 rounded-md border text-sm transition duration-200 ${
selectedCandidate?.id === candidate.id selectedCandidate?.id === candidate.id
? "border-blue-600 bg-slate-50 font-semibold" ? "border-blue-600 bg-slate-50 dark:bg-slate-800/50 dark:border-blue-500 font-semibold"
: "border-slate-200 hover:border-slate-300 bg-white" : "border-slate-200 dark:border-slate-800 hover:border-slate-300 dark:hover:border-slate-700 bg-white dark:bg-slate-900"
}`} }`}
> >
<div className="text-slate-900 font-medium truncate"> <div className="text-slate-900 dark:text-white font-medium truncate">
{candidate.name} {candidate.name}
</div> </div>
<div className="text-xs text-slate-500 truncate mt-0.5"> <div className="text-xs text-slate-500 dark:text-slate-400 truncate mt-0.5">
{candidate.contact_info.email} {candidate.contact_info.email}
</div> </div>
</button> </button>
))} ))}
{sortedCandidates.length === 0 && ( {sortedCandidates.length === 0 && (
<p className="text-xs text-slate-500 italic p-3 text-center"> <p className="text-xs text-slate-500 dark:text-slate-400 italic p-3 text-center">
{lang === "es" ? "No se encontraron candidatos" : "No candidates found"} {lang === "es" ? "No se encontraron candidatos" : "No candidates found"}
</p> </p>
)} )}
@ -645,25 +640,25 @@ export default function CandidatesPage() {
{/* RIGHT COLUMN: Detail Pane (2/3 width) */} {/* RIGHT COLUMN: Detail Pane (2/3 width) */}
<div className="md:col-span-2"> <div className="md:col-span-2">
{selectedCandidate ? ( {selectedCandidate ? (
<div className="bg-white p-6 rounded-lg shadow-sm border border-slate-200 flex flex-col gap-6"> <div className="bg-white dark:bg-slate-900 p-6 rounded-lg shadow-sm border border-slate-200 dark:border-slate-800 flex flex-col gap-6">
{/* Header Profile Details */} {/* Header Profile Details */}
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4 pb-4 border-b border-slate-200"> <div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4 pb-4 border-b border-slate-200 dark:border-slate-800">
<div> <div>
<h2 className="text-xl font-bold text-slate-900"> <h2 className="text-xl font-bold text-slate-900 dark:text-white">
{selectedCandidate.name} {selectedCandidate.name}
</h2> </h2>
<p className="text-xs text-slate-500 mt-1"> <p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
{t.createdDate}: {new Date(selectedCandidate.created_at).toLocaleDateString()} {t.createdDate}: {new Date(selectedCandidate.created_at).toLocaleDateString()}
</p> </p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1 mt-3 text-xs"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1 mt-3 text-xs">
<div> <div>
<span className="text-slate-500">{t.email}: </span> <span className="text-slate-500 dark:text-slate-400">{t.email}: </span>
<span className="text-slate-600 font-medium">{selectedCandidate.contact_info.email}</span> <span className="text-slate-600 dark:text-slate-300 font-medium">{selectedCandidate.contact_info.email}</span>
</div> </div>
<div> <div>
<span className="text-slate-500">{t.phone}: </span> <span className="text-slate-500 dark:text-slate-400">{t.phone}: </span>
<span className="text-slate-600 font-medium">{selectedCandidate.contact_info.phone || "N/A"}</span> <span className="text-slate-600 dark:text-slate-300 font-medium">{selectedCandidate.contact_info.phone || "N/A"}</span>
</div> </div>
</div> </div>
</div> </div>
@ -679,10 +674,10 @@ export default function CandidatesPage() {
{/* Summary Extracted */} {/* Summary Extracted */}
{selectedCandidate.contact_info.summary && ( {selectedCandidate.contact_info.summary && (
<div> <div>
<h3 className="text-xs font-semibold text-slate-500 uppercase tracking-wider block mb-2"> <h3 className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider block mb-2">
{t.professionalSummary} {t.professionalSummary}
</h3> </h3>
<p className="text-slate-600 text-sm leading-relaxed whitespace-pre-wrap"> <p className="text-slate-600 dark:text-slate-300 text-sm leading-relaxed whitespace-pre-wrap">
{selectedCandidate.contact_info.summary} {selectedCandidate.contact_info.summary}
</p> </p>
</div> </div>
@ -691,14 +686,14 @@ export default function CandidatesPage() {
{/* Skills tag group */} {/* Skills tag group */}
{selectedCandidate.contact_info.skills && selectedCandidate.contact_info.skills.length > 0 && ( {selectedCandidate.contact_info.skills && selectedCandidate.contact_info.skills.length > 0 && (
<div> <div>
<h3 className="text-xs font-semibold text-slate-500 uppercase tracking-wider block mb-2"> <h3 className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider block mb-2">
{t.skillsAndTech} {t.skillsAndTech}
</h3> </h3>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{selectedCandidate.contact_info.skills.map((skill) => ( {selectedCandidate.contact_info.skills.map((skill) => (
<span <span
key={skill} key={skill}
className="px-2 py-0.5 bg-slate-50 text-slate-600 text-xs rounded border border-slate-200 font-medium" className="px-2 py-0.5 bg-slate-50 dark:bg-slate-800 text-slate-600 dark:text-slate-300 text-xs rounded border border-slate-200 dark:border-slate-700 font-medium"
> >
{skill} {skill}
</span> </span>
@ -709,37 +704,37 @@ export default function CandidatesPage() {
{/* Linked Vacancies Section */} {/* Linked Vacancies Section */}
<div> <div>
<h3 className="text-xs font-semibold text-slate-500 uppercase tracking-wider block mb-3"> <h3 className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider block mb-3">
{t.linkedVacancies} {t.linkedVacancies}
</h3> </h3>
{linkedVacancies.length > 0 ? ( {linkedVacancies.length > 0 ? (
<div className="border border-slate-200 rounded-md overflow-hidden bg-white"> <div className="border border-slate-200 dark:border-slate-800 rounded-md overflow-hidden bg-white dark:bg-slate-900">
<table className="w-full text-left border-collapse"> <table className="w-full text-left border-collapse">
<thead> <thead>
<tr className="bg-slate-50 text-slate-500 text-xs border-b border-slate-200 font-semibold"> <tr className="bg-slate-50 dark:bg-slate-800 text-slate-500 dark:text-slate-400 text-xs border-b border-slate-200 dark:border-slate-800 font-semibold">
<th className="p-3 font-semibold">{t.jobTitle}</th> <th className="p-3 font-semibold">{t.jobTitle}</th>
<th className="p-3 font-semibold">{t.aiScore}</th> <th className="p-3 font-semibold">{t.aiScore}</th>
<th className="p-3 font-semibold">{t.classification}</th> <th className="p-3 font-semibold">{t.classification}</th>
<th className="p-3 font-semibold">{t.stage}</th> <th className="p-3 font-semibold">{t.stage}</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-200 text-sm"> <tbody className="divide-y divide-slate-200 dark:divide-slate-800 text-sm">
{linkedVacancies.map((vacancy) => ( {linkedVacancies.map((vacancy) => (
<tr key={vacancy.jobId} className="text-slate-600"> <tr key={vacancy.jobId} className="text-slate-600 dark:text-slate-300">
<td className="p-3 font-medium text-slate-900"> <td className="p-3 font-medium text-slate-900 dark:text-white">
{vacancy.jobTitle} {vacancy.jobTitle}
</td> </td>
<td className="p-3 font-semibold text-blue-600"> <td className="p-3 font-semibold text-blue-600 dark:text-blue-400">
{vacancy.aiScore !== null ? `${vacancy.aiScore} / 100` : "-"} {vacancy.aiScore !== null ? `${vacancy.aiScore} / 100` : "-"}
</td> </td>
<td className="p-3"> <td className="p-3">
{vacancy.classification ? ( {vacancy.classification ? (
<span className={`px-2 py-0.5 text-xs rounded-md font-semibold border ${ <span className={`px-2 py-0.5 text-xs rounded-md font-semibold border ${
vacancy.classification === "Qualified" vacancy.classification === "Qualified"
? "bg-green-50 text-green-700 border-green-200" ? "bg-green-50 dark:bg-green-950/30 text-green-700 dark:text-green-400 border-green-200 dark:border-green-900/50"
: vacancy.classification === "Review" : vacancy.classification === "Review"
? "bg-slate-50 text-slate-600 border-slate-200" ? "bg-slate-50 dark:bg-slate-800 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-700"
: "bg-red-50 text-red-700 border-red-200" : "bg-red-50 dark:bg-red-950/30 text-red-700 dark:text-red-400 border-red-200 dark:border-red-900/50"
}`}> }`}>
{translateClassification(vacancy.classification, lang)} {translateClassification(vacancy.classification, lang)}
</span> </span>
@ -749,7 +744,7 @@ export default function CandidatesPage() {
</td> </td>
<td className="p-3"> <td className="p-3">
{vacancy.stage ? ( {vacancy.stage ? (
<span className="px-2 py-0.5 text-xs rounded-md bg-blue-50 text-blue-700 border border-blue-200 font-semibold"> <span className="px-2 py-0.5 text-xs rounded-md bg-blue-50 dark:bg-blue-950/30 text-blue-700 dark:text-blue-400 border border-blue-200 dark:border-blue-900/50 font-semibold">
{translateStage(vacancy.stage, lang)} {translateStage(vacancy.stage, lang)}
</span> </span>
) : ( ) : (
@ -762,7 +757,7 @@ export default function CandidatesPage() {
</table> </table>
</div> </div>
) : ( ) : (
<p className="text-xs text-slate-500 italic"> <p className="text-xs text-slate-500 dark:text-slate-400 italic">
{t.noLinkedVacancies} {t.noLinkedVacancies}
</p> </p>
)} )}
@ -770,8 +765,8 @@ export default function CandidatesPage() {
</div> </div>
) : ( ) : (
<div className="bg-white p-12 rounded-lg shadow-sm border border-slate-200 flex flex-col items-center justify-center text-center"> <div className="bg-white dark:bg-slate-900 p-12 rounded-lg shadow-sm border border-slate-200 dark:border-slate-800 flex flex-col items-center justify-center text-center">
<p className="text-slate-600 font-semibold mb-2"> <p className="text-slate-600 dark:text-slate-300 font-semibold mb-2">
{t.noCandidateSelected} {t.noCandidateSelected}
</p> </p>
</div> </div>
@ -782,18 +777,18 @@ export default function CandidatesPage() {
{/* Delete Confirmation Modal */} {/* Delete Confirmation Modal */}
{showDeleteConfirm && ( {showDeleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/50 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/50 dark:bg-slate-950/70 backdrop-blur-sm">
<div className="bg-white rounded-lg shadow-md border border-slate-200 max-w-md w-full p-6"> <div className="bg-white dark:bg-slate-900 rounded-lg shadow-md border border-slate-200 dark:border-slate-800 max-w-md w-full p-6">
<h3 className="text-lg font-bold text-slate-900 mb-2"> <h3 className="text-lg font-bold text-slate-900 dark:text-white mb-2">
{t.confirmTitle} {t.confirmTitle}
</h3> </h3>
<p className="text-sm text-slate-600 mb-6"> <p className="text-sm text-slate-600 dark:text-slate-300 mb-6">
{t.deleteConfirmation} {t.deleteConfirmation}
</p> </p>
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<button <button
onClick={() => setShowDeleteConfirm(false)} onClick={() => setShowDeleteConfirm(false)}
className="px-4 py-2 border border-slate-200 rounded-md text-sm text-slate-600 bg-white hover:bg-slate-50 transition" className="px-4 py-2 border border-slate-200 dark:border-slate-700 rounded-md text-sm text-slate-600 dark:text-slate-300 bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700 transition"
> >
{t.cancel} {t.cancel}
</button> </button>
@ -810,55 +805,55 @@ export default function CandidatesPage() {
{/* Duplicate Detection dialog */} {/* Duplicate Detection dialog */}
{duplicateData && ( {duplicateData && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/50 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/50 dark:bg-slate-950/70 backdrop-blur-sm">
<div className="bg-white rounded-lg shadow-md border border-slate-200 max-w-xl w-full p-6 flex flex-col gap-4 max-h-[90vh] overflow-y-auto"> <div className="bg-white dark:bg-slate-900 rounded-lg shadow-md border border-slate-200 dark:border-slate-800 max-w-xl w-full p-6 flex flex-col gap-4 max-h-[90vh] overflow-y-auto">
<div> <div>
<h3 className="text-lg font-bold text-slate-900"> <h3 className="text-lg font-bold text-slate-900 dark:text-white">
{t.duplicateDetected} {t.duplicateDetected}
</h3> </h3>
<p className="text-xs text-slate-500 mt-1"> <p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
{t.duplicateMsg} ({duplicateData.fileName}) {t.duplicateMsg} ({duplicateData.fileName})
</p> </p>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Existing Profile */} {/* Existing Profile */}
<div className="border border-slate-200 rounded-md p-3 bg-slate-50 text-xs"> <div className="border border-slate-200 dark:border-slate-800 rounded-md p-3 bg-slate-50 dark:bg-slate-800/50 text-xs">
<h4 className="text-xs font-semibold text-slate-900 uppercase tracking-wider mb-2"> <h4 className="text-xs font-semibold text-slate-900 dark:text-white uppercase tracking-wider mb-2">
{t.existingProfile} {t.existingProfile}
</h4> </h4>
<div className="text-sm font-bold text-slate-900"> <div className="text-sm font-bold text-slate-900 dark:text-white">
{duplicateData.existingCandidate.name} {duplicateData.existingCandidate.name}
</div> </div>
<div className="text-xs text-slate-500 mt-1"> <div className="text-xs text-slate-500 dark:text-slate-400 mt-1">
Email: <span className="text-slate-600 font-medium">{duplicateData.existingCandidate.contact_info.email}</span> Email: <span className="text-slate-600 dark:text-slate-300 font-medium">{duplicateData.existingCandidate.contact_info.email}</span>
</div> </div>
<div className="text-xs text-slate-500"> <div className="text-xs text-slate-500 dark:text-slate-400">
Phone: <span className="text-slate-600 font-medium">{duplicateData.existingCandidate.contact_info.phone || "N/A"}</span> Phone: <span className="text-slate-600 dark:text-slate-300 font-medium">{duplicateData.existingCandidate.contact_info.phone || "N/A"}</span>
</div> </div>
{duplicateData.existingCandidate.contact_info.summary && ( {duplicateData.existingCandidate.contact_info.summary && (
<p className="text-slate-600 mt-2 line-clamp-3"> <p className="text-slate-600 dark:text-slate-300 mt-2 line-clamp-3">
{duplicateData.existingCandidate.contact_info.summary} {duplicateData.existingCandidate.contact_info.summary}
</p> </p>
)} )}
</div> </div>
{/* New Profile */} {/* New Profile */}
<div className="border border-slate-200 rounded-md p-3 bg-slate-50 text-xs"> <div className="border border-slate-200 dark:border-slate-800 rounded-md p-3 bg-slate-50 dark:bg-slate-800/50 text-xs">
<h4 className="text-xs font-semibold text-slate-900 uppercase tracking-wider mb-2"> <h4 className="text-xs font-semibold text-slate-900 dark:text-white uppercase tracking-wider mb-2">
{t.newProfile} {t.newProfile}
</h4> </h4>
<div className="text-sm font-bold text-slate-900"> <div className="text-sm font-bold text-slate-900 dark:text-white">
{duplicateData.newProfile.candidateName || duplicateData.newProfile.name || "Unknown"} {duplicateData.newProfile.candidateName || duplicateData.newProfile.name || "Unknown"}
</div> </div>
<div className="text-xs text-slate-500 mt-1"> <div className="text-xs text-slate-500 dark:text-slate-400 mt-1">
Email: <span className="text-slate-600 font-medium">{duplicateData.newProfile.email || "N/A"}</span> Email: <span className="text-slate-600 dark:text-slate-300 font-medium">{duplicateData.newProfile.email || "N/A"}</span>
</div> </div>
<div className="text-xs text-slate-500"> <div className="text-xs text-slate-500 dark:text-slate-400">
Phone: <span className="text-slate-600 font-medium">{duplicateData.newProfile.phone || "N/A"}</span> Phone: <span className="text-slate-600 dark:text-slate-300 font-medium">{duplicateData.newProfile.phone || "N/A"}</span>
</div> </div>
{duplicateData.newProfile.summary && ( {duplicateData.newProfile.summary && (
<p className="text-slate-600 mt-2 line-clamp-3"> <p className="text-slate-600 dark:text-slate-300 mt-2 line-clamp-3">
{duplicateData.newProfile.summary} {duplicateData.newProfile.summary}
</p> </p>
)} )}
@ -866,26 +861,26 @@ export default function CandidatesPage() {
</div> </div>
{/* AI Comparison Summary */} {/* AI Comparison Summary */}
<div className="border border-slate-200 rounded-md p-3 bg-blue-50"> <div className="border border-slate-200 dark:border-slate-800 rounded-md p-3 bg-blue-50 dark:bg-blue-950/20">
<h4 className="text-xs font-semibold text-blue-600 uppercase tracking-wider mb-2"> <h4 className="text-xs font-semibold text-blue-600 dark:text-blue-400 uppercase tracking-wider mb-2">
{t.aiComparison} {t.aiComparison}
</h4> </h4>
{duplicateData.comparison ? ( {duplicateData.comparison ? (
<p className="text-xs text-slate-600 leading-relaxed"> <p className="text-xs text-slate-600 dark:text-slate-300 leading-relaxed">
{getBilingualText(duplicateData.comparison, lang)} {getBilingualText(duplicateData.comparison, lang)}
</p> </p>
) : ( ) : (
<p className="text-xs text-slate-500 italic"> <p className="text-xs text-slate-500 dark:text-slate-400 italic">
{lang === "es" ? "Comparando perfiles con IA..." : "Comparing profiles with AI..."} {lang === "es" ? "Comparando perfiles con IA..." : "Comparing profiles with AI..."}
</p> </p>
)} )}
</div> </div>
{/* Actions */} {/* Actions */}
<div className="flex justify-end gap-2 pt-2 border-t border-slate-200"> <div className="flex justify-end gap-2 pt-2 border-t border-slate-200 dark:border-slate-800">
<button <button
onClick={() => duplicateData.onResolve("cancel")} onClick={() => duplicateData.onResolve("cancel")}
className="px-3 py-1.5 border border-slate-200 rounded-md text-xs text-slate-600 bg-white hover:bg-slate-50 transition" className="px-3 py-1.5 border border-slate-200 dark:border-slate-700 rounded-md text-xs text-slate-600 dark:text-slate-300 bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700 transition"
> >
{lang === "es" ? "Cancelar" : "Cancel"} {lang === "es" ? "Cancelar" : "Cancel"}
</button> </button>

View file

@ -2,6 +2,7 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { supabase } from "@/lib/supabase"; import { supabase } from "@/lib/supabase";
import { useApp } from "@/components/AppContext";
interface Interview { interface Interview {
id: string; id: string;
@ -19,7 +20,59 @@ interface Interview {
} | null; } | null;
} }
const translations = {
en: {
interviewsTitle: "Interviews",
interviewsSubtitle: "Manage scheduled candidate interview stages and write evaluation feedback.",
loadingInterviews: "Loading interviews...",
noInterviews: "No interviews scheduled. Upload candidate CVs under the Jobs tab to trigger evaluations.",
unknownCandidate: "Unknown Candidate",
unknownJob: "Unknown Job",
role: "Role",
dateScheduled: "Date Scheduled",
stage: "Stage",
interviewFeedback: "Interview Feedback",
feedbackPlaceholder: "Write detailed assessment feedback, questions, or observations...",
saving: "Saving...",
updateInterview: "Update Interview",
updateSuccess: "Interview updated successfully!",
updateFailed: "Failed to update interview.",
screening: "Screening",
technical: "Technical",
cultural: "Cultural",
offer: "Offer",
hired: "Hired",
rejected: "Rejected"
},
es: {
interviewsTitle: "Entrevistas",
interviewsSubtitle: "Gestione las etapas de entrevistas programadas de los candidatos y escriba comentarios de evaluación.",
loadingInterviews: "Cargando entrevistas...",
noInterviews: "No hay entrevistas programadas. Cargue los CV de los candidatos en la pestaña Vacantes para activar las evaluaciones.",
unknownCandidate: "Candidato Desconocido",
unknownJob: "Puesto Desconocido",
role: "Puesto",
dateScheduled: "Fecha Programada",
stage: "Etapa",
interviewFeedback: "Comentarios de la Entrevista",
feedbackPlaceholder: "Escriba comentarios detallados de la evaluación, preguntas u observaciones...",
saving: "Guardando...",
updateInterview: "Actualizar Entrevista",
updateSuccess: "¡Entrevista actualizada con éxito!",
updateFailed: "Error al actualizar la entrevista.",
screening: "Preselección",
technical: "Técnica",
cultural: "Cultural",
offer: "Oferta",
hired: "Contratado",
rejected: "Rechazado"
}
};
export default function InterviewsPage() { export default function InterviewsPage() {
const { lang } = useApp();
const t = translations[lang];
const [interviews, setInterviews] = useState<Interview[]>([]); const [interviews, setInterviews] = useState<Interview[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [updatingId, setUpdatingId] = useState<string | null>(null); const [updatingId, setUpdatingId] = useState<string | null>(null);
@ -97,7 +150,7 @@ export default function InterviewsPage() {
if (error) throw error; if (error) throw error;
setActionMessage("Interview updated successfully!"); setActionMessage(t.updateSuccess);
// Hide message after 3 seconds // Hide message after 3 seconds
setTimeout(() => setActionMessage(null), 3000); setTimeout(() => setActionMessage(null), 3000);
@ -111,7 +164,7 @@ export default function InterviewsPage() {
); );
} catch (err: unknown) { } catch (err: unknown) {
console.error("Error updating interview:", err); console.error("Error updating interview:", err);
setActionMessage("Failed to update interview."); setActionMessage(t.updateFailed);
} finally { } finally {
setUpdatingId(null); setUpdatingId(null);
} }
@ -121,26 +174,26 @@ export default function InterviewsPage() {
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div> <div>
<h1 className="text-2xl font-bold text-slate-900">Interviews</h1> <h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.interviewsTitle}</h1>
<p className="text-slate-600 text-sm"> <p className="text-slate-600 dark:text-slate-300 text-sm">
Manage scheduled candidate interview stages and write evaluation feedback. {t.interviewsSubtitle}
</p> </p>
</div> </div>
{actionMessage && ( {actionMessage && (
<div className="px-4 py-2 bg-slate-50 border border-slate-200 rounded-md text-xs font-semibold text-slate-600"> <div className="px-4 py-2 bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-md text-xs font-semibold text-slate-600 dark:text-slate-300">
{actionMessage} {actionMessage}
</div> </div>
)} )}
</div> </div>
{loading ? ( {loading ? (
<div className="bg-white p-6 rounded-lg shadow-sm border border-slate-200"> <div className="bg-white dark:bg-slate-900 p-6 rounded-lg shadow-sm border border-slate-200 dark:border-slate-800">
<p className="text-slate-500 text-sm">Loading interviews...</p> <p className="text-slate-500 dark:text-slate-400 text-sm">{t.loadingInterviews}</p>
</div> </div>
) : interviews.length === 0 ? ( ) : interviews.length === 0 ? (
<div className="bg-white p-6 rounded-lg shadow-sm border border-slate-200"> <div className="bg-white dark:bg-slate-900 p-6 rounded-lg shadow-sm border border-slate-200 dark:border-slate-800">
<p className="text-slate-500 text-sm"> <p className="text-slate-500 dark:text-slate-400 text-sm">
No interviews scheduled. Upload candidate CVs under the Jobs tab to trigger evaluations. {t.noInterviews}
</p> </p>
</div> </div>
) : ( ) : (
@ -154,47 +207,47 @@ export default function InterviewsPage() {
return ( return (
<div <div
key={interview.id} key={interview.id}
className="bg-white p-6 rounded-lg shadow-sm border border-slate-200 flex flex-col gap-4" className="bg-white dark:bg-slate-900 p-6 rounded-lg shadow-sm border border-slate-200 dark:border-slate-800 flex flex-col gap-4"
> >
{/* Header */} {/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-4 border-b border-slate-200"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-4 border-b border-slate-200 dark:border-slate-800">
<div> <div>
<h2 className="text-base font-bold text-slate-900"> <h2 className="text-base font-bold text-slate-900 dark:text-white">
{interview.candidates?.name || "Unknown Candidate"} {interview.candidates?.name || t.unknownCandidate}
</h2> </h2>
<p className="text-sm text-slate-600 font-medium"> <p className="text-sm text-slate-600 dark:text-slate-300 font-medium">
Role: {interview.jobs?.title || "Unknown Job"} {t.role}: {interview.jobs?.title || t.unknownJob}
</p> </p>
<p className="text-xs text-slate-500 mt-1"> <p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
Date Scheduled:{" "} {t.dateScheduled}:{" "}
{new Date(interview.interview_date).toLocaleString()} {new Date(interview.interview_date).toLocaleString()}
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider"> <label className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">
Stage: {t.stage}:
</label> </label>
<select <select
value={currentEdit.stage} value={currentEdit.stage}
onChange={(e) => onChange={(e) =>
handleStateChange(interview.id, "stage", e.target.value) handleStateChange(interview.id, "stage", e.target.value)
} }
className="px-2 py-1 text-sm bg-white border border-slate-200 rounded-md text-slate-900 focus:outline-none" className="px-2 py-1 text-sm bg-white dark:bg-slate-850 border border-slate-200 dark:border-slate-700 rounded-md text-slate-900 dark:text-white focus:outline-none"
> >
<option value="Screening">Screening</option> <option value="Screening">{t.screening}</option>
<option value="Technical">Technical</option> <option value="Technical">{t.technical}</option>
<option value="Cultural">Cultural</option> <option value="Cultural">{t.cultural}</option>
<option value="Offer">Offer</option> <option value="Offer">{t.offer}</option>
<option value="Hired">Hired</option> <option value="Hired">{t.hired}</option>
<option value="Rejected">Rejected</option> <option value="Rejected">{t.rejected}</option>
</select> </select>
</div> </div>
</div> </div>
{/* Feedback Area */} {/* Feedback Area */}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider"> <label className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">
Interview Feedback {t.interviewFeedback}
</label> </label>
<textarea <textarea
value={currentEdit.feedback} value={currentEdit.feedback}
@ -205,9 +258,9 @@ export default function InterviewsPage() {
e.target.value e.target.value
) )
} }
placeholder="Write detailed assessment feedback, questions, or observations..." placeholder={t.feedbackPlaceholder}
rows={3} rows={3}
className="w-full px-3 py-2 border border-slate-200 rounded-md text-slate-900 bg-white placeholder:text-slate-500 text-sm focus:outline-none" className="w-full px-3 py-2 border border-slate-200 dark:border-slate-700 rounded-md text-slate-900 dark:text-white bg-white dark:bg-slate-850 placeholder:text-slate-500 dark:placeholder:text-slate-400 text-sm focus:outline-none"
/> />
</div> </div>
@ -218,7 +271,7 @@ export default function InterviewsPage() {
disabled={updatingId === interview.id} disabled={updatingId === interview.id}
className="py-2 px-4 bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold rounded-md transition duration-200 disabled:opacity-50" className="py-2 px-4 bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold rounded-md transition duration-200 disabled:opacity-50"
> >
{updatingId === interview.id ? "Saving..." : "Update Interview"} {updatingId === interview.id ? t.saving : t.updateInterview}
</button> </button>
</div> </div>
</div> </div>

View file

@ -1,6 +1,7 @@
"use client"; "use client";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { useApp } from "@/components/AppContext";
interface Job { interface Job {
id: string; id: string;
@ -102,7 +103,131 @@ interface DuplicateState {
onResolve: (action: "overwrite" | "ignore" | "cancel") => void; onResolve: (action: "overwrite" | "ignore" | "cancel") => void;
} }
const translations = {
en: {
createVacancy: "Create Vacancy",
jobTitle: "Job Title",
jobTitlePlaceholder: "e.g., Senior React Developer",
requirementsText: "Requirements text",
requirementsPlaceholder: "Describe key candidate qualifications and tech stack...",
creating: "Creating...",
createVacancyBtn: "Create Vacancy",
jobVacancies: "Job Vacancies",
loadingJobs: "Loading jobs...",
noVacancies: "No vacancies created yet.",
createdDate: "Created",
vacancyDetails: "Vacancy Details",
vacancyId: "ID",
description: "Description",
extractedSkills: "Extracted Job Keywords / Required Skills",
noSkillsExtracted: "No required skills extracted for this job yet.",
uploadCvTitle: "Upload Candidate CV for this Vacancy (PDF)",
uploadCvDesc: "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.",
uploadingButton: "Uploading CVs...",
uploadButton: "Choose CV Files",
uploadProgress: "Upload Progress",
success: "Success",
error: "Error",
uploading: "Uploading...",
showingMatches: "Showing {count} qualified matches",
unevaluatedCount: "({count} unevaluated)",
bulkRunAi: "Bulk Run AI Evaluation ({count})",
evaluating: "Evaluating...",
findingMatches: "Finding matches...",
noActiveMatches: "No active potential matches found.",
noActiveMatchesDesc: "Upload CVs or check the mismatch/unqualified list below.",
mismatchedOrUnqualified: "Mismatched or Unqualified Candidates",
selectVacancyToGetStarted: "Select or create a job vacancy to get started",
selectVacancyToGetStartedDesc: "Use the sidebar panel to choose a vacancy or fill in the form to establish a new open position.",
potentialMatch: "Potential Match ({pct}% overlap)",
skillMismatch: "Skill Mismatch ({pct}% overlap)",
semanticSimilarity: "Semantic: {pct}%",
skillsCheck: "Skills Check: {count} of {total} matching",
missing: "missing",
aiAssessmentResult: "AI ASSESSMENT RESULT",
decision: "Decision",
score: "Score",
readyForDeepAssessment: "Ready for deep assessment. Only potential matches recommended for LLM budget optimization.",
reRunAi: "Re-run AI",
runAiEvaluation: "Run AI Evaluation",
promoted: "Promoted",
promoteToInterviews: "Promote to Interviews",
duplicateDetected: "Duplicate Candidate Detected",
duplicateMsg: "The system detected an existing candidate with the same email or name.",
existingProfile: "Existing Profile",
newProfile: "Newly Uploaded Profile",
aiComparison: "AI Comparison Summary",
comparingWithAi: "Comparing profiles with AI...",
cancel: "Cancel",
keepBoth: "Keep Both",
overwrite: "Overwrite",
requiredFields: "All fields are required"
},
es: {
createVacancy: "Crear Vacante",
jobTitle: "Título del Puesto",
jobTitlePlaceholder: "ej., Desarrollador Senior React",
requirementsText: "Texto de requisitos",
requirementsPlaceholder: "Describa las cualificaciones clave del candidato y el stack tecnológico...",
creating: "Creando...",
createVacancyBtn: "Crear Vacante",
jobVacancies: "Vacantes de Empleo",
loadingJobs: "Cargando puestos...",
noVacancies: "Aún no se han creado vacantes.",
createdDate: "Creado",
vacancyDetails: "Detalles de la Vacante",
vacancyId: "ID",
description: "Descripción",
extractedSkills: "Palabras Clave Extraídas / Habilidades Requeridas",
noSkillsExtracted: "Aún no se han extraído habilidades requeridas para este puesto.",
uploadCvTitle: "Cargar CV de Candidato para esta Vacante (PDF)",
uploadCvDesc: "Al cargar el CV de un candidato se analiza el texto y se extraen sus habilidades/perfil. Se asocia con este puesto, lo que permite verificar la coincidencia de habilidades antes de ejecutar el modelo de puntuación de IA profunda.",
uploadingButton: "Cargando CVs...",
uploadButton: "Elegir Archivos de CV",
uploadProgress: "Progreso de Carga",
success: "Éxito",
error: "Error",
uploading: "Cargando...",
showingMatches: "Mostrando {count} coincidencias calificadas",
unevaluatedCount: "({count} sin evaluar)",
bulkRunAi: "Evaluación Masiva de IA ({count})",
evaluating: "Evaluando...",
findingMatches: "Buscando coincidencias...",
noActiveMatches: "No se encontraron coincidencias potenciales activas.",
noActiveMatchesDesc: "Cargue CVs o revise la lista de no coincidentes/no calificados a continuación.",
mismatchedOrUnqualified: "Candidatos No Coincidentes o No Calificados",
selectVacancyToGetStarted: "Seleccione o cree una vacante de empleo para comenzar",
selectVacancyToGetStartedDesc: "Use el panel lateral para elegir una vacante o complete el formulario para establecer un nuevo puesto abierto.",
potentialMatch: "Coincidencia Potencial ({pct}% coincidencia)",
skillMismatch: "Falta de Coincidencia ({pct}% coincidencia)",
semanticSimilarity: "Semántico: {pct}%",
skillsCheck: "Verificación: {count} de {total} coincidentes",
missing: "falta",
aiAssessmentResult: "RESULTADO DE LA EVALUACIÓN DE IA",
decision: "Decisión",
score: "Puntaje",
readyForDeepAssessment: "Listo para evaluación profunda. Solo se recomiendan coincidencias potenciales para optimización del presupuesto de LLM.",
reRunAi: "Re-evaluar IA",
runAiEvaluation: "Evaluar con IA",
promoted: "Promocionado",
promoteToInterviews: "Promocionar a Entrevistas",
duplicateDetected: "Candidato Duplicado Detectado",
duplicateMsg: "El sistema detectó un candidato existente con el mismo correo o nombre.",
existingProfile: "Perfil Existente",
newProfile: "Nuevo Perfil Cargado",
aiComparison: "Resumen de Comparación de IA",
comparingWithAi: "Comparando perfiles con IA...",
cancel: "Cancelar",
keepBoth: "Conservar Ambos",
overwrite: "Sobrescribir",
requiredFields: "Todos los campos son obligatorios"
}
};
export default function JobsPage() { export default function JobsPage() {
const { lang } = useApp();
const t = translations[lang];
const [jobs, setJobs] = useState<Job[]>([]); const [jobs, setJobs] = useState<Job[]>([]);
const [selectedJob, setSelectedJob] = useState<Job | null>(null); const [selectedJob, setSelectedJob] = useState<Job | null>(null);
const [matches, setMatches] = useState<Candidate[]>([]); const [matches, setMatches] = useState<Candidate[]>([]);
@ -498,55 +623,55 @@ export default function JobsPage() {
{/* Left Column: Create Form & Vacancies List */} {/* Left Column: Create Form & Vacancies List */}
<div className="lg:col-span-1 flex flex-col gap-6"> <div className="lg:col-span-1 flex flex-col gap-6">
{/* Create vacancy form */} {/* Create vacancy form */}
<div className="bg-white p-6 rounded-lg shadow-sm border border-slate-200"> <div className="bg-white dark:bg-slate-900 p-6 rounded-lg shadow-sm border border-slate-200 dark:border-slate-800">
<h2 className="text-lg font-bold text-slate-900 mb-4">Create Vacancy</h2> <h2 className="text-lg font-bold text-slate-900 dark:text-white mb-4">{t.createVacancy}</h2>
<form onSubmit={handleCreateJob} className="flex flex-col gap-4"> <form onSubmit={handleCreateJob} className="flex flex-col gap-4">
<div> <div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1"> <label className="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider mb-1">
Job Title {t.jobTitle}
</label> </label>
<input <input
type="text" type="text"
value={newTitle} value={newTitle}
onChange={(e) => setNewTitle(e.target.value)} onChange={(e) => setNewTitle(e.target.value)}
placeholder="e.g., Senior React Developer" placeholder={t.jobTitlePlaceholder}
className="w-full px-3 py-2 border border-slate-200 rounded-md text-slate-900 bg-white placeholder:text-slate-500 text-sm focus:outline-none" className="w-full px-3 py-2 border border-slate-200 dark:border-slate-700 rounded-md text-slate-900 dark:text-white bg-white dark:bg-slate-800 placeholder:text-slate-500 dark:placeholder:text-slate-400 text-sm focus:outline-none"
required required
/> />
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1"> <label className="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider mb-1">
Requirements text {t.requirementsText}
</label> </label>
<textarea <textarea
value={newRequirements} value={newRequirements}
onChange={(e) => setNewRequirements(e.target.value)} onChange={(e) => setNewRequirements(e.target.value)}
placeholder="Describe key candidate qualifications and tech stack..." placeholder={t.requirementsPlaceholder}
rows={4} rows={4}
className="w-full px-3 py-2 border border-slate-200 rounded-md text-slate-900 bg-white placeholder:text-slate-500 text-sm focus:outline-none" className="w-full px-3 py-2 border border-slate-200 dark:border-slate-700 rounded-md text-slate-900 dark:text-white bg-white dark:bg-slate-800 placeholder:text-slate-500 dark:placeholder:text-slate-400 text-sm focus:outline-none"
required required
/> />
</div> </div>
{formError && ( {formError && (
<p className="text-xs text-red-600 font-semibold">{formError}</p> <p className="text-xs text-red-600 dark:text-red-400 font-semibold">{formError}</p>
)} )}
<button <button
type="submit" type="submit"
disabled={isSubmitting} disabled={isSubmitting}
className="w-full py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold rounded-md transition duration-200 disabled:opacity-50" className="w-full py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold rounded-md transition duration-200 disabled:opacity-50"
> >
{isSubmitting ? "Creating..." : "Create Vacancy"} {isSubmitting ? t.creating : t.createVacancyBtn}
</button> </button>
</form> </form>
</div> </div>
{/* Vacancy list */} {/* Vacancy list */}
<div className="bg-white p-6 rounded-lg shadow-sm border border-slate-200 flex-1"> <div className="bg-white dark:bg-slate-900 p-6 rounded-lg shadow-sm border border-slate-200 dark:border-slate-800 flex-1">
<h2 className="text-lg font-bold text-slate-900 mb-4">Job Vacancies</h2> <h2 className="text-lg font-bold text-slate-900 dark:text-white mb-4">{t.jobVacancies}</h2>
{loadingJobs ? ( {loadingJobs ? (
<p className="text-slate-500 text-sm">Loading jobs...</p> <p className="text-slate-500 dark:text-slate-400 text-sm">{t.loadingJobs}</p>
) : jobs.length === 0 ? ( ) : jobs.length === 0 ? (
<p className="text-slate-500 text-sm">No vacancies created yet.</p> <p className="text-slate-500 dark:text-slate-400 text-sm">{t.noVacancies}</p>
) : ( ) : (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{jobs.map((job) => ( {jobs.map((job) => (
@ -559,13 +684,13 @@ export default function JobsPage() {
}} }}
className={`w-full text-left p-3 rounded-md border text-sm transition duration-200 ${ className={`w-full text-left p-3 rounded-md border text-sm transition duration-200 ${
selectedJob?.id === job.id selectedJob?.id === job.id
? "border-blue-600 bg-slate-50 font-semibold" ? "border-blue-600 bg-slate-50 dark:bg-slate-800/50 dark:border-blue-500 font-semibold text-slate-900 dark:text-white"
: "border-slate-200 hover:border-slate-300" : "border-slate-200 dark:border-slate-800 hover:border-slate-300 dark:hover:border-slate-700 text-slate-900 dark:text-slate-300 bg-white dark:bg-slate-900"
}`} }`}
> >
<div className="text-slate-900">{job.title}</div> <div className="font-semibold">{job.title}</div>
<div className="text-xs text-slate-500 mt-1"> <div className="text-xs text-slate-500 dark:text-slate-400 mt-1">
Created: {new Date(job.created_at).toLocaleDateString()} {t.createdDate}: {new Date(job.created_at).toLocaleDateString()}
</div> </div>
</button> </button>
))} ))}
@ -577,40 +702,40 @@ export default function JobsPage() {
{/* Right Column: Selected Job Details & Candidate Match */} {/* Right Column: Selected Job Details & Candidate Match */}
<div className="lg:col-span-2"> <div className="lg:col-span-2">
{selectedJob ? ( {selectedJob ? (
<div className="bg-white p-6 rounded-lg shadow-sm border border-slate-200 flex flex-col gap-6"> <div className="bg-white dark:bg-slate-900 p-6 rounded-lg shadow-sm border border-slate-200 dark:border-slate-800 flex flex-col gap-6">
{/* Header */} {/* Header */}
<div> <div>
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wider"> <div className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">
Vacancy Details {t.vacancyDetails}
</div> </div>
<h1 className="text-2xl font-bold text-slate-900 mt-1"> <h1 className="text-2xl font-bold text-slate-900 dark:text-white mt-1">
{selectedJob.title} {selectedJob.title}
</h1> </h1>
<p className="text-xs text-slate-500 mt-1"> <p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
ID: {selectedJob.id} {t.vacancyId}: {selectedJob.id}
</p> </p>
</div> </div>
{/* Requirements & Extracted Job Skills */} {/* Requirements & Extracted Job Skills */}
<div className="p-4 bg-slate-50 rounded-md border border-slate-200 flex flex-col gap-3"> <div className="p-4 bg-slate-50 dark:bg-slate-800/50 rounded-md border border-slate-200 dark:border-slate-800 flex flex-col gap-3">
<div> <div>
<h3 className="text-sm font-semibold text-slate-900 mb-1"> <h3 className="text-sm font-semibold text-slate-900 dark:text-white mb-1">
Description {t.description}
</h3> </h3>
<p className="text-slate-600 text-sm whitespace-pre-wrap leading-relaxed"> <p className="text-slate-600 dark:text-slate-300 text-sm whitespace-pre-wrap leading-relaxed">
{selectedJob.requirements.text} {selectedJob.requirements.text}
</p> </p>
</div> </div>
{selectedJob.requirements.skills && selectedJob.requirements.skills.length > 0 && ( {selectedJob.requirements.skills && selectedJob.requirements.skills.length > 0 && (
<div> <div>
<h3 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1.5"> <h3 className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider mb-1.5">
Extracted Job Keywords / Required Skills {t.extractedSkills}
</h3> </h3>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{selectedJob.requirements.skills.map((skill) => ( {selectedJob.requirements.skills.map((skill) => (
<span <span
key={skill} key={skill}
className="px-2 py-0.5 bg-white border border-slate-200 text-slate-700 text-xs rounded-md font-medium" className="px-2 py-0.5 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 text-slate-700 dark:text-slate-300 text-xs rounded-md font-medium"
> >
{skill} {skill}
</span> </span>
@ -621,15 +746,15 @@ export default function JobsPage() {
</div> </div>
{/* PDF Uploader */} {/* PDF Uploader */}
<div className="border border-dashed border-slate-200 rounded-lg p-6 flex flex-col items-center justify-center text-center"> <div className="border border-dashed border-slate-200 dark:border-slate-800 rounded-lg p-6 flex flex-col items-center justify-center text-center">
<h3 className="text-sm font-semibold text-slate-900 mb-1"> <h3 className="text-sm font-semibold text-slate-900 dark:text-white mb-1">
Upload Candidate CV for this Vacancy (PDF) {t.uploadCvTitle}
</h3> </h3>
<p className="text-xs text-slate-500 mb-4 max-w-md"> <p className="text-xs text-slate-500 dark:text-slate-400 mb-4 max-w-md">
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. {t.uploadCvDesc}
</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 ? "Uploading CVs..." : "Choose CV Files"} {uploading ? t.uploadingButton : t.uploadButton}
<input <input
type="file" type="file"
accept=".pdf" accept=".pdf"
@ -640,46 +765,46 @@ export default function JobsPage() {
/> />
</label> </label>
{uploadError && ( {uploadError && (
<p className="text-xs text-red-600 mt-3 font-semibold"> <p className="text-xs text-red-600 dark:text-red-400 mt-3 font-semibold">
{uploadError} {uploadError}
</p> </p>
)} )}
{uploadSuccess && ( {uploadSuccess && (
<p className="text-xs text-green-600 mt-3 font-semibold"> <p className="text-xs text-green-600 dark:text-green-400 mt-3 font-semibold">
{uploadSuccess} {uploadSuccess}
</p> </p>
)} )}
{uploadStatuses.length > 0 && ( {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"> <div className="mt-4 w-full max-w-md border border-slate-200 dark:border-slate-800 rounded-md p-4 bg-slate-50 dark:bg-slate-800/50 text-left">
<h4 className="text-xs font-semibold text-slate-900 mb-2 uppercase tracking-wider"> <h4 className="text-xs font-semibold text-slate-900 dark:text-white mb-2 uppercase tracking-wider">
Upload Progress {t.uploadProgress}
</h4> </h4>
<ul className="divide-y divide-slate-200"> <ul className="divide-y divide-slate-200 dark:divide-slate-800">
{uploadStatuses.map((item, idx) => ( {uploadStatuses.map((item, idx) => (
<li key={idx} className="py-2 flex flex-col gap-1 text-xs"> <li key={idx} className="py-2 flex flex-col gap-1 text-xs">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="font-medium text-slate-700 truncate max-w-[250px]" title={item.name}> <span className="font-medium text-slate-700 dark:text-slate-300 truncate max-w-[250px]" title={item.name}>
{item.name} {item.name}
</span> </span>
{item.status === "uploading" && ( {item.status === "uploading" && (
<span className="text-slate-600 font-semibold flex items-center gap-1"> <span className="text-slate-600 dark:text-slate-400 font-semibold flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-slate-400 animate-pulse"></span> <span className="w-1.5 h-1.5 rounded-full bg-slate-400 animate-pulse"></span>
Uploading... {t.uploading}
</span> </span>
)} )}
{item.status === "success" && ( {item.status === "success" && (
<span className="text-green-600 font-semibold flex items-center gap-1"> <span className="text-green-600 dark:text-green-400 font-semibold flex items-center gap-1">
Success {t.success}
</span> </span>
)} )}
{item.status === "error" && ( {item.status === "error" && (
<span className="text-red-600 font-semibold flex items-center gap-1"> <span className="text-red-600 dark:text-red-400 font-semibold flex items-center gap-1">
Error {t.error}
</span> </span>
)} )}
</div> </div>
{item.errorMessage && ( {item.errorMessage && (
<p className="text-red-600 font-normal mt-0.5">{item.errorMessage}</p> <p className="text-red-650 dark:text-red-400 font-normal mt-0.5">{item.errorMessage}</p>
)} )}
</li> </li>
))} ))}
@ -747,17 +872,17 @@ export default function JobsPage() {
return ( return (
<div <div
key={match.id} key={match.id}
className="p-5 rounded-lg border border-slate-200 bg-white flex flex-col gap-4 shadow-sm hover:border-slate-300 transition duration-200" className="p-5 rounded-lg border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 flex flex-col gap-4 shadow-sm hover:border-slate-300 dark:hover:border-slate-705 transition duration-200"
> >
{/* Upper info panel */} {/* Upper info panel */}
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4"> <div className="flex flex-col md:flex-row md:items-start justify-between gap-4">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="text-slate-900 font-bold text-base"> <div className="text-slate-900 dark:text-white font-bold text-base">
{match.name} {match.name}
</div> </div>
<div className="text-xs text-slate-500"> <div className="text-xs text-slate-500 dark:text-slate-400">
Email: <span className="text-slate-700 font-medium mr-3">{match.contact_info.email}</span> Email: <span className="text-slate-700 dark:text-slate-300 font-medium mr-3">{match.contact_info.email}</span>
Phone: <span className="text-slate-700 font-medium">{match.contact_info.phone}</span> Phone: <span className="text-slate-700 dark:text-slate-300 font-medium">{match.contact_info.phone}</span>
</div> </div>
</div> </div>
@ -766,28 +891,28 @@ export default function JobsPage() {
<span <span
className={`px-2.5 py-1 text-xs font-semibold rounded-md border ${ className={`px-2.5 py-1 text-xs font-semibold rounded-md border ${
isPotentialMatch isPotentialMatch
? "bg-green-50 text-green-700 border-green-200" ? "bg-green-50 dark:bg-green-950/30 text-green-700 dark:text-green-400 border-green-200 dark:border-green-900/50"
: "bg-slate-50 text-slate-500 border-slate-200" : "bg-slate-50 dark:bg-slate-800 text-slate-500 dark:text-slate-400 border-slate-200 dark:border-slate-700"
}`} }`}
> >
{isPotentialMatch {isPotentialMatch
? `Potential Match (${matchPct}% overlap)` ? t.potentialMatch.replace("{pct}", String(matchPct))
: `Skill Mismatch (${matchPct}% overlap)`} : t.skillMismatch.replace("{pct}", String(matchPct))}
</span> </span>
{/* Semantic embedding similarity badge */} {/* Semantic embedding similarity badge */}
{similarityPct !== null && ( {similarityPct !== null && (
<span className="px-2.5 py-1 text-xs font-semibold rounded-md border bg-blue-50 text-blue-700 border-blue-200"> <span className="px-2.5 py-1 text-xs font-semibold rounded-md border bg-blue-50 dark:bg-blue-950/30 text-blue-700 dark:text-blue-400 border-blue-200 dark:border-blue-900/50">
Semantic: {similarityPct}% {t.semanticSimilarity.replace("{pct}", String(similarityPct))}
</span> </span>
)} )}
</div> </div>
</div> </div>
{/* Skills overlap details */} {/* Skills overlap details */}
<div className="bg-slate-50 p-3 rounded-md border border-slate-100 flex flex-col gap-2"> <div className="bg-slate-50 dark:bg-slate-800/50 p-3 rounded-md border border-slate-100 dark:border-slate-800 flex flex-col gap-2">
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wider"> <div className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">
Skills Check: {overlapCount} of {totalRequired} matching {t.skillsCheck.replace("{count}", String(overlapCount)).replace("{total}", String(totalRequired))}
</div> </div>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
@ -795,7 +920,7 @@ export default function JobsPage() {
{matchedSkills.map((skill: string) => ( {matchedSkills.map((skill: string) => (
<span <span
key={skill} key={skill}
className="px-2 py-0.5 bg-green-100 text-green-800 border border-green-200 text-xs rounded-md font-medium" className="px-2 py-0.5 bg-green-100 dark:bg-green-955 text-green-800 dark:text-green-300 border border-green-200 dark:border-green-800 text-xs rounded-md font-medium"
> >
{skill} {skill}
</span> </span>
@ -805,41 +930,41 @@ export default function JobsPage() {
{missingSkills.map((skill: string) => ( {missingSkills.map((skill: string) => (
<span <span
key={skill} key={skill}
className="px-2 py-0.5 bg-white border border-slate-200 border-dashed text-slate-400 text-xs rounded-md" className="px-2 py-0.5 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-850 border-dashed text-slate-400 dark:text-slate-500 text-xs rounded-md"
> >
{skill} (missing) {skill} ({t.missing})
</span> </span>
))} ))}
{/* Fallback if no skills are loaded */} {/* Fallback if no skills are loaded */}
{jobSkills.length === 0 && ( {jobSkills.length === 0 && (
<span className="text-xs text-slate-500 italic"> <span className="text-xs text-slate-500 dark:text-slate-400 italic">
No required skills extracted for this job yet. {t.noSkillsExtracted}
</span> </span>
)} )}
</div> </div>
</div> </div>
{/* Bottom evaluation / action panel */} {/* Bottom evaluation / action panel */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pt-3 border-t border-slate-100"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pt-3 border-t border-slate-100 dark:border-slate-800">
<div className="flex-1"> <div className="flex-1">
{latestScore ? ( {latestScore ? (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="text-xs text-slate-500"> <div className="text-xs text-slate-500 dark:text-slate-400 uppercase tracking-wider font-semibold">
AI ASSESSMENT RESULT {t.aiAssessmentResult}
</div> </div>
<div className="text-sm text-slate-700 font-medium"> <div className="text-sm text-slate-700 dark:text-slate-300 font-medium">
Decision: <span className="font-bold text-slate-900">{latestScore.evaluation.classification}</span> {t.decision}: <span className="font-bold text-slate-900 dark:text-white">{latestScore.evaluation.classification}</span>
<span className="mx-2 font-normal text-slate-300">|</span> <span className="mx-2 font-normal text-slate-300 dark:text-slate-700">|</span>
Score: <span className="font-bold text-blue-600 text-base">{latestScore.ai_score} / 100</span> {t.score}: <span className="font-bold text-blue-600 dark:text-blue-400 text-base">{latestScore.ai_score} / 100</span>
</div> </div>
<div className="text-xs text-slate-500 leading-normal max-w-lg mt-1"> <div className="text-xs text-slate-500 dark:text-slate-400 leading-normal max-w-lg mt-1">
{latestScore.evaluation.summary} {latestScore.evaluation.summary}
</div> </div>
</div> </div>
) : ( ) : (
<div className="text-xs text-slate-500 italic"> <div className="text-xs text-slate-500 dark:text-slate-400 italic">
Ready for deep assessment. Only potential matches recommended for LLM budget optimization. {t.readyForDeepAssessment}
</div> </div>
)} )}
</div> </div>
@ -849,19 +974,19 @@ export default function JobsPage() {
<button <button
onClick={() => handleEvaluate(match.id)} onClick={() => handleEvaluate(match.id)}
disabled={evaluatingIds[match.id] || isBulkEvaluating} disabled={evaluatingIds[match.id] || isBulkEvaluating}
className="px-3 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-700 text-xs font-semibold rounded-md border border-slate-200 transition duration-200 disabled:opacity-50" className="px-3 py-1.5 bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 text-xs font-semibold rounded-md border border-slate-200 dark:border-slate-700 transition duration-200 disabled:opacity-50"
> >
{evaluatingIds[match.id] {evaluatingIds[match.id]
? "Evaluating..." ? t.evaluating
: latestScore : latestScore
? "Re-run AI" ? t.reRunAi
: "Run AI Evaluation"} : t.runAiEvaluation}
</button> </button>
{/* Promote to Interview Pipeline */} {/* Promote to Interview Pipeline */}
{match.interview ? ( {match.interview ? (
<span className="px-3 py-1.5 bg-green-50 border border-green-200 text-green-700 text-xs font-semibold rounded-md"> <span className="px-3 py-1.5 bg-green-50 dark:bg-green-955/30 border border-green-200 dark:border-green-900/50 text-green-700 dark:text-green-400 text-xs font-semibold rounded-md">
Promoted ({match.interview.stage}) {t.promoted} ({match.interview.stage})
</span> </span>
) : ( ) : (
<button <button
@ -872,16 +997,16 @@ export default function JobsPage() {
isUnqualified || isUnqualified ||
!isPotentialMatch !isPotentialMatch
} }
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-xs font-semibold rounded-md transition duration-200 disabled:opacity-50 disabled:bg-slate-100 disabled:text-slate-400 disabled:border disabled:border-slate-200" className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-xs font-semibold rounded-md transition duration-200 disabled:opacity-50 disabled:bg-slate-100 dark:disabled:bg-slate-800 disabled:text-slate-400 dark:disabled:text-slate-600 disabled:border disabled:border-slate-200 dark:disabled:border-slate-755"
title={ title={
isUnqualified isUnqualified
? "Cannot promote unqualified candidates" ? (lang === "es" ? "No se pueden promocionar candidatos no calificados" : "Cannot promote unqualified candidates")
: !isPotentialMatch : !isPotentialMatch
? "Skill overlap too low to promote" ? (lang === "es" ? "Coincidencia de habilidades muy baja para promocionar" : "Skill overlap too low to promote")
: "Promote to Interviews" : (lang === "es" ? "Promocionar a Entrevistas" : "Promote to Interviews")
} }
> >
{promotingIds[match.id] ? "Promoting..." : "Promote to Interviews"} {promotingIds[match.id] ? (lang === "es" ? "Promocionando..." : "Promoting...") : t.promoteToInterviews}
</button> </button>
)} )}
</div> </div>
@ -893,14 +1018,14 @@ export default function JobsPage() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{/* Toolbar / Header */} {/* Toolbar / Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-4 border-b border-slate-200"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-4 border-b border-slate-200 dark:border-slate-800">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-slate-600 text-sm font-semibold"> <span className="text-slate-600 dark:text-slate-300 text-sm font-semibold">
Showing {visibleMatches.length} qualified matches {t.showingMatches.replace("{count}", String(visibleMatches.length))}
</span> </span>
{visibleMatchesToEval.length > 0 && ( {visibleMatchesToEval.length > 0 && (
<span className="text-xs text-slate-500 font-medium"> <span className="text-xs text-slate-500 dark:text-slate-400 font-medium">
({visibleMatchesToEval.length} unevaluated) {t.unevaluatedCount.replace("{count}", String(visibleMatchesToEval.length))}
</span> </span>
)} )}
</div> </div>
@ -914,10 +1039,10 @@ export default function JobsPage() {
{isBulkEvaluating ? ( {isBulkEvaluating ? (
<> <>
<span className="w-2 h-2 rounded-full bg-white animate-ping"></span> <span className="w-2 h-2 rounded-full bg-white animate-ping"></span>
{bulkEvalProgress || "Evaluating..."} {bulkEvalProgress || t.evaluating}
</> </>
) : ( ) : (
`Bulk Run AI Evaluation (${visibleMatchesToEval.length})` t.bulkRunAi.replace("{count}", String(visibleMatchesToEval.length))
)} )}
</button> </button>
)} )}
@ -925,11 +1050,11 @@ export default function JobsPage() {
{/* Visible Matches List */} {/* Visible Matches List */}
{loadingMatches ? ( {loadingMatches ? (
<p className="text-slate-500 text-sm">Finding matches...</p> <p className="text-slate-500 dark:text-slate-400 text-sm">{t.findingMatches}</p>
) : visibleMatches.length === 0 && !loadingMatches ? ( ) : visibleMatches.length === 0 && !loadingMatches ? (
<div className="p-8 text-center border border-slate-100 rounded-lg bg-slate-50/50"> <div className="p-8 text-center border border-slate-100 dark:border-slate-800 rounded-lg bg-slate-50/50 dark:bg-slate-800/20">
<p className="text-slate-500 text-sm font-medium">No active potential matches found.</p> <p className="text-slate-500 dark:text-slate-400 text-sm font-medium">{t.noActiveMatches}</p>
<p className="text-slate-400 text-xs mt-1">Upload CVs or check the mismatch/unqualified list below.</p> <p className="text-slate-400 dark:text-slate-500 text-xs mt-1">{t.noActiveMatchesDesc}</p>
</div> </div>
) : ( ) : (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
@ -941,14 +1066,14 @@ export default function JobsPage() {
{/* Expandable Hidden Matches List */} {/* Expandable Hidden Matches List */}
{hiddenMatches.length > 0 && ( {hiddenMatches.length > 0 && (
<div className="border border-slate-200 rounded-lg overflow-hidden"> <div className="border border-slate-200 dark:border-slate-800 rounded-lg overflow-hidden">
<button <button
onClick={() => setShowHiddenCandidates(!showHiddenCandidates)} onClick={() => setShowHiddenCandidates(!showHiddenCandidates)}
className="w-full flex items-center justify-between p-4 bg-slate-50 hover:bg-slate-100 transition duration-200 border-b border-slate-200" className="w-full flex items-center justify-between p-4 bg-slate-50 dark:bg-slate-800 hover:bg-slate-100 dark:hover:bg-slate-700 transition duration-200 border-b border-slate-200 dark:border-slate-800"
> >
<div className="flex items-center gap-2 text-slate-700 font-semibold text-sm"> <div className="flex items-center gap-2 text-slate-700 dark:text-slate-300 font-semibold text-sm">
<span>Mismatched or Unqualified Candidates</span> <span>{t.mismatchedOrUnqualified}</span>
<span className="px-2 py-0.5 bg-slate-200 text-slate-800 text-xs rounded-full font-bold"> <span className="px-2 py-0.5 bg-slate-200 dark:bg-slate-700 text-slate-800 dark:text-slate-200 text-xs rounded-full font-bold">
{hiddenMatches.length} {hiddenMatches.length}
</span> </span>
</div> </div>
@ -965,7 +1090,7 @@ export default function JobsPage() {
</button> </button>
{showHiddenCandidates && ( {showHiddenCandidates && (
<div className="p-4 bg-slate-50/50 border-t border-slate-200 flex flex-col gap-4"> <div className="p-4 bg-slate-50/50 dark:bg-slate-900/50 border-t border-slate-200 dark:border-slate-800 flex flex-col gap-4">
{hiddenMatches.map(({ candidate, overlap }) => {hiddenMatches.map(({ candidate, overlap }) =>
renderCandidateCard(candidate, overlap) renderCandidateCard(candidate, overlap)
)} )}
@ -979,12 +1104,12 @@ export default function JobsPage() {
</div> </div>
</div> </div>
) : ( ) : (
<div className="bg-white p-12 rounded-lg shadow-sm border border-slate-200 flex flex-col items-center justify-center text-center"> <div className="bg-white dark:bg-slate-900 p-12 rounded-lg shadow-sm border border-slate-200 dark:border-slate-800 flex flex-col items-center justify-center text-center">
<p className="text-slate-600 font-semibold mb-2"> <p className="text-slate-600 dark:text-slate-300 font-semibold mb-2">
Select or create a job vacancy to get started {t.selectVacancyToGetStarted}
</p> </p>
<p className="text-slate-500 text-xs max-w-sm"> <p className="text-slate-500 dark:text-slate-400 text-xs max-w-sm">
Use the sidebar panel to choose a vacancy or fill in the form to establish a new open position. {t.selectVacancyToGetStartedDesc}
</p> </p>
</div> </div>
)} )}
@ -992,55 +1117,55 @@ export default function JobsPage() {
{/* Duplicate Detection dialog */} {/* Duplicate Detection dialog */}
{duplicateData && ( {duplicateData && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/50 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/50 dark:bg-slate-950/70 backdrop-blur-sm">
<div className="bg-white rounded-lg shadow-md border border-slate-200 max-w-xl w-full p-6 flex flex-col gap-4 max-h-[90vh] overflow-y-auto"> <div className="bg-white dark:bg-slate-900 rounded-lg shadow-md border border-slate-200 dark:border-slate-800 max-w-xl w-full p-6 flex flex-col gap-4 max-h-[90vh] overflow-y-auto">
<div> <div>
<h3 className="text-lg font-bold text-slate-900"> <h3 className="text-lg font-bold text-slate-900 dark:text-white">
Duplicate Candidate Detected / Candidato Duplicado Detectado {t.duplicateDetected}
</h3> </h3>
<p className="text-xs text-slate-500 mt-1"> <p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
The system detected an existing candidate with the same email or name. / El sistema detectó un candidato existente con el mismo correo o nombre. ({duplicateData.fileName}) {t.duplicateMsg} ({duplicateData.fileName})
</p> </p>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
{/* Existing Profile */} {/* Existing Profile */}
<div className="border border-slate-200 rounded-md p-3 bg-slate-50"> <div className="border border-slate-200 dark:border-slate-800 rounded-md p-3 bg-slate-50 dark:bg-slate-800/50">
<h4 className="text-xs font-semibold text-slate-900 uppercase tracking-wider mb-2"> <h4 className="text-xs font-semibold text-slate-900 dark:text-white uppercase tracking-wider mb-2">
Existing Profile / Perfil Existente {t.existingProfile}
</h4> </h4>
<div className="text-sm font-bold text-slate-900"> <div className="text-sm font-bold text-slate-900 dark:text-white">
{duplicateData.existingCandidate.name} {duplicateData.existingCandidate.name}
</div> </div>
<div className="text-xs text-slate-500 mt-1"> <div className="text-xs text-slate-500 dark:text-slate-400 mt-1">
Email: <span className="text-slate-600 font-medium">{duplicateData.existingCandidate.contact_info.email}</span> Email: <span className="text-slate-600 dark:text-slate-300 font-medium">{duplicateData.existingCandidate.contact_info.email}</span>
</div> </div>
<div className="text-xs text-slate-500"> <div className="text-xs text-slate-500 dark:text-slate-400">
Phone: <span className="text-slate-600 font-medium">{duplicateData.existingCandidate.contact_info.phone || "N/A"}</span> Phone: <span className="text-slate-600 dark:text-slate-300 font-medium">{duplicateData.existingCandidate.contact_info.phone || "N/A"}</span>
</div> </div>
{duplicateData.existingCandidate.contact_info.summary && ( {duplicateData.existingCandidate.contact_info.summary && (
<p className="text-slate-600 mt-2 line-clamp-3"> <p className="text-slate-600 dark:text-slate-300 mt-2 line-clamp-3">
{duplicateData.existingCandidate.contact_info.summary} {duplicateData.existingCandidate.contact_info.summary}
</p> </p>
)} )}
</div> </div>
{/* New Profile */} {/* New Profile */}
<div className="border border-slate-200 rounded-md p-3 bg-slate-50"> <div className="border border-slate-200 dark:border-slate-800 rounded-md p-3 bg-slate-50 dark:bg-slate-800/50">
<h4 className="text-xs font-semibold text-slate-900 uppercase tracking-wider mb-2"> <h4 className="text-xs font-semibold text-slate-900 dark:text-white uppercase tracking-wider mb-2">
Newly Uploaded Profile / Nuevo Perfil Cargado {t.newProfile}
</h4> </h4>
<div className="text-sm font-bold text-slate-900"> <div className="text-sm font-bold text-slate-900 dark:text-white">
{duplicateData.newProfile.candidateName || duplicateData.newProfile.name || "Unknown"} {duplicateData.newProfile.candidateName || duplicateData.newProfile.name || "Unknown"}
</div> </div>
<div className="text-xs text-slate-500 mt-1"> <div className="text-xs text-slate-500 dark:text-slate-400 mt-1">
Email: <span className="text-slate-600 font-medium">{duplicateData.newProfile.email || "N/A"}</span> Email: <span className="text-slate-600 dark:text-slate-300 font-medium">{duplicateData.newProfile.email || "N/A"}</span>
</div> </div>
<div className="text-xs text-slate-500"> <div className="text-xs text-slate-500 dark:text-slate-400">
Phone: <span className="text-slate-600 font-medium">{duplicateData.newProfile.phone || "N/A"}</span> Phone: <span className="text-slate-600 dark:text-slate-300 font-medium">{duplicateData.newProfile.phone || "N/A"}</span>
</div> </div>
{duplicateData.newProfile.summary && ( {duplicateData.newProfile.summary && (
<p className="text-slate-600 mt-2 line-clamp-3"> <p className="text-slate-600 dark:text-slate-300 mt-2 line-clamp-3">
{duplicateData.newProfile.summary} {duplicateData.newProfile.summary}
</p> </p>
)} )}
@ -1048,41 +1173,40 @@ export default function JobsPage() {
</div> </div>
{/* AI Comparison Summary */} {/* AI Comparison Summary */}
<div className="border border-slate-200 rounded-md p-3 bg-blue-50"> <div className="border border-slate-200 dark:border-slate-800 rounded-md p-3 bg-blue-50 dark:bg-blue-950/20">
<h4 className="text-xs font-semibold text-blue-600 uppercase tracking-wider mb-2"> <h4 className="text-xs font-semibold text-blue-600 dark:text-blue-400 uppercase tracking-wider mb-2">
AI Comparison Summary / Resumen de Comparación de IA {t.aiComparison}
</h4> </h4>
{duplicateData.comparison ? ( {duplicateData.comparison ? (
<div className="text-xs text-slate-600 leading-relaxed flex flex-col gap-2"> <p className="text-xs text-slate-600 dark:text-slate-300 leading-relaxed">
<p><strong>EN:</strong> {duplicateData.comparison.en}</p> {duplicateData.comparison[lang] || duplicateData.comparison.en || duplicateData.comparison.es}
<p><strong>ES:</strong> {duplicateData.comparison.es}</p> </p>
</div>
) : ( ) : (
<p className="text-xs text-slate-500 italic"> <p className="text-xs text-slate-500 dark:text-slate-400 italic">
Comparing profiles with AI... / Comparando perfiles con IA... {t.comparingWithAi}
</p> </p>
)} )}
</div> </div>
{/* Actions */} {/* Actions */}
<div className="flex justify-end gap-2 pt-2 border-t border-slate-200"> <div className="flex justify-end gap-2 pt-2 border-t border-slate-200 dark:border-slate-800">
<button <button
onClick={() => duplicateData.onResolve("cancel")} onClick={() => duplicateData.onResolve("cancel")}
className="px-3 py-1.5 border border-slate-200 rounded-md text-xs text-slate-600 bg-white hover:bg-slate-50 transition" className="px-3 py-1.5 border border-slate-200 dark:border-slate-700 rounded-md text-xs text-slate-600 dark:text-slate-300 bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700 transition"
> >
Cancel / Cancelar {t.cancel}
</button> </button>
<button <button
onClick={() => duplicateData.onResolve("ignore")} onClick={() => duplicateData.onResolve("ignore")}
className="px-3 py-1.5 bg-slate-600 hover:bg-slate-700 text-white rounded-md text-xs font-semibold transition" className="px-3 py-1.5 bg-slate-600 hover:bg-slate-700 text-white rounded-md text-xs font-semibold transition"
> >
Keep Both / Conservar ambos {t.keepBoth}
</button> </button>
<button <button
onClick={() => duplicateData.onResolve("overwrite")} onClick={() => duplicateData.onResolve("overwrite")}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-xs font-semibold transition" className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-xs font-semibold transition"
> >
Overwrite / Sobrescribir {t.overwrite}
</button> </button>
</div> </div>
</div> </div>

View file

@ -1,31 +1,111 @@
"use client";
import Link from "next/link"; import Link from "next/link";
import { useApp } from "@/components/AppContext";
export default function DashboardLayout({ export default function DashboardLayout({
children, children,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const { lang, setLang, theme, setTheme } = useApp();
const toggleLanguage = () => {
setLang(lang === "en" ? "es" : "en");
};
const toggleTheme = () => {
setTheme(theme === "light" ? "dark" : "light");
};
return ( return (
<div className="min-h-screen bg-slate-50 flex flex-col"> <div className="min-h-screen bg-slate-50 dark:bg-slate-950 flex flex-col transition-colors duration-200">
<header className="bg-white border-b border-slate-200 shadow-sm"> <header className="bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-800 shadow-sm transition-colors duration-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
<div className="flex items-center gap-8"> <div className="flex items-center gap-8">
<span className="text-slate-900 font-bold text-lg">AI Recruitment</span> <span className="text-slate-900 dark:text-white font-bold text-lg">
{lang === "en" ? "AI Recruitment" : "Reclutamiento IA"}
</span>
<nav className="flex gap-4"> <nav className="flex gap-4">
<Link href="/jobs" className="text-sm text-slate-600 hover:text-slate-900 font-medium"> <Link
Jobs href="/jobs"
className="text-sm text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-white font-medium transition-colors"
>
{lang === "en" ? "Jobs" : "Vacantes"}
</Link> </Link>
<Link href="/candidates" className="text-sm text-slate-600 hover:text-slate-900 font-medium"> <Link
Candidates href="/candidates"
className="text-sm text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-white font-medium transition-colors"
>
{lang === "en" ? "Candidates" : "Candidatos"}
</Link> </Link>
<Link href="/interviews" className="text-sm text-slate-600 hover:text-slate-900 font-medium"> <Link
Interviews href="/interviews"
className="text-sm text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-white font-medium transition-colors"
>
{lang === "en" ? "Interviews" : "Entrevistas"}
</Link> </Link>
<Link href="/workflows" className="text-sm text-slate-600 hover:text-slate-900 font-medium"> <Link
Workflows href="/workflows"
className="text-sm text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-white font-medium transition-colors"
>
{lang === "en" ? "Workflows" : "Flujos de Trabajo"}
</Link> </Link>
</nav> </nav>
</div> </div>
<div className="flex items-center gap-4">
{/* Language Toggle */}
<button
onClick={toggleLanguage}
className="px-2.5 py-1 text-xs font-semibold rounded-md border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700 text-slate-600 dark:text-slate-300 transition-colors"
title={lang === "en" ? "Switch to Spanish" : "Cambiar a Inglés"}
>
{lang === "en" ? "ES" : "EN"}
</button>
{/* Dark Mode Toggle */}
<button
onClick={toggleTheme}
className="p-1.5 rounded-md border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700 text-slate-600 dark:text-slate-300 transition-colors"
aria-label={lang === "en" ? "Toggle theme" : "Cambiar tema"}
title={lang === "en" ? "Toggle light/dark mode" : "Cambiar modo claro/oscuro"}
>
{theme === "light" ? (
// Moon Icon
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={2}
stroke="currentColor"
className="w-4 h-4"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"
/>
</svg>
) : (
// Sun Icon
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={2}
stroke="currentColor"
className="w-4 h-4"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 3v2.25m0 13.5V21M4.93 4.93l1.41 1.41m11.32 11.32l1.41 1.41M3 12h2.25m13.5 0H21M5.75 12a6.25 6.25 0 1112.5 0 6.25 6.25 0 01-12.5 0z"
/>
</svg>
)}
</button>
</div>
</div> </div>
</header> </header>
<main className="flex-1 max-w-7xl mx-auto w-full p-6 sm:p-8"> <main className="flex-1 max-w-7xl mx-auto w-full p-6 sm:p-8">

View file

@ -1,8 +1,54 @@
"use client"; "use client";
import React, { useState } from "react"; import React, { useState } from "react";
import { useApp } from "@/components/AppContext";
const translations = {
en: {
workflowsTitle: "Workflows",
workflowsSubtitle: "Monitor active n8n webhooks and background integration pipelines.",
n8nWebhookIntegration: "n8n Webhook Integration",
webhookDesc: "This webhook coordinates CV processing and automatic screening candidates scores updates.",
statusLabel: "Status:",
activeStatus: "Active",
inactiveStatus: "Inactive / Missing Env",
webhookUrlLabel: "Active Webhook Target URL",
noUrlConfigured: "No webhook URL configured. Set NEXT_PUBLIC_N8N_WEBHOOK_URL in environment.",
copied: "Copied!",
copy: "Copy",
automatedPipeline: "Automated Recruitment Pipeline Execution",
step1Title: "CV Ingestion:",
step1Desc: " CVs uploaded on the Jobs screen are parsed, and candidate records are stored in Supabase with candidate vector embeddings.",
step2Title: "Webhook Trigger:",
step2Desc: " The backend route calls the n8n webhook URL with candidate meta-information and parsed CV text.",
step3Title: "AI Review & Evaluation:",
step3Desc: " n8n runs the screening workflow, generates scores, sets classification fields, and populates the database suggestions."
},
es: {
workflowsTitle: "Flujos de Trabajo",
workflowsSubtitle: "Monitoree los webhooks de n8n activos y los flujos de integración en segundo plano.",
n8nWebhookIntegration: "Integración de Webhook de n8n",
webhookDesc: "Este webhook coordina el procesamiento de CV y las actualizaciones automáticas de puntuación de candidatos preseleccionados.",
statusLabel: "Estado:",
activeStatus: "Activo",
inactiveStatus: "Inactivo / Falta Env",
webhookUrlLabel: "URL de Destino del Webhook Activo",
noUrlConfigured: "No se configuró la URL del webhook. Establezca NEXT_PUBLIC_N8N_WEBHOOK_URL en el entorno.",
copied: "¡Copiado!",
copy: "Copiar",
automatedPipeline: "Ejecución del Pipeline de Reclutamiento Automatizado",
step1Title: "Ingesta de CV:",
step1Desc: " Los CV cargados en la pantalla de Vacantes se analizan y los registros de los candidatos se almacenan en Supabase con sus embeddings vectoriales.",
step2Title: "Activación de Webhook:",
step2Desc: " La ruta del backend llama a la URL del webhook de n8n con la metainformación del candidato y el texto analizado del CV.",
step3Title: "Revisión y Evaluación con IA:",
step3Desc: " n8n ejecuta el flujo de trabajo de preselección, genera puntuaciones, establece campos de clasificación y llena las sugerencias en la base de datos."
}
};
export default function WorkflowsPage() { export default function WorkflowsPage() {
const { lang } = useApp();
const t = translations[lang];
const webhookUrl = process.env.NEXT_PUBLIC_N8N_WEBHOOK_URL || ""; const webhookUrl = process.env.NEXT_PUBLIC_N8N_WEBHOOK_URL || "";
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@ -17,81 +63,81 @@ export default function WorkflowsPage() {
return ( return (
<div className="flex flex-col gap-6 max-w-3xl"> <div className="flex flex-col gap-6 max-w-3xl">
<div> <div>
<h1 className="text-2xl font-bold text-slate-900">Workflows</h1> <h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.workflowsTitle}</h1>
<p className="text-slate-600 text-sm"> <p className="text-slate-600 dark:text-slate-300 text-sm">
Monitor active n8n webhooks and background integration pipelines. {t.workflowsSubtitle}
</p> </p>
</div> </div>
<div className="bg-white p-6 rounded-lg shadow-sm border border-slate-200 flex flex-col gap-6"> <div className="bg-white dark:bg-slate-900 p-6 rounded-lg shadow-sm border border-slate-200 dark:border-slate-800 flex flex-col gap-6">
<div> <div>
<h2 className="text-base font-bold text-slate-900">n8n Webhook Integration</h2> <h2 className="text-base font-bold text-slate-900 dark:text-white">{t.n8nWebhookIntegration}</h2>
<p className="text-slate-500 text-xs mt-1"> <p className="text-slate-500 dark:text-slate-400 text-xs mt-1">
This webhook coordinates CV processing and automatic screening candidates scores updates. {t.webhookDesc}
</p> </p>
</div> </div>
{/* Integration Status Badge */} {/* Integration Status Badge */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs font-semibold text-slate-500 uppercase tracking-wider"> <span className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">
Status: {t.statusLabel}
</span> </span>
<span <span
className={`px-2 py-0.5 text-xs font-semibold rounded-md border ${ className={`px-2 py-0.5 text-xs font-semibold rounded-md border ${
webhookUrl webhookUrl
? "bg-slate-50 text-slate-600 border-slate-200" ? "bg-slate-50 dark:bg-slate-800 text-slate-600 dark:text-slate-350 border-slate-200 dark:border-slate-700"
: "bg-slate-50 text-slate-500 border-slate-200" : "bg-slate-50 dark:bg-slate-800 text-slate-500 dark:text-slate-400 border-slate-200 dark:border-slate-700"
}`} }`}
> >
{webhookUrl ? "Active" : "Inactive / Missing Env"} {webhookUrl ? t.activeStatus : t.inactiveStatus}
</span> </span>
</div> </div>
{/* Webhook Input/Copy Panel */} {/* Webhook Input/Copy Panel */}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider"> <label className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">
Active Webhook Target URL {t.webhookUrlLabel}
</label> </label>
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
type="text" type="text"
readOnly readOnly
value={webhookUrl || "No webhook URL configured. Set NEXT_PUBLIC_N8N_WEBHOOK_URL in environment."} value={webhookUrl || t.noUrlConfigured}
className="flex-1 px-3 py-2 border border-slate-200 rounded-md text-slate-900 bg-slate-50 text-sm focus:outline-none" className="flex-1 px-3 py-2 border border-slate-200 dark:border-slate-700 rounded-md text-slate-900 dark:text-white bg-slate-50 dark:bg-slate-800 text-sm focus:outline-none"
/> />
{webhookUrl && ( {webhookUrl && (
<button <button
onClick={handleCopy} onClick={handleCopy}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold rounded-md transition duration-200" className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold rounded-md transition duration-200"
> >
{copied ? "Copied!" : "Copy"} {copied ? t.copied : t.copy}
</button> </button>
)} )}
</div> </div>
</div> </div>
{/* Informational Pipeline Flow */} {/* Informational Pipeline Flow */}
<div className="pt-4 border-t border-slate-200 flex flex-col gap-3"> <div className="pt-4 border-t border-slate-200 dark:border-slate-800 flex flex-col gap-3">
<h3 className="text-xs font-semibold text-slate-500 uppercase tracking-wider"> <h3 className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">
Automated Recruitment Pipeline Execution {t.automatedPipeline}
</h3> </h3>
<div className="flex flex-col gap-3 text-sm text-slate-600"> <div className="flex flex-col gap-3 text-sm text-slate-600 dark:text-slate-300">
<div className="flex gap-3 items-start"> <div className="flex gap-3 items-start">
<span className="font-bold text-blue-600">1.</span> <span className="font-bold text-blue-600 dark:text-blue-400">1.</span>
<p> <p>
<strong>CV Ingestion:</strong> CVs uploaded on the Jobs screen are parsed, and candidate records are stored in Supabase with candidate vector embeddings. <strong className="text-slate-900 dark:text-white">{t.step1Title}</strong>{t.step1Desc}
</p> </p>
</div> </div>
<div className="flex gap-3 items-start"> <div className="flex gap-3 items-start">
<span className="font-bold text-blue-600">2.</span> <span className="font-bold text-blue-600 dark:text-blue-400">2.</span>
<p> <p>
<strong>Webhook Trigger:</strong> The backend route calls the n8n webhook URL with candidate meta-information and parsed CV text. <strong className="text-slate-900 dark:text-white">{t.step2Title}</strong>{t.step2Desc}
</p> </p>
</div> </div>
<div className="flex gap-3 items-start"> <div className="flex gap-3 items-start">
<span className="font-bold text-blue-600">3.</span> <span className="font-bold text-blue-600 dark:text-blue-400">3.</span>
<p> <p>
<strong>AI Review & Evaluation:</strong> n8n runs the screening workflow, generates scores, sets classification fields, and populates the database suggestions. <strong className="text-slate-900 dark:text-white">{t.step3Title}</strong>{t.step3Desc}
</p> </p>
</div> </div>
</div> </div>

View file

@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { AppProvider } from "@/components/AppContext";
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@ -27,7 +28,9 @@ export default function RootLayout({
lang="en" lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
> >
<body className="min-h-full flex flex-col">{children}</body> <body className="min-h-full flex flex-col">
<AppProvider>{children}</AppProvider>
</body>
</html> </html>
); );
} }

79
components/AppContext.tsx Normal file
View file

@ -0,0 +1,79 @@
"use client";
import React, { createContext, useContext, useState, useEffect } from "react";
type Language = "en" | "es";
type Theme = "light" | "dark";
interface AppContextType {
lang: Language;
setLang: (lang: Language) => void;
theme: Theme;
setTheme: (theme: Theme) => void;
}
const AppContext = createContext<AppContextType | undefined>(undefined);
export function AppProvider({ children }: { children: React.ReactNode }) {
const [lang, setLangState] = useState<Language>("en");
const [theme, setThemeState] = useState<Theme>("light");
const [mounted, setMounted] = useState(false);
// Load from localStorage on mount
useEffect(() => {
const storedLang = localStorage.getItem("lang") as Language;
const storedTheme = localStorage.getItem("theme") as Theme;
setTimeout(() => {
if (storedLang === "en" || storedLang === "es") {
setLangState(storedLang);
}
if (storedTheme === "light" || storedTheme === "dark") {
setThemeState(storedTheme);
} else {
// Check system preference
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
if (mediaQuery.matches) {
setThemeState("dark");
}
}
setMounted(true);
}, 0);
}, []);
// Sync theme to document element
useEffect(() => {
if (!mounted) return;
const root = window.document.documentElement;
if (theme === "dark") {
root.classList.add("dark");
} else {
root.classList.remove("dark");
}
}, [theme, mounted]);
const setLang = (newLang: Language) => {
setLangState(newLang);
localStorage.setItem("lang", newLang);
};
const setTheme = (newTheme: Theme) => {
setThemeState(newTheme);
localStorage.setItem("theme", newTheme);
};
return (
<AppContext.Provider value={{ lang, setLang, theme, setTheme }}>
{children}
</AppContext.Provider>
);
}
export function useApp() {
const context = useContext(AppContext);
if (context === undefined) {
throw new Error("useApp must be used within an AppProvider");
}
return context;
}

View file

@ -1,6 +1,7 @@
import type { Config } from "tailwindcss"; import type { Config } from "tailwindcss";
const config: Config = { const config: Config = {
darkMode: "class",
content: [ content: [
"./app/**/*.{js,ts,jsx,tsx,mdx}", "./app/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}", "./components/**/*.{js,ts,jsx,tsx,mdx}",
@ -13,10 +14,16 @@ const config: Config = {
white: "#ffffff", white: "#ffffff",
slate: { slate: {
50: "#f8fafc", 50: "#f8fafc",
100: "#f1f5f9",
200: "#e2e8f0", 200: "#e2e8f0",
300: "#cbd5e1",
400: "#94a3b8",
500: "#64748b", 500: "#64748b",
600: "#475569", 600: "#475569",
700: "#334155",
800: "#1e293b",
900: "#0f172a", 900: "#0f172a",
950: "#020617",
}, },
blue: { blue: {
50: "#eff6ff", 50: "#eff6ff",