feat(theme): add dark mode & move language toggle
This commit is contained in:
parent
1a439e3bd0
commit
6c83fb6af7
8 changed files with 705 additions and 318 deletions
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useApp } from "@/components/AppContext";
|
||||
|
||||
interface Score {
|
||||
id: string;
|
||||
|
|
@ -215,7 +216,7 @@ export default function CandidatesPage() {
|
|||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// i18n Language Toggle State
|
||||
const [lang, setLang] = useState<"en" | "es">("en");
|
||||
const { lang } = useApp();
|
||||
const t = translations[lang];
|
||||
|
||||
// Delete & Duplicate States
|
||||
|
|
@ -479,11 +480,11 @@ export default function CandidatesPage() {
|
|||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* 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">
|
||||
<h3 className="text-sm font-semibold text-slate-900 mb-1">
|
||||
<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 dark:text-white mb-1">
|
||||
{t.uploadCv}
|
||||
</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}
|
||||
</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">
|
||||
|
|
@ -499,36 +500,36 @@ export default function CandidatesPage() {
|
|||
</label>
|
||||
|
||||
{uploadStatuses.length > 0 && (
|
||||
<div className="mt-4 w-full max-w-md border border-slate-200 rounded-md p-4 bg-slate-50 text-left">
|
||||
<h4 className="text-xs font-semibold text-slate-900 mb-2 uppercase tracking-wider">
|
||||
<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 dark:text-white mb-2 uppercase tracking-wider">
|
||||
{t.uploadProgress}
|
||||
</h4>
|
||||
<ul className="divide-y divide-slate-200">
|
||||
<ul className="divide-y divide-slate-200 dark:divide-slate-800">
|
||||
{uploadStatuses.map((item, idx) => (
|
||||
<li key={idx} className="py-2 flex flex-col gap-1 text-xs">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-slate-700 truncate max-w-[250px]" title={item.name}>
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300 truncate max-w-[250px]" title={item.name}>
|
||||
{item.name}
|
||||
</span>
|
||||
{item.status === "uploading" && (
|
||||
<span className="text-slate-600 font-semibold flex items-center gap-1">
|
||||
<span className="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>
|
||||
{lang === "es" ? "Cargando..." : "Uploading..."}
|
||||
</span>
|
||||
)}
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{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>
|
||||
))}
|
||||
|
|
@ -538,29 +539,23 @@ export default function CandidatesPage() {
|
|||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-slate-200">
|
||||
<p className="text-slate-500 text-sm">{t.loading}</p>
|
||||
<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 dark:text-slate-400 text-sm">{t.loading}</p>
|
||||
</div>
|
||||
) : candidates.length === 0 ? (
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-slate-200">
|
||||
<p className="text-slate-500 text-sm">
|
||||
<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 dark:text-slate-400 text-sm">
|
||||
{t.noCandidatesFound}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* 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="flex items-center justify-between border-b border-slate-200 pb-3">
|
||||
<h2 className="text-lg font-bold text-slate-900">
|
||||
<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 dark:border-slate-800 pb-3">
|
||||
<h2 className="text-lg font-bold text-slate-900 dark:text-white">
|
||||
{t.candidatesTitle}
|
||||
</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>
|
||||
|
||||
{/* Search Input */}
|
||||
|
|
@ -571,13 +566,13 @@ export default function CandidatesPage() {
|
|||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
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 !== "") */}
|
||||
{searchQuery !== "" && (
|
||||
<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}:
|
||||
</span>
|
||||
<button
|
||||
|
|
@ -585,7 +580,7 @@ export default function CandidatesPage() {
|
|||
className={`px-2 py-0.5 text-xs rounded transition font-medium border ${
|
||||
searchField === "name"
|
||||
? "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"}
|
||||
|
|
@ -595,7 +590,7 @@ export default function CandidatesPage() {
|
|||
className={`px-2 py-0.5 text-xs rounded transition font-medium border ${
|
||||
searchField === "email"
|
||||
? "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"}
|
||||
|
|
@ -605,7 +600,7 @@ export default function CandidatesPage() {
|
|||
className={`px-2 py-0.5 text-xs rounded transition font-medium border ${
|
||||
searchField === "skills"
|
||||
? "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"}
|
||||
|
|
@ -622,20 +617,20 @@ export default function CandidatesPage() {
|
|||
onClick={() => setSelectedCandidate(candidate)}
|
||||
className={`w-full text-left p-3 rounded-md border text-sm transition duration-200 ${
|
||||
selectedCandidate?.id === candidate.id
|
||||
? "border-blue-600 bg-slate-50 font-semibold"
|
||||
: "border-slate-200 hover:border-slate-300 bg-white"
|
||||
? "border-blue-600 bg-slate-50 dark:bg-slate-800/50 dark:border-blue-500 font-semibold"
|
||||
: "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}
|
||||
</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}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{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"}
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -645,25 +640,25 @@ export default function CandidatesPage() {
|
|||
{/* RIGHT COLUMN: Detail Pane (2/3 width) */}
|
||||
<div className="md:col-span-2">
|
||||
{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 */}
|
||||
<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>
|
||||
<h2 className="text-xl font-bold text-slate-900">
|
||||
<h2 className="text-xl font-bold text-slate-900 dark:text-white">
|
||||
{selectedCandidate.name}
|
||||
</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()}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1 mt-3 text-xs">
|
||||
<div>
|
||||
<span className="text-slate-500">{t.email}: </span>
|
||||
<span className="text-slate-600 font-medium">{selectedCandidate.contact_info.email}</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">{t.email}: </span>
|
||||
<span className="text-slate-600 dark:text-slate-300 font-medium">{selectedCandidate.contact_info.email}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">{t.phone}: </span>
|
||||
<span className="text-slate-600 font-medium">{selectedCandidate.contact_info.phone || "N/A"}</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">{t.phone}: </span>
|
||||
<span className="text-slate-600 dark:text-slate-300 font-medium">{selectedCandidate.contact_info.phone || "N/A"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -679,10 +674,10 @@ export default function CandidatesPage() {
|
|||
{/* Summary Extracted */}
|
||||
{selectedCandidate.contact_info.summary && (
|
||||
<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}
|
||||
</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}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -691,14 +686,14 @@ export default function CandidatesPage() {
|
|||
{/* Skills tag group */}
|
||||
{selectedCandidate.contact_info.skills && selectedCandidate.contact_info.skills.length > 0 && (
|
||||
<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}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selectedCandidate.contact_info.skills.map((skill) => (
|
||||
<span
|
||||
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}
|
||||
</span>
|
||||
|
|
@ -709,37 +704,37 @@ export default function CandidatesPage() {
|
|||
|
||||
{/* Linked Vacancies Section */}
|
||||
<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}
|
||||
</h3>
|
||||
{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">
|
||||
<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.aiScore}</th>
|
||||
<th className="p-3 font-semibold">{t.classification}</th>
|
||||
<th className="p-3 font-semibold">{t.stage}</th>
|
||||
</tr>
|
||||
</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) => (
|
||||
<tr key={vacancy.jobId} className="text-slate-600">
|
||||
<td className="p-3 font-medium text-slate-900">
|
||||
<tr key={vacancy.jobId} className="text-slate-600 dark:text-slate-300">
|
||||
<td className="p-3 font-medium text-slate-900 dark:text-white">
|
||||
{vacancy.jobTitle}
|
||||
</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` : "-"}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{vacancy.classification ? (
|
||||
<span className={`px-2 py-0.5 text-xs rounded-md font-semibold border ${
|
||||
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"
|
||||
? "bg-slate-50 text-slate-600 border-slate-200"
|
||||
: "bg-red-50 text-red-700 border-red-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 dark:bg-red-950/30 text-red-700 dark:text-red-400 border-red-200 dark:border-red-900/50"
|
||||
}`}>
|
||||
{translateClassification(vacancy.classification, lang)}
|
||||
</span>
|
||||
|
|
@ -749,7 +744,7 @@ export default function CandidatesPage() {
|
|||
</td>
|
||||
<td className="p-3">
|
||||
{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)}
|
||||
</span>
|
||||
) : (
|
||||
|
|
@ -762,7 +757,7 @@ export default function CandidatesPage() {
|
|||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-slate-500 italic">
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 italic">
|
||||
{t.noLinkedVacancies}
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -770,8 +765,8 @@ export default function CandidatesPage() {
|
|||
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white p-12 rounded-lg shadow-sm border border-slate-200 flex flex-col items-center justify-center text-center">
|
||||
<p className="text-slate-600 font-semibold mb-2">
|
||||
<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 dark:text-slate-300 font-semibold mb-2">
|
||||
{t.noCandidateSelected}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -782,18 +777,18 @@ export default function CandidatesPage() {
|
|||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{showDeleteConfirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/50 backdrop-blur-sm">
|
||||
<div className="bg-white rounded-lg shadow-md border border-slate-200 max-w-md w-full p-6">
|
||||
<h3 className="text-lg font-bold text-slate-900 mb-2">
|
||||
<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 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 dark:text-white mb-2">
|
||||
{t.confirmTitle}
|
||||
</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}
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
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}
|
||||
</button>
|
||||
|
|
@ -810,55 +805,55 @@ export default function CandidatesPage() {
|
|||
|
||||
{/* Duplicate Detection dialog */}
|
||||
{duplicateData && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/50 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="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 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>
|
||||
<h3 className="text-lg font-bold text-slate-900">
|
||||
<h3 className="text-lg font-bold text-slate-900 dark:text-white">
|
||||
{t.duplicateDetected}
|
||||
</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})
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Existing Profile */}
|
||||
<div className="border border-slate-200 rounded-md p-3 bg-slate-50 text-xs">
|
||||
<h4 className="text-xs font-semibold text-slate-900 uppercase tracking-wider mb-2">
|
||||
<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 dark:text-white uppercase tracking-wider mb-2">
|
||||
{t.existingProfile}
|
||||
</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}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-1">
|
||||
Email: <span className="text-slate-600 font-medium">{duplicateData.existingCandidate.contact_info.email}</span>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
||||
Email: <span className="text-slate-600 dark:text-slate-300 font-medium">{duplicateData.existingCandidate.contact_info.email}</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
Phone: <span className="text-slate-600 font-medium">{duplicateData.existingCandidate.contact_info.phone || "N/A"}</span>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Phone: <span className="text-slate-600 dark:text-slate-300 font-medium">{duplicateData.existingCandidate.contact_info.phone || "N/A"}</span>
|
||||
</div>
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New Profile */}
|
||||
<div className="border border-slate-200 rounded-md p-3 bg-slate-50 text-xs">
|
||||
<h4 className="text-xs font-semibold text-slate-900 uppercase tracking-wider mb-2">
|
||||
<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 dark:text-white uppercase tracking-wider mb-2">
|
||||
{t.newProfile}
|
||||
</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"}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-1">
|
||||
Email: <span className="text-slate-600 font-medium">{duplicateData.newProfile.email || "N/A"}</span>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
||||
Email: <span className="text-slate-600 dark:text-slate-300 font-medium">{duplicateData.newProfile.email || "N/A"}</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
Phone: <span className="text-slate-600 font-medium">{duplicateData.newProfile.phone || "N/A"}</span>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Phone: <span className="text-slate-600 dark:text-slate-300 font-medium">{duplicateData.newProfile.phone || "N/A"}</span>
|
||||
</div>
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -866,26 +861,26 @@ export default function CandidatesPage() {
|
|||
</div>
|
||||
|
||||
{/* AI Comparison Summary */}
|
||||
<div className="border border-slate-200 rounded-md p-3 bg-blue-50">
|
||||
<h4 className="text-xs font-semibold text-blue-600 uppercase tracking-wider mb-2">
|
||||
<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 dark:text-blue-400 uppercase tracking-wider mb-2">
|
||||
{t.aiComparison}
|
||||
</h4>
|
||||
{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)}
|
||||
</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..."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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
|
||||
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"}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { useApp } from "@/components/AppContext";
|
||||
|
||||
interface Interview {
|
||||
id: string;
|
||||
|
|
@ -19,7 +20,59 @@ interface Interview {
|
|||
} | 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() {
|
||||
const { lang } = useApp();
|
||||
const t = translations[lang];
|
||||
|
||||
const [interviews, setInterviews] = useState<Interview[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [updatingId, setUpdatingId] = useState<string | null>(null);
|
||||
|
|
@ -97,7 +150,7 @@ export default function InterviewsPage() {
|
|||
|
||||
if (error) throw error;
|
||||
|
||||
setActionMessage("Interview updated successfully!");
|
||||
setActionMessage(t.updateSuccess);
|
||||
// Hide message after 3 seconds
|
||||
setTimeout(() => setActionMessage(null), 3000);
|
||||
|
||||
|
|
@ -111,7 +164,7 @@ export default function InterviewsPage() {
|
|||
);
|
||||
} catch (err: unknown) {
|
||||
console.error("Error updating interview:", err);
|
||||
setActionMessage("Failed to update interview.");
|
||||
setActionMessage(t.updateFailed);
|
||||
} finally {
|
||||
setUpdatingId(null);
|
||||
}
|
||||
|
|
@ -121,26 +174,26 @@ export default function InterviewsPage() {
|
|||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Interviews</h1>
|
||||
<p className="text-slate-600 text-sm">
|
||||
Manage scheduled candidate interview stages and write evaluation feedback.
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.interviewsTitle}</h1>
|
||||
<p className="text-slate-600 dark:text-slate-300 text-sm">
|
||||
{t.interviewsSubtitle}
|
||||
</p>
|
||||
</div>
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-slate-200">
|
||||
<p className="text-slate-500 text-sm">Loading interviews...</p>
|
||||
<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 dark:text-slate-400 text-sm">{t.loadingInterviews}</p>
|
||||
</div>
|
||||
) : interviews.length === 0 ? (
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-slate-200">
|
||||
<p className="text-slate-500 text-sm">
|
||||
No interviews scheduled. Upload candidate CVs under the Jobs tab to trigger evaluations.
|
||||
<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 dark:text-slate-400 text-sm">
|
||||
{t.noInterviews}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -154,47 +207,47 @@ export default function InterviewsPage() {
|
|||
return (
|
||||
<div
|
||||
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 */}
|
||||
<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>
|
||||
<h2 className="text-base font-bold text-slate-900">
|
||||
{interview.candidates?.name || "Unknown Candidate"}
|
||||
<h2 className="text-base font-bold text-slate-900 dark:text-white">
|
||||
{interview.candidates?.name || t.unknownCandidate}
|
||||
</h2>
|
||||
<p className="text-sm text-slate-600 font-medium">
|
||||
Role: {interview.jobs?.title || "Unknown Job"}
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300 font-medium">
|
||||
{t.role}: {interview.jobs?.title || t.unknownJob}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Date Scheduled:{" "}
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
||||
{t.dateScheduled}:{" "}
|
||||
{new Date(interview.interview_date).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
Stage:
|
||||
<label className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
||||
{t.stage}:
|
||||
</label>
|
||||
<select
|
||||
value={currentEdit.stage}
|
||||
onChange={(e) =>
|
||||
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="Technical">Technical</option>
|
||||
<option value="Cultural">Cultural</option>
|
||||
<option value="Offer">Offer</option>
|
||||
<option value="Hired">Hired</option>
|
||||
<option value="Rejected">Rejected</option>
|
||||
<option value="Screening">{t.screening}</option>
|
||||
<option value="Technical">{t.technical}</option>
|
||||
<option value="Cultural">{t.cultural}</option>
|
||||
<option value="Offer">{t.offer}</option>
|
||||
<option value="Hired">{t.hired}</option>
|
||||
<option value="Rejected">{t.rejected}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feedback Area */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
Interview Feedback
|
||||
<label className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
||||
{t.interviewFeedback}
|
||||
</label>
|
||||
<textarea
|
||||
value={currentEdit.feedback}
|
||||
|
|
@ -205,9 +258,9 @@ export default function InterviewsPage() {
|
|||
e.target.value
|
||||
)
|
||||
}
|
||||
placeholder="Write detailed assessment feedback, questions, or observations..."
|
||||
placeholder={t.feedbackPlaceholder}
|
||||
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>
|
||||
|
||||
|
|
@ -218,7 +271,7 @@ export default function InterviewsPage() {
|
|||
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"
|
||||
>
|
||||
{updatingId === interview.id ? "Saving..." : "Update Interview"}
|
||||
{updatingId === interview.id ? t.saving : t.updateInterview}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useApp } from "@/components/AppContext";
|
||||
|
||||
interface Job {
|
||||
id: string;
|
||||
|
|
@ -102,7 +103,131 @@ interface DuplicateState {
|
|||
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() {
|
||||
const { lang } = useApp();
|
||||
const t = translations[lang];
|
||||
|
||||
const [jobs, setJobs] = useState<Job[]>([]);
|
||||
const [selectedJob, setSelectedJob] = useState<Job | null>(null);
|
||||
const [matches, setMatches] = useState<Candidate[]>([]);
|
||||
|
|
@ -498,55 +623,55 @@ export default function JobsPage() {
|
|||
{/* Left Column: Create Form & Vacancies List */}
|
||||
<div className="lg:col-span-1 flex flex-col gap-6">
|
||||
{/* Create vacancy form */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-slate-200">
|
||||
<h2 className="text-lg font-bold text-slate-900 mb-4">Create Vacancy</h2>
|
||||
<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 dark:text-white mb-4">{t.createVacancy}</h2>
|
||||
<form onSubmit={handleCreateJob} className="flex flex-col gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1">
|
||||
Job Title
|
||||
<label className="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider mb-1">
|
||||
{t.jobTitle}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newTitle}
|
||||
onChange={(e) => setNewTitle(e.target.value)}
|
||||
placeholder="e.g., Senior React Developer"
|
||||
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"
|
||||
placeholder={t.jobTitlePlaceholder}
|
||||
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
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1">
|
||||
Requirements text
|
||||
<label className="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider mb-1">
|
||||
{t.requirementsText}
|
||||
</label>
|
||||
<textarea
|
||||
value={newRequirements}
|
||||
onChange={(e) => setNewRequirements(e.target.value)}
|
||||
placeholder="Describe key candidate qualifications and tech stack..."
|
||||
placeholder={t.requirementsPlaceholder}
|
||||
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
|
||||
/>
|
||||
</div>
|
||||
{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
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
{isSubmitting ? "Creating..." : "Create Vacancy"}
|
||||
{isSubmitting ? t.creating : t.createVacancyBtn}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Vacancy list */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-slate-200 flex-1">
|
||||
<h2 className="text-lg font-bold text-slate-900 mb-4">Job Vacancies</h2>
|
||||
<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 dark:text-white mb-4">{t.jobVacancies}</h2>
|
||||
{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 ? (
|
||||
<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">
|
||||
{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 ${
|
||||
selectedJob?.id === job.id
|
||||
? "border-blue-600 bg-slate-50 font-semibold"
|
||||
: "border-slate-200 hover:border-slate-300"
|
||||
? "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 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="text-xs text-slate-500 mt-1">
|
||||
Created: {new Date(job.created_at).toLocaleDateString()}
|
||||
<div className="font-semibold">{job.title}</div>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
||||
{t.createdDate}: {new Date(job.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
|
@ -577,40 +702,40 @@ export default function JobsPage() {
|
|||
{/* Right Column: Selected Job Details & Candidate Match */}
|
||||
<div className="lg:col-span-2">
|
||||
{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 */}
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
Vacancy Details
|
||||
<div className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
||||
{t.vacancyDetails}
|
||||
</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}
|
||||
</h1>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
ID: {selectedJob.id}
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
||||
{t.vacancyId}: {selectedJob.id}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
<h3 className="text-sm font-semibold text-slate-900 mb-1">
|
||||
Description
|
||||
<h3 className="text-sm font-semibold text-slate-900 dark:text-white mb-1">
|
||||
{t.description}
|
||||
</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}
|
||||
</p>
|
||||
</div>
|
||||
{selectedJob.requirements.skills && selectedJob.requirements.skills.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1.5">
|
||||
Extracted Job Keywords / Required Skills
|
||||
<h3 className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider mb-1.5">
|
||||
{t.extractedSkills}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selectedJob.requirements.skills.map((skill) => (
|
||||
<span
|
||||
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}
|
||||
</span>
|
||||
|
|
@ -621,15 +746,15 @@ export default function JobsPage() {
|
|||
</div>
|
||||
|
||||
{/* PDF Uploader */}
|
||||
<div className="border border-dashed border-slate-200 rounded-lg p-6 flex flex-col items-center justify-center text-center">
|
||||
<h3 className="text-sm font-semibold text-slate-900 mb-1">
|
||||
Upload Candidate CV for this Vacancy (PDF)
|
||||
<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 dark:text-white mb-1">
|
||||
{t.uploadCvTitle}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500 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.
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mb-4 max-w-md">
|
||||
{t.uploadCvDesc}
|
||||
</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">
|
||||
{uploading ? "Uploading CVs..." : "Choose CV Files"}
|
||||
{uploading ? t.uploadingButton : t.uploadButton}
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
|
|
@ -640,46 +765,46 @@ export default function JobsPage() {
|
|||
/>
|
||||
</label>
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
{uploadStatuses.length > 0 && (
|
||||
<div className="mt-4 w-full max-w-md border border-slate-200 rounded-md p-4 bg-slate-50 text-left">
|
||||
<h4 className="text-xs font-semibold text-slate-900 mb-2 uppercase tracking-wider">
|
||||
Upload Progress
|
||||
<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 dark:text-white mb-2 uppercase tracking-wider">
|
||||
{t.uploadProgress}
|
||||
</h4>
|
||||
<ul className="divide-y divide-slate-200">
|
||||
<ul className="divide-y divide-slate-200 dark:divide-slate-800">
|
||||
{uploadStatuses.map((item, idx) => (
|
||||
<li key={idx} className="py-2 flex flex-col gap-1 text-xs">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-slate-700 truncate max-w-[250px]" title={item.name}>
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300 truncate max-w-[250px]" title={item.name}>
|
||||
{item.name}
|
||||
</span>
|
||||
{item.status === "uploading" && (
|
||||
<span className="text-slate-600 font-semibold flex items-center gap-1">
|
||||
<span className="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>
|
||||
Uploading...
|
||||
{t.uploading}
|
||||
</span>
|
||||
)}
|
||||
{item.status === "success" && (
|
||||
<span className="text-green-600 font-semibold flex items-center gap-1">
|
||||
✓ Success
|
||||
<span className="text-green-600 dark:text-green-400 font-semibold flex items-center gap-1">
|
||||
✓ {t.success}
|
||||
</span>
|
||||
)}
|
||||
{item.status === "error" && (
|
||||
<span className="text-red-600 font-semibold flex items-center gap-1">
|
||||
✗ Error
|
||||
<span className="text-red-600 dark:text-red-400 font-semibold flex items-center gap-1">
|
||||
✗ {t.error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{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>
|
||||
))}
|
||||
|
|
@ -747,17 +872,17 @@ export default function JobsPage() {
|
|||
return (
|
||||
<div
|
||||
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 */}
|
||||
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4">
|
||||
<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}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
Email: <span className="text-slate-700 font-medium mr-3">{match.contact_info.email}</span>
|
||||
Phone: <span className="text-slate-700 font-medium">{match.contact_info.phone}</span>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">
|
||||
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 dark:text-slate-300 font-medium">{match.contact_info.phone}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -766,28 +891,28 @@ export default function JobsPage() {
|
|||
<span
|
||||
className={`px-2.5 py-1 text-xs font-semibold rounded-md border ${
|
||||
isPotentialMatch
|
||||
? "bg-green-50 text-green-700 border-green-200"
|
||||
: "bg-slate-50 text-slate-500 border-slate-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 dark:bg-slate-800 text-slate-500 dark:text-slate-400 border-slate-200 dark:border-slate-700"
|
||||
}`}
|
||||
>
|
||||
{isPotentialMatch
|
||||
? `Potential Match (${matchPct}% overlap)`
|
||||
: `Skill Mismatch (${matchPct}% overlap)`}
|
||||
? t.potentialMatch.replace("{pct}", String(matchPct))
|
||||
: t.skillMismatch.replace("{pct}", String(matchPct))}
|
||||
</span>
|
||||
|
||||
{/* Semantic embedding similarity badge */}
|
||||
{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">
|
||||
Semantic: {similarityPct}%
|
||||
<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">
|
||||
{t.semanticSimilarity.replace("{pct}", String(similarityPct))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skills overlap details */}
|
||||
<div className="bg-slate-50 p-3 rounded-md border border-slate-100 flex flex-col gap-2">
|
||||
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
Skills Check: {overlapCount} of {totalRequired} matching
|
||||
<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 dark:text-slate-400 uppercase tracking-wider">
|
||||
{t.skillsCheck.replace("{count}", String(overlapCount)).replace("{total}", String(totalRequired))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
|
|
@ -795,7 +920,7 @@ export default function JobsPage() {
|
|||
{matchedSkills.map((skill: string) => (
|
||||
<span
|
||||
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}
|
||||
</span>
|
||||
|
|
@ -805,41 +930,41 @@ export default function JobsPage() {
|
|||
{missingSkills.map((skill: string) => (
|
||||
<span
|
||||
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>
|
||||
))}
|
||||
|
||||
{/* Fallback if no skills are loaded */}
|
||||
{jobSkills.length === 0 && (
|
||||
<span className="text-xs text-slate-500 italic">
|
||||
No required skills extracted for this job yet.
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400 italic">
|
||||
{t.noSkillsExtracted}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
{latestScore ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-xs text-slate-500">
|
||||
AI ASSESSMENT RESULT
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400 uppercase tracking-wider font-semibold">
|
||||
{t.aiAssessmentResult}
|
||||
</div>
|
||||
<div className="text-sm text-slate-700 font-medium">
|
||||
Decision: <span className="font-bold text-slate-900">{latestScore.evaluation.classification}</span>
|
||||
<span className="mx-2 font-normal text-slate-300">|</span>
|
||||
Score: <span className="font-bold text-blue-600 text-base">{latestScore.ai_score} / 100</span>
|
||||
<div className="text-sm text-slate-700 dark:text-slate-300 font-medium">
|
||||
{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 dark:text-slate-700">|</span>
|
||||
{t.score}: <span className="font-bold text-blue-600 dark:text-blue-400 text-base">{latestScore.ai_score} / 100</span>
|
||||
</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}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-slate-500 italic">
|
||||
Ready for deep assessment. Only potential matches recommended for LLM budget optimization.
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400 italic">
|
||||
{t.readyForDeepAssessment}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -849,19 +974,19 @@ export default function JobsPage() {
|
|||
<button
|
||||
onClick={() => handleEvaluate(match.id)}
|
||||
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]
|
||||
? "Evaluating..."
|
||||
? t.evaluating
|
||||
: latestScore
|
||||
? "Re-run AI"
|
||||
: "Run AI Evaluation"}
|
||||
? t.reRunAi
|
||||
: t.runAiEvaluation}
|
||||
</button>
|
||||
|
||||
{/* Promote to Interview Pipeline */}
|
||||
{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">
|
||||
Promoted ({match.interview.stage})
|
||||
<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">
|
||||
{t.promoted} ({match.interview.stage})
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
|
|
@ -872,16 +997,16 @@ export default function JobsPage() {
|
|||
isUnqualified ||
|
||||
!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={
|
||||
isUnqualified
|
||||
? "Cannot promote unqualified candidates"
|
||||
? (lang === "es" ? "No se pueden promocionar candidatos no calificados" : "Cannot promote unqualified candidates")
|
||||
: !isPotentialMatch
|
||||
? "Skill overlap too low to promote"
|
||||
: "Promote to Interviews"
|
||||
? (lang === "es" ? "Coincidencia de habilidades muy baja para promocionar" : "Skill overlap too low to promote")
|
||||
: (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>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -893,14 +1018,14 @@ export default function JobsPage() {
|
|||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* 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">
|
||||
<span className="text-slate-600 text-sm font-semibold">
|
||||
Showing {visibleMatches.length} qualified matches
|
||||
<span className="text-slate-600 dark:text-slate-300 text-sm font-semibold">
|
||||
{t.showingMatches.replace("{count}", String(visibleMatches.length))}
|
||||
</span>
|
||||
{visibleMatchesToEval.length > 0 && (
|
||||
<span className="text-xs text-slate-500 font-medium">
|
||||
({visibleMatchesToEval.length} unevaluated)
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400 font-medium">
|
||||
{t.unevaluatedCount.replace("{count}", String(visibleMatchesToEval.length))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -914,10 +1039,10 @@ export default function JobsPage() {
|
|||
{isBulkEvaluating ? (
|
||||
<>
|
||||
<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>
|
||||
)}
|
||||
|
|
@ -925,11 +1050,11 @@ export default function JobsPage() {
|
|||
|
||||
{/* Visible Matches List */}
|
||||
{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 ? (
|
||||
<div className="p-8 text-center border border-slate-100 rounded-lg bg-slate-50/50">
|
||||
<p className="text-slate-500 text-sm font-medium">No active potential matches found.</p>
|
||||
<p className="text-slate-400 text-xs mt-1">Upload CVs or check the mismatch/unqualified list below.</p>
|
||||
<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 dark:text-slate-400 text-sm font-medium">{t.noActiveMatches}</p>
|
||||
<p className="text-slate-400 dark:text-slate-500 text-xs mt-1">{t.noActiveMatchesDesc}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
|
|
@ -941,14 +1066,14 @@ export default function JobsPage() {
|
|||
|
||||
{/* Expandable Hidden Matches List */}
|
||||
{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
|
||||
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">
|
||||
<span>Mismatched or Unqualified Candidates</span>
|
||||
<span className="px-2 py-0.5 bg-slate-200 text-slate-800 text-xs rounded-full font-bold">
|
||||
<div className="flex items-center gap-2 text-slate-700 dark:text-slate-300 font-semibold text-sm">
|
||||
<span>{t.mismatchedOrUnqualified}</span>
|
||||
<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}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -965,7 +1090,7 @@ export default function JobsPage() {
|
|||
</button>
|
||||
|
||||
{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 }) =>
|
||||
renderCandidateCard(candidate, overlap)
|
||||
)}
|
||||
|
|
@ -979,12 +1104,12 @@ export default function JobsPage() {
|
|||
</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">
|
||||
<p className="text-slate-600 font-semibold mb-2">
|
||||
Select or create a job vacancy to get started
|
||||
<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 dark:text-slate-300 font-semibold mb-2">
|
||||
{t.selectVacancyToGetStarted}
|
||||
</p>
|
||||
<p className="text-slate-500 text-xs max-w-sm">
|
||||
Use the sidebar panel to choose a vacancy or fill in the form to establish a new open position.
|
||||
<p className="text-slate-500 dark:text-slate-400 text-xs max-w-sm">
|
||||
{t.selectVacancyToGetStartedDesc}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -992,55 +1117,55 @@ export default function JobsPage() {
|
|||
|
||||
{/* Duplicate Detection dialog */}
|
||||
{duplicateData && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/50 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="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 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>
|
||||
<h3 className="text-lg font-bold text-slate-900">
|
||||
Duplicate Candidate Detected / Candidato Duplicado Detectado
|
||||
<h3 className="text-lg font-bold text-slate-900 dark:text-white">
|
||||
{t.duplicateDetected}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500 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})
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
||||
{t.duplicateMsg} ({duplicateData.fileName})
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
|
||||
{/* Existing Profile */}
|
||||
<div className="border border-slate-200 rounded-md p-3 bg-slate-50">
|
||||
<h4 className="text-xs font-semibold text-slate-900 uppercase tracking-wider mb-2">
|
||||
Existing Profile / Perfil Existente
|
||||
<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 dark:text-white uppercase tracking-wider mb-2">
|
||||
{t.existingProfile}
|
||||
</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}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-1">
|
||||
Email: <span className="text-slate-600 font-medium">{duplicateData.existingCandidate.contact_info.email}</span>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
||||
Email: <span className="text-slate-600 dark:text-slate-300 font-medium">{duplicateData.existingCandidate.contact_info.email}</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
Phone: <span className="text-slate-600 font-medium">{duplicateData.existingCandidate.contact_info.phone || "N/A"}</span>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Phone: <span className="text-slate-600 dark:text-slate-300 font-medium">{duplicateData.existingCandidate.contact_info.phone || "N/A"}</span>
|
||||
</div>
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New Profile */}
|
||||
<div className="border border-slate-200 rounded-md p-3 bg-slate-50">
|
||||
<h4 className="text-xs font-semibold text-slate-900 uppercase tracking-wider mb-2">
|
||||
Newly Uploaded Profile / Nuevo Perfil Cargado
|
||||
<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 dark:text-white uppercase tracking-wider mb-2">
|
||||
{t.newProfile}
|
||||
</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"}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-1">
|
||||
Email: <span className="text-slate-600 font-medium">{duplicateData.newProfile.email || "N/A"}</span>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
||||
Email: <span className="text-slate-600 dark:text-slate-300 font-medium">{duplicateData.newProfile.email || "N/A"}</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
Phone: <span className="text-slate-600 font-medium">{duplicateData.newProfile.phone || "N/A"}</span>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Phone: <span className="text-slate-600 dark:text-slate-300 font-medium">{duplicateData.newProfile.phone || "N/A"}</span>
|
||||
</div>
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -1048,41 +1173,40 @@ export default function JobsPage() {
|
|||
</div>
|
||||
|
||||
{/* AI Comparison Summary */}
|
||||
<div className="border border-slate-200 rounded-md p-3 bg-blue-50">
|
||||
<h4 className="text-xs font-semibold text-blue-600 uppercase tracking-wider mb-2">
|
||||
AI Comparison Summary / Resumen de Comparación de IA
|
||||
<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 dark:text-blue-400 uppercase tracking-wider mb-2">
|
||||
{t.aiComparison}
|
||||
</h4>
|
||||
{duplicateData.comparison ? (
|
||||
<div className="text-xs text-slate-600 leading-relaxed flex flex-col gap-2">
|
||||
<p><strong>EN:</strong> {duplicateData.comparison.en}</p>
|
||||
<p><strong>ES:</strong> {duplicateData.comparison.es}</p>
|
||||
</div>
|
||||
<p className="text-xs text-slate-600 dark:text-slate-300 leading-relaxed">
|
||||
{duplicateData.comparison[lang] || duplicateData.comparison.en || duplicateData.comparison.es}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-slate-500 italic">
|
||||
Comparing profiles with AI... / Comparando perfiles con IA...
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 italic">
|
||||
{t.comparingWithAi}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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
|
||||
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
|
||||
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"
|
||||
>
|
||||
Keep Both / Conservar ambos
|
||||
{t.keepBoth}
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
Overwrite / Sobrescribir
|
||||
{t.overwrite}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,31 +1,111 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useApp } from "@/components/AppContext";
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { lang, setLang, theme, setTheme } = useApp();
|
||||
|
||||
const toggleLanguage = () => {
|
||||
setLang(lang === "en" ? "es" : "en");
|
||||
};
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(theme === "light" ? "dark" : "light");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 flex flex-col">
|
||||
<header className="bg-white border-b border-slate-200 shadow-sm">
|
||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 flex flex-col transition-colors duration-200">
|
||||
<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="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">
|
||||
<Link href="/jobs" className="text-sm text-slate-600 hover:text-slate-900 font-medium">
|
||||
Jobs
|
||||
<Link
|
||||
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 href="/candidates" className="text-sm text-slate-600 hover:text-slate-900 font-medium">
|
||||
Candidates
|
||||
<Link
|
||||
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 href="/interviews" className="text-sm text-slate-600 hover:text-slate-900 font-medium">
|
||||
Interviews
|
||||
<Link
|
||||
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 href="/workflows" className="text-sm text-slate-600 hover:text-slate-900 font-medium">
|
||||
Workflows
|
||||
<Link
|
||||
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>
|
||||
</nav>
|
||||
</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>
|
||||
</header>
|
||||
<main className="flex-1 max-w-7xl mx-auto w-full p-6 sm:p-8">
|
||||
|
|
|
|||
|
|
@ -1,8 +1,54 @@
|
|||
"use client";
|
||||
|
||||
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() {
|
||||
const { lang } = useApp();
|
||||
const t = translations[lang];
|
||||
const webhookUrl = process.env.NEXT_PUBLIC_N8N_WEBHOOK_URL || "";
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
|
|
@ -17,81 +63,81 @@ export default function WorkflowsPage() {
|
|||
return (
|
||||
<div className="flex flex-col gap-6 max-w-3xl">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Workflows</h1>
|
||||
<p className="text-slate-600 text-sm">
|
||||
Monitor active n8n webhooks and background integration pipelines.
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.workflowsTitle}</h1>
|
||||
<p className="text-slate-600 dark:text-slate-300 text-sm">
|
||||
{t.workflowsSubtitle}
|
||||
</p>
|
||||
</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>
|
||||
<h2 className="text-base font-bold text-slate-900">n8n Webhook Integration</h2>
|
||||
<p className="text-slate-500 text-xs mt-1">
|
||||
This webhook coordinates CV processing and automatic screening candidates scores updates.
|
||||
<h2 className="text-base font-bold text-slate-900 dark:text-white">{t.n8nWebhookIntegration}</h2>
|
||||
<p className="text-slate-500 dark:text-slate-400 text-xs mt-1">
|
||||
{t.webhookDesc}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Integration Status Badge */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
Status:
|
||||
<span className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
||||
{t.statusLabel}
|
||||
</span>
|
||||
<span
|
||||
className={`px-2 py-0.5 text-xs font-semibold rounded-md border ${
|
||||
webhookUrl
|
||||
? "bg-slate-50 text-slate-600 border-slate-200"
|
||||
: "bg-slate-50 text-slate-500 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 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>
|
||||
</div>
|
||||
|
||||
{/* Webhook Input/Copy Panel */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
Active Webhook Target URL
|
||||
<label className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
||||
{t.webhookUrlLabel}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={webhookUrl || "No webhook URL configured. Set NEXT_PUBLIC_N8N_WEBHOOK_URL in environment."}
|
||||
className="flex-1 px-3 py-2 border border-slate-200 rounded-md text-slate-900 bg-slate-50 text-sm focus:outline-none"
|
||||
value={webhookUrl || t.noUrlConfigured}
|
||||
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 && (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{copied ? "Copied!" : "Copy"}
|
||||
{copied ? t.copied : t.copy}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Informational Pipeline Flow */}
|
||||
<div className="pt-4 border-t border-slate-200 flex flex-col gap-3">
|
||||
<h3 className="text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
Automated Recruitment Pipeline Execution
|
||||
<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 dark:text-slate-400 uppercase tracking-wider">
|
||||
{t.automatedPipeline}
|
||||
</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">
|
||||
<span className="font-bold text-blue-600">1.</span>
|
||||
<span className="font-bold text-blue-600 dark:text-blue-400">1.</span>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { AppProvider } from "@/components/AppContext";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
|
|
@ -27,7 +28,9 @@ export default function RootLayout({
|
|||
lang="en"
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
79
components/AppContext.tsx
Normal file
79
components/AppContext.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
darkMode: "class",
|
||||
content: [
|
||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
|
|
@ -13,10 +14,16 @@ const config: Config = {
|
|||
white: "#ffffff",
|
||||
slate: {
|
||||
50: "#f8fafc",
|
||||
100: "#f1f5f9",
|
||||
200: "#e2e8f0",
|
||||
300: "#cbd5e1",
|
||||
400: "#94a3b8",
|
||||
500: "#64748b",
|
||||
600: "#475569",
|
||||
700: "#334155",
|
||||
800: "#1e293b",
|
||||
900: "#0f172a",
|
||||
950: "#020617",
|
||||
},
|
||||
blue: {
|
||||
50: "#eff6ff",
|
||||
|
|
|
|||
Loading…
Reference in a new issue