Compare commits

..

No commits in common. "88815f6895d4df449c69efa2252c44764342d8c6" and "774e5044caea7b8f640c0cb64689075c730ecd59" have entirely different histories.

6 changed files with 147 additions and 1956 deletions

View file

@ -3,16 +3,6 @@
import React, { useState, useEffect } from "react";
import { supabase } from "@/lib/supabase";
import { useApp } from "@/components/AppContext";
import ReactMarkdown from "react-markdown";
interface Comment {
id: string;
text: string;
timestamp: string; // ISO string
author?: string;
stage?: string;
isAi?: boolean;
}
interface Interview {
id: string;
@ -22,32 +12,14 @@ interface Interview {
stage: string;
feedback: string | null;
created_at: string;
updated_at: string;
pinned: boolean;
candidates: {
id: string;
name: string;
contact_info: {
email: string;
phone: string;
skills?: string[];
summary?: string;
};
} | null;
jobs: {
id: string;
title: string;
requirements: {
text?: string;
};
} | null;
}
interface Job {
id: string;
title: string;
}
const translations = {
en: {
interviewsTitle: "Interviews",
@ -59,9 +31,10 @@ const translations = {
role: "Role",
dateScheduled: "Date Scheduled",
stage: "Stage",
interviewFeedback: "Interview Feedback & Timeline",
feedbackPlaceholder: "Write assessment feedback, observations or notes...",
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",
@ -69,24 +42,7 @@ const translations = {
cultural: "Cultural",
offer: "Offer",
hired: "Hired",
rejected: "Rejected",
allPositions: "All Positions",
openPositions: "Open Positions",
addComment: "Add Comment",
commentPlaceholder: "Type a new comment/update...",
noCommentsYet: "No comments added yet.",
backToMain: "Back to All Interviews",
selectCandidateDetails: "Select a candidate to view details",
pinCandidate: "Pin Candidate",
unpinCandidate: "Unpin Candidate",
postedByAgent: "Agent",
noCandidatesInStage: "No candidates for this position.",
candidates: "Candidates",
aiSuggestButton: "Get AI Suggestion",
suggesting: "Analyzing...",
cooldownMessage: "Cooldown: change stage or wait {hours}h to query AI suggestion again"
rejected: "Rejected"
},
es: {
interviewsTitle: "Entrevistas",
@ -98,9 +54,10 @@ const translations = {
role: "Puesto",
dateScheduled: "Fecha Programada",
stage: "Etapa",
interviewFeedback: "Comentarios y Cronología",
feedbackPlaceholder: "Escriba comentarios, observaciones o notas de la evaluación...",
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",
@ -108,272 +65,108 @@ const translations = {
cultural: "Cultural",
offer: "Oferta",
hired: "Contratado",
rejected: "Rechazado",
allPositions: "Todos los Puestos",
openPositions: "Puestos Abiertos",
addComment: "Agregar Comentario",
commentPlaceholder: "Escriba un nuevo comentario o actualización...",
noCommentsYet: "Aún no hay comentarios agregados.",
backToMain: "Volver a Todas las Entrevistas",
selectCandidateDetails: "Seleccione un candidato para ver los detalles",
pinCandidate: "Fijar Candidato",
unpinCandidate: "Desfijar Candidato",
postedByAgent: "Agente",
noCandidatesInStage: "No hay candidatos para este puesto.",
candidates: "Candidatos",
aiSuggestButton: "Obtener Sugerencia IA",
suggesting: "Analizando...",
cooldownMessage: "Cooldown: cambie la etapa o espere {hours}h para volver a pedir sugerencia"
rejected: "Rechazado"
}
};
const translateStage = (stage: string, lang: "en" | "es") => {
if (lang === "es") {
if (stage === "Screening") return "Preselección";
if (stage === "Technical") return "Técnica";
if (stage === "Cultural") return "Cultural";
if (stage === "Offer") return "Oferta";
if (stage === "Hired") return "Contratado";
if (stage === "Rejected") return "Rechazado";
}
return stage;
};
function parseFeedback(feedbackText: string | null): Comment[] {
if (!feedbackText) return [];
try {
const parsed = JSON.parse(feedbackText);
if (Array.isArray(parsed)) {
return parsed as Comment[];
}
} catch {
// Ignore and fall back to plain text format
}
return [{ id: "legacy-initial", text: feedbackText, timestamp: new Date().toISOString() }];
}
function generateCommentId(): string {
return Math.random().toString(36).substring(2, 9) + Date.now().toString(36);
}
export default function InterviewsPage() {
const { lang } = useApp();
const t = translations[lang];
const [interviews, setInterviews] = useState<Interview[]>([]);
const [jobs, setJobs] = useState<Job[]>([]);
const [loading, setLoading] = useState(true);
const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
const [selectedInterviewId, setSelectedInterviewId] = useState<string | null>(null);
const [newComment, setNewComment] = useState("");
const [collapsedComments, setCollapsedComments] = useState<Record<string, boolean>>({});
const [suggesting, setSuggesting] = useState(false);
const [updatingId, setUpdatingId] = useState<string | null>(null);
const [editStates, setEditStates] = useState<
Record<string, { stage: string; feedback: string }>
>({});
const [actionMessage, setActionMessage] = useState<string | null>(null);
useEffect(() => {
let active = true;
async function loadData() {
async function fetchInterviews() {
try {
const [interviewsRes, jobsRes] = await Promise.all([
supabase
.from("interviews")
.select("*, candidates(*), jobs(*)"),
supabase
.from("jobs")
.select("id, title")
.order("title", { ascending: true })
]);
if (interviewsRes.error) throw interviewsRes.error;
if (jobsRes.error) throw jobsRes.error;
const { data, error } = await supabase
.from("interviews")
.select("*, candidates(name), jobs(title)")
.order("interview_date", { ascending: false });
if (error) throw error;
if (active) {
setInterviews((interviewsRes.data as unknown as Interview[]) || []);
setJobs((jobsRes.data as Job[]) || []);
const typedData = (data as unknown as Interview[]) || [];
setInterviews(typedData);
// Initialize edit states
const initialEditStates: Record<string, { stage: string; feedback: string }> = {};
typedData.forEach((item) => {
initialEditStates[item.id] = {
stage: item.stage,
feedback: item.feedback || "",
};
});
setEditStates(initialEditStates);
setLoading(false);
}
} catch (err) {
console.error("Error loading data:", err);
console.error("Error fetching interviews:", err);
if (active) {
setLoading(false);
}
}
}
loadData();
fetchInterviews();
return () => {
active = false;
};
}, []);
const updateInterviewField = async (id: string, updates: Partial<Interview>) => {
const handleStateChange = (id: string, field: "stage" | "feedback", value: string) => {
setEditStates((prev) => ({
...prev,
[id]: {
...prev[id],
[field]: value,
},
}));
};
const handleUpdate = async (id: string) => {
const editState = editStates[id];
if (!editState) return;
try {
const updated_at = new Date().toISOString();
setUpdatingId(id);
setActionMessage(null);
const { error } = await supabase
.from("interviews")
.update({
...updates,
updated_at,
stage: editState.stage,
feedback: editState.feedback,
})
.eq("id", id);
if (error) throw error;
// Update state locally
setActionMessage(t.updateSuccess);
// Hide message after 3 seconds
setTimeout(() => setActionMessage(null), 3000);
// Refresh interview data locally
setInterviews((prev) =>
prev.map((item) =>
item.id === id
? { ...item, ...updates, updated_at }
? { ...item, stage: editState.stage, feedback: editState.feedback }
: item
)
);
} catch (err) {
} catch (err: unknown) {
console.error("Error updating interview:", err);
}
};
const handleSelectJob = (jobId: string | null) => {
setSelectedJobId(jobId);
if (jobId) {
const activeInt = interviews.find((i) => i.id === selectedInterviewId);
if (activeInt && activeInt.job_id !== jobId) {
setSelectedInterviewId(null);
}
}
};
const togglePin = async (id: string, currentPinned: boolean) => {
await updateInterviewField(id, { pinned: !currentPinned });
};
const handleAddComment = async () => {
if (!selectedInterviewId || !newComment.trim()) return;
const currentInterview = interviews.find((i) => i.id === selectedInterviewId);
if (!currentInterview) return;
const existingComments = parseFeedback(currentInterview.feedback);
const commentId = generateCommentId();
const newCommentItem: Comment = {
id: commentId,
text: newComment.trim(),
timestamp: new Date().toISOString(),
author: lang === "es" ? "Agente" : "Agent"
};
const updatedComments = [...existingComments, newCommentItem];
await updateInterviewField(selectedInterviewId, { feedback: JSON.stringify(updatedComments) });
setNewComment("");
};
const toggleCommentCollapse = (commentId: string) => {
setCollapsedComments((prev) => ({
...prev,
[commentId]: !prev[commentId],
}));
};
const getInterviewCountForJob = (jobId: string) => {
return interviews.filter((item) => item.job_id === jobId).length;
};
const getSortedInterviews = (items: Interview[]) => {
return [...items].sort((a, b) => {
if (a.pinned && !b.pinned) return -1;
if (!a.pinned && b.pinned) return 1;
if (a.pinned && b.pinned) {
const nameA = a.candidates?.name || "";
const nameB = b.candidates?.name || "";
return nameA.localeCompare(nameB);
}
const dateA = new Date(a.updated_at || a.created_at).getTime();
const dateB = new Date(b.updated_at || b.created_at).getTime();
return dateA - dateB;
});
};
const getFilteredInterviews = () => {
if (selectedJobId) {
return interviews.filter((item) => item.job_id === selectedJobId);
}
return interviews;
};
const filteredInterviews = getFilteredInterviews();
const sortedInterviews = getSortedInterviews(filteredInterviews);
const selectedInterview = interviews.find((item) => item.id === selectedInterviewId) || null;
const selectedInterviewComments = selectedInterview ? parseFeedback(selectedInterview.feedback) : [];
// Cooldown calculation for AI suggestion
const lastAiComment = [...selectedInterviewComments]
.reverse()
.find((c) => c.author === "AI Assistant" || c.author === "Asistente IA" || c.isAi);
const getCooldownStatus = () => {
if (!selectedInterview || !lastAiComment) return { active: false, hours: 0 };
if (lastAiComment.stage && lastAiComment.stage !== selectedInterview.stage) return { active: false, hours: 0 };
const lastTime = new Date(lastAiComment.timestamp).getTime();
const oneDayMs = 24 * 60 * 60 * 1000;
// eslint-disable-next-line react-hooks/purity
const elapsed = Date.now() - lastTime;
if (elapsed < oneDayMs) {
return { active: true, hours: Math.ceil((oneDayMs - elapsed) / (1000 * 60 * 60)) };
}
return { active: false, hours: 0 };
};
const cooldownStatus = getCooldownStatus();
const cooldownActive = cooldownStatus.active;
const cooldownHours = cooldownStatus.hours;
const handleGetAiSuggestion = async () => {
if (!selectedInterview || cooldownActive || suggesting) return;
setSuggesting(true);
try {
const response = await fetch("/api/interviews/suggest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
candidateName: selectedInterview.candidates?.name,
jobTitle: selectedInterview.jobs?.title,
currentStage: selectedInterview.stage,
candidateSummary: selectedInterview.candidates?.contact_info?.summary,
candidateSkills: selectedInterview.candidates?.contact_info?.skills,
jobRequirements: selectedInterview.jobs?.requirements?.text,
commentHistory: selectedInterviewComments,
lang,
}),
});
if (!response.ok) throw new Error("Failed to get suggestion");
const data = await response.json();
// Save suggestion as comment
const commentId = generateCommentId();
const newCommentItem: Comment = {
id: commentId,
text: data.suggestion,
timestamp: new Date().toISOString(),
author: lang === "es" ? "Asistente IA" : "AI Assistant",
stage: selectedInterview.stage,
isAi: true
};
const updatedComments = [...selectedInterviewComments, newCommentItem];
await updateInterviewField(selectedInterview.id, { feedback: JSON.stringify(updatedComments) });
} catch (err) {
console.error("Error getting AI suggestion:", err);
setActionMessage(t.updateFailed);
} finally {
setSuggesting(false);
setUpdatingId(null);
}
};
@ -386,21 +179,10 @@ export default function InterviewsPage() {
{t.interviewsSubtitle}
</p>
</div>
{/* Back to main button */}
{(selectedJobId !== null || selectedInterviewId !== null) && (
<button
onClick={() => {
setSelectedJobId(null);
setSelectedInterviewId(null);
}}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-white border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 rounded-md transition duration-200 cursor-pointer"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-3.5 h-3.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
</svg>
{t.backToMain}
</button>
{actionMessage && (
<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>
@ -415,267 +197,86 @@ export default function InterviewsPage() {
</p>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6 items-start">
{/* COLUMN 1: Open Positions Sidebar */}
<div className="lg: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-1.5">
<h3 className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider px-2 mb-1">
{t.openPositions}
</h3>
<button
onClick={() => handleSelectJob(null)}
className={`w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center justify-between cursor-pointer ${
selectedJobId === null
? "bg-slate-100 dark:bg-slate-800 text-slate-900 dark:text-white font-medium"
: "text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800/50"
}`}
>
<span>{t.allPositions}</span>
<span className="text-xs px-2 py-0.5 bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-full font-medium">
{interviews.length}
</span>
</button>
<div className="grid grid-cols-1 gap-6">
{interviews.map((interview) => {
const currentEdit = editStates[interview.id] || {
stage: interview.stage,
feedback: interview.feedback || "",
};
{jobs.map((job) => {
const count = getInterviewCountForJob(job.id);
return (
<button
key={job.id}
onClick={() => handleSelectJob(job.id)}
className={`w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center justify-between cursor-pointer ${
selectedJobId === job.id
? "bg-slate-100 dark:bg-slate-800 text-slate-900 dark:text-white font-medium"
: "text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800/50"
}`}
>
<span className="truncate mr-2" title={job.title}>{job.title}</span>
<span className="text-xs px-2 py-0.5 bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-full font-medium flex-shrink-0">
{count}
</span>
</button>
);
})}
</div>
{/* COLUMN 2: Candidates List */}
<div className="lg: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="border-b border-slate-200 dark:border-slate-800 pb-2">
<h3 className="text-sm font-bold text-slate-900 dark:text-white">
{t.candidates}
</h3>
</div>
{sortedInterviews.length === 0 ? (
<p className="text-slate-500 dark:text-slate-400 text-sm py-4 italic">
{t.noCandidatesInStage}
</p>
) : (
<div className="flex flex-col gap-3">
{sortedInterviews.map((interview) => (
<div
key={interview.id}
onClick={() => setSelectedInterviewId(interview.id)}
className={`cursor-pointer p-4 rounded-lg shadow-sm border transition duration-200 relative ${
selectedInterviewId === interview.id
? "bg-slate-50 dark:bg-slate-800/40 border-blue-300 dark:border-blue-800"
: "bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 hover:border-slate-300 dark:hover:border-slate-700"
} ${
interview.pinned
? "border-l-4 border-l-blue-600 pl-3"
: "border-l border-l-slate-200 dark:border-l-slate-800 pl-4"
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<h4 className="text-sm font-bold text-slate-900 dark:text-white truncate">
{interview.candidates?.name || t.unknownCandidate}
</h4>
<p className="text-xs text-slate-500 dark:text-slate-400 truncate mt-0.5">
{interview.jobs?.title || t.unknownJob}
</p>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
<span className={`px-2 py-0.5 text-[10px] font-semibold rounded-full ${
interview.stage === "Hired"
? "bg-green-100 dark:bg-green-950/40 text-green-800 dark:text-green-300"
: interview.stage === "Rejected"
? "bg-red-100 dark:bg-red-950/40 text-red-800 dark:text-red-300"
: "bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300"
}`}>
{translateStage(interview.stage, lang)}
</span>
<button
onClick={(e) => {
e.stopPropagation();
togglePin(interview.id, interview.pinned);
}}
className="p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 transition-colors cursor-pointer"
title={interview.pinned ? t.unpinCandidate : t.pinCandidate}
>
{interview.pinned ? (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400">
<path d="M12 17v5M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.89A.5.5 0 0 0 6.36 14h11.27a.5.5 0 0 0 .25-.56l-1.78-.89a2 2 0 0 1-1.11-1.79V4a2 2 0 0 1 2-2h-10a2 2 0 0 1 2 2v6.76z" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-3.5 h-3.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 17v5M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.89A.5.5 0 0 0 6.36 14h11.27a.5.5 0 0 0 .25-.56l-1.78-.89a2 2 0 0 1-1.11-1.79V4a2 2 0 0 1 2-2h-10a2 2 0 0 1 2 2v6.76z" />
</svg>
)}
</button>
</div>
</div>
return (
<div
key={interview.id}
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 dark:border-slate-800">
<div>
<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 dark:text-slate-300 font-medium">
{t.role}: {interview.jobs?.title || t.unknownJob}
</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
{t.dateScheduled}:{" "}
{new Date(interview.interview_date).toLocaleString()}
</p>
</div>
))}
</div>
)}
</div>
{/* COLUMN 3: Candidate Details Pane */}
<div className="lg:col-span-2 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 min-h-[300px]">
{!selectedInterview ? (
<div className="flex-1 flex flex-col items-center justify-center text-center py-12">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-10 h-10 text-slate-300 dark:text-slate-600 mb-3">
<path strokeLinecap="round" strokeLinejoin="round" d="M17.982 18.725A7.488 7.488 0 0 0 12 15.75a7.488 7.488 0 0 0-5.982 2.975m11.963 0a9 9 0 1 0-11.963 0m11.963 0A8.966 8.966 0 0 1 12 21a8.966 8.966 0 0 1-5.982-2.275M15 9.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
<p className="text-slate-500 dark:text-slate-400 text-sm font-medium">
{t.selectCandidateDetails}
</p>
</div>
) : (
<div className="flex flex-col gap-6">
{/* Details Header */}
<div className="border-b border-slate-200 dark:border-slate-800 pb-4 flex flex-col gap-1.5">
<h2 className="text-xl font-bold text-slate-900 dark:text-white">
{selectedInterview.candidates?.name || t.unknownCandidate}
</h2>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-slate-600 dark:text-slate-300 font-medium">
<span>
{t.role}: <span className="font-bold text-slate-800 dark:text-slate-200">{selectedInterview.jobs?.title || t.unknownJob}</span>
</span>
<span className="text-slate-300 dark:text-slate-700 hidden sm:inline">|</span>
<span>
{t.dateScheduled}: <span className="text-slate-500 dark:text-slate-400">{new Date(selectedInterview.interview_date).toLocaleString()}</span>
</span>
</div>
</div>
{/* Stage Dropdown Selector */}
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">
{t.stage}:
</label>
<select
value={selectedInterview.stage}
onChange={(e) => updateInterviewField(selectedInterview.id, { stage: e.target.value })}
className="w-full px-3 py-2 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-md text-slate-900 dark:text-white focus:outline-none focus:border-blue-500 transition-colors"
>
<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>
{/* Comments Thread System */}
<div className="flex flex-col gap-4 border-t border-slate-200 dark:border-slate-800 pt-4">
<div className="flex items-center justify-between gap-4">
<h3 className="text-xs font-bold text-slate-400 dark:text-slate-500 uppercase tracking-wider">
{t.interviewFeedback}
</h3>
{/* AI Suggestion Button */}
<button
onClick={handleGetAiSuggestion}
disabled={suggesting || cooldownActive}
title={cooldownActive ? t.cooldownMessage.replace("{hours}", String(cooldownHours)) : undefined}
className={`px-3 py-1.5 text-xs font-bold rounded-md border transition-all duration-200 cursor-pointer flex items-center gap-1.5 select-none ${
cooldownActive
? "bg-slate-50 dark:bg-slate-800/50 text-slate-400 border-slate-200 dark:border-slate-800 cursor-not-allowed opacity-60"
: "bg-blue-50 hover:bg-blue-100 dark:bg-blue-950/20 dark:hover:bg-blue-950/45 text-blue-600 dark:text-blue-400 border-blue-200 dark:border-blue-800/80"
}`}
<div className="flex items-center gap-2">
<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 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-md text-slate-900 dark:text-white focus:outline-none"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-3.5 h-3.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904 9 21m0-12h.008v.008H9V9Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0ZM12 10.5h.008v.008H12V10.5Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm-.75 3h.008v.008H11.625V13.5Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0ZM15 9.75h.008v.008H15V9.75Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0ZM18 12h.008v.008H18V12Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm-.75 3h.008v.008H17.625V15Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0ZM2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm11.379-3.379a3 3 0 0 0-4.242 4.242l4.242-4.242Z" />
</svg>
{suggesting ? t.suggesting : t.aiSuggestButton}
</button>
</div>
{/* Comment List */}
<div className="max-h-72 overflow-y-auto pr-1 flex flex-col gap-3">
{selectedInterviewComments.length === 0 ? (
<p className="text-slate-500 dark:text-slate-400 text-xs italic py-2">
{t.noCommentsYet}
</p>
) : (
selectedInterviewComments.map((comment) => {
const isCollapsed = collapsedComments[comment.id] || false;
const isAiComment = comment.isAi || comment.author === "AI Assistant" || comment.author === "Asistente IA";
return (
<div
key={comment.id}
className={`p-3 rounded-lg border flex flex-col gap-1.5 transition-colors ${
isAiComment
? "bg-blue-50/20 dark:bg-blue-950/10 border-blue-100 dark:border-blue-900/50"
: "bg-slate-50 dark:bg-slate-800/40 border-slate-100 dark:border-slate-800/60"
}`}
>
<div className="flex items-center justify-between text-[11px] text-slate-500 dark:text-slate-400">
<span className={`font-bold ${isAiComment ? "text-blue-600 dark:text-blue-400" : "text-slate-700 dark:text-slate-300"}`}>
{comment.author || t.postedByAgent}
</span>
<div className="flex items-center gap-2">
<span>
{new Date(comment.timestamp).toLocaleString()}
</span>
<button
onClick={() => toggleCommentCollapse(comment.id)}
className="text-xs text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 font-semibold cursor-pointer select-none"
>
{isCollapsed ? "[+]" : "[-]"}
</button>
</div>
</div>
{!isCollapsed && (
<div className="text-xs text-slate-700 dark:text-slate-300 leading-relaxed markdown-content">
<ReactMarkdown>{comment.text}</ReactMarkdown>
</div>
)}
</div>
);
})
)}
</div>
{/* Add Comment Input */}
<div className="flex flex-col gap-2 mt-2">
<textarea
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder={t.commentPlaceholder}
rows={3}
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 focus:border-blue-500 transition-colors"
/>
<div className="flex justify-end">
<button
onClick={handleAddComment}
disabled={!newComment.trim()}
className="py-1.5 px-3.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-xs font-semibold rounded-md transition duration-200 cursor-pointer"
>
{t.addComment}
</button>
</div>
<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 dark:text-slate-400 uppercase tracking-wider">
{t.interviewFeedback}
</label>
<textarea
value={currentEdit.feedback}
onChange={(e) =>
handleStateChange(
interview.id,
"feedback",
e.target.value
)
}
placeholder={t.feedbackPlaceholder}
rows={3}
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"
/>
</div>
{/* Save Button */}
<div className="flex justify-end">
<button
onClick={() => handleUpdate(interview.id)}
disabled={updatingId === interview.id}
className="py-2 px-4 bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold rounded-md transition duration-200 disabled:opacity-50"
>
{updatingId === interview.id ? t.saving : t.updateInterview}
</button>
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>

View file

@ -1,77 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const {
candidateName,
jobTitle,
currentStage,
candidateSummary,
candidateSkills,
jobRequirements,
commentHistory,
lang,
} = body;
if (!candidateName || !jobTitle || !currentStage) {
return NextResponse.json({ error: "Missing required parameters" }, { status: 400 });
}
const webhookUrl = process.env.NEXT_PUBLIC_N8N_WEBHOOK_URL;
const baseUrl = webhookUrl
? webhookUrl.replace(/\/evaluate-candidate$/, "")
: "https://n8n.gaboggamer.online/webhook";
const targetUrl = `${baseUrl}/suggest-next-steps`;
const response = await fetch(targetUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
candidateName,
jobTitle,
currentStage,
candidateSummary,
candidateSkills,
jobRequirements,
commentHistory,
lang,
}),
});
if (!response.ok) {
const errText = await response.text();
return NextResponse.json(
{ error: `n8n suggestion error: ${response.status} - ${errText}` },
{ status: 500 }
);
}
const data = await response.json();
// Support either direct suggestion string, or wrapped inside an object/array
let result = Array.isArray(data) ? data[0] : data;
if (result && result.json) {
result = result.json;
}
const suggestionText = typeof result === "string" ? result : result.suggestion;
if (!suggestionText) {
return NextResponse.json({ error: "Invalid response structure from n8n suggestion" }, { status: 500 });
}
return NextResponse.json({
success: true,
suggestion: suggestionText.trim(),
});
} catch (error: unknown) {
console.error("Error in interviews suggest API:", error);
const errorMessage = error instanceof Error ? error.message : "Internal Server Error";
return NextResponse.json({ error: errorMessage }, { status: 500 });
}
}

1182
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -13,8 +13,7 @@
"next": "16.2.7",
"pdf-parse": "^2.4.5",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-markdown": "^10.1.0"
"react-dom": "19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",

View file

@ -573,85 +573,6 @@ async function main() {
};
// BRANCH D: AI Next Step Suggestion Webhook Branch
const suggestWebhookNode = {
parameters: {
httpMethod: "POST",
path: "suggest-next-steps",
responseMode: "responseNode",
options: {},
},
id: "suggest-webhook-trigger",
name: "Webhook Trigger - Suggest",
type: "n8n-nodes-base.webhook",
typeVersion: 1.1,
position: [100, 1400],
};
const suggestChainNode = {
parameters: {
promptType: "define",
hasOutputParser: false,
needsFallback: configureFallback,
enableFallbackModel: configureFallback,
hasFallbackModel: configureFallback,
text: "=Candidate Name: {{ $('Webhook Trigger - Suggest').item.json.body.candidateName }}\nVacancy: {{ $('Webhook Trigger - Suggest').item.json.body.jobTitle }}\nCurrent Stage: {{ $('Webhook Trigger - Suggest').item.json.body.currentStage }}\nCandidate Summary: {{ $('Webhook Trigger - Suggest').item.json.body.candidateSummary || 'None provided' }}\nCandidate Skills: {{ JSON.stringify($('Webhook Trigger - Suggest').item.json.body.candidateSkills || []) }}\nJob Requirements: {{ $('Webhook Trigger - Suggest').item.json.body.jobRequirements || 'None provided' }}\n\nComments History:\n{{ JSON.stringify($('Webhook Trigger - Suggest').item.json.body.commentHistory || []) }}",
systemMessage: "You are an AI recruitment co-pilot. Suggest the next step for this candidate in their interview process. Requirements:\n1. MUST be extremely brief and concise (max 3-4 bullet points).\n2. MUST focus on actionable suggestions based on their current stage, comment history, and candidate profile.\n3. Respond in Spanish if the body.lang parameter is 'es', otherwise English.\n4. Use standard Markdown formatting. Do not include any pre-text or post-text. Return only the markdown content.",
},
id: "suggest-llm-chain",
name: "LLM Chain Suggestion (Next Steps)",
type: "@n8n/n8n-nodes-langchain.chainLlm",
typeVersion: 1.9,
position: [400, 1400],
};
const suggestPrimaryModelNode = {
parameters: primaryConfig.nodeParameters,
id: "suggest-primary-model",
name: "Primary Chat Model - Suggest",
type: primaryConfig.nodeType,
typeVersion: 1,
position: [300, 1580],
credentials: {
[primaryConfig.credentialType]: {
id: primaryCredId,
name: `Semillero2_Primary_${primaryConfig.credentialType}`,
},
},
};
const suggestSetNode = {
parameters: {
assignments: {
assignments: [
{
name: "suggestion",
value: "={{ $json.text }}",
type: "string",
},
],
},
include: "none",
options: {},
},
id: "suggest-format-data",
name: "Format Suggestion Data",
type: "n8n-nodes-base.set",
typeVersion: 3.4,
position: [700, 1400],
};
const suggestRespondWebhookNode = {
parameters: {
options: {},
},
id: "suggest-respond-webhook",
name: "Respond to Webhook - Suggest",
type: "n8n-nodes-base.respondToWebhook",
typeVersion: 1.1,
position: [900, 1400],
};
// Base nodes array
const wNodes: any[] = [
evalWebhookNode,
@ -673,13 +594,7 @@ async function main() {
profileJobChainNode,
profileJobPrimaryModelNode,
profileJobJsonParserNode,
profileJobRespondWebhookNode,
suggestWebhookNode,
suggestChainNode,
suggestPrimaryModelNode,
suggestSetNode,
suggestRespondWebhookNode
profileJobRespondWebhookNode
];
// Base connections map
@ -860,52 +775,6 @@ async function main() {
},
],
],
},
// BRANCH D
"Webhook Trigger - Suggest": {
main: [
[
{
node: "LLM Chain Suggestion (Next Steps)",
type: "main",
index: 0,
},
],
],
},
"Primary Chat Model - Suggest": {
ai_languageModel: [
[
{
node: "LLM Chain Suggestion (Next Steps)",
type: "ai_languageModel",
index: 0,
},
],
],
},
"LLM Chain Suggestion (Next Steps)": {
main: [
[
{
node: "Format Suggestion Data",
type: "main",
index: 0,
},
],
],
},
"Format Suggestion Data": {
main: [
[
{
node: "Respond to Webhook - Suggest",
type: "main",
index: 0,
},
],
],
}
};
@ -996,35 +865,6 @@ async function main() {
],
],
};
const suggestFallbackModelNode = {
parameters: fallbackConfig.nodeParameters,
id: "suggest-fallback-model",
name: "Fallback Chat Model - Suggest",
type: fallbackConfig.nodeType,
typeVersion: 1,
position: [560, 1580],
credentials: {
[fallbackConfig.credentialType]: {
id: fallbackCredId,
name: `Semillero2_Fallback_${fallbackConfig.credentialType}`,
},
},
};
wNodes.push(suggestFallbackModelNode);
wConnections["Fallback Chat Model - Suggest"] = {
ai_languageModel: [
[
{
node: "LLM Chain Suggestion (Next Steps)",
type: "ai_languageModel",
index: 1,
},
],
],
};
}
// Define complete workflow object

View file

@ -1,2 +0,0 @@
ALTER TABLE interviews ADD COLUMN IF NOT EXISTS pinned BOOLEAN DEFAULT FALSE NOT NULL;
ALTER TABLE interviews ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now() NOT NULL;