Compare commits
4 commits
774e5044ca
...
88815f6895
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88815f6895 | ||
|
|
cc9087630c | ||
|
|
b1c2f7fbd2 | ||
|
|
633f1a6f2b |
6 changed files with 1956 additions and 147 deletions
|
|
@ -3,6 +3,16 @@
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { supabase } from "@/lib/supabase";
|
import { supabase } from "@/lib/supabase";
|
||||||
import { useApp } from "@/components/AppContext";
|
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 {
|
interface Interview {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -12,14 +22,32 @@ interface Interview {
|
||||||
stage: string;
|
stage: string;
|
||||||
feedback: string | null;
|
feedback: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
pinned: boolean;
|
||||||
candidates: {
|
candidates: {
|
||||||
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
contact_info: {
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
skills?: string[];
|
||||||
|
summary?: string;
|
||||||
|
};
|
||||||
} | null;
|
} | null;
|
||||||
jobs: {
|
jobs: {
|
||||||
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
requirements: {
|
||||||
|
text?: string;
|
||||||
|
};
|
||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Job {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
const translations = {
|
const translations = {
|
||||||
en: {
|
en: {
|
||||||
interviewsTitle: "Interviews",
|
interviewsTitle: "Interviews",
|
||||||
|
|
@ -31,10 +59,9 @@ const translations = {
|
||||||
role: "Role",
|
role: "Role",
|
||||||
dateScheduled: "Date Scheduled",
|
dateScheduled: "Date Scheduled",
|
||||||
stage: "Stage",
|
stage: "Stage",
|
||||||
interviewFeedback: "Interview Feedback",
|
interviewFeedback: "Interview Feedback & Timeline",
|
||||||
feedbackPlaceholder: "Write detailed assessment feedback, questions, or observations...",
|
feedbackPlaceholder: "Write assessment feedback, observations or notes...",
|
||||||
saving: "Saving...",
|
saving: "Saving...",
|
||||||
updateInterview: "Update Interview",
|
|
||||||
updateSuccess: "Interview updated successfully!",
|
updateSuccess: "Interview updated successfully!",
|
||||||
updateFailed: "Failed to update interview.",
|
updateFailed: "Failed to update interview.",
|
||||||
screening: "Screening",
|
screening: "Screening",
|
||||||
|
|
@ -42,7 +69,24 @@ const translations = {
|
||||||
cultural: "Cultural",
|
cultural: "Cultural",
|
||||||
offer: "Offer",
|
offer: "Offer",
|
||||||
hired: "Hired",
|
hired: "Hired",
|
||||||
rejected: "Rejected"
|
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"
|
||||||
},
|
},
|
||||||
es: {
|
es: {
|
||||||
interviewsTitle: "Entrevistas",
|
interviewsTitle: "Entrevistas",
|
||||||
|
|
@ -54,10 +98,9 @@ const translations = {
|
||||||
role: "Puesto",
|
role: "Puesto",
|
||||||
dateScheduled: "Fecha Programada",
|
dateScheduled: "Fecha Programada",
|
||||||
stage: "Etapa",
|
stage: "Etapa",
|
||||||
interviewFeedback: "Comentarios de la Entrevista",
|
interviewFeedback: "Comentarios y Cronología",
|
||||||
feedbackPlaceholder: "Escriba comentarios detallados de la evaluación, preguntas u observaciones...",
|
feedbackPlaceholder: "Escriba comentarios, observaciones o notas de la evaluación...",
|
||||||
saving: "Guardando...",
|
saving: "Guardando...",
|
||||||
updateInterview: "Actualizar Entrevista",
|
|
||||||
updateSuccess: "¡Entrevista actualizada con éxito!",
|
updateSuccess: "¡Entrevista actualizada con éxito!",
|
||||||
updateFailed: "Error al actualizar la entrevista.",
|
updateFailed: "Error al actualizar la entrevista.",
|
||||||
screening: "Preselección",
|
screening: "Preselección",
|
||||||
|
|
@ -65,108 +108,272 @@ const translations = {
|
||||||
cultural: "Cultural",
|
cultural: "Cultural",
|
||||||
offer: "Oferta",
|
offer: "Oferta",
|
||||||
hired: "Contratado",
|
hired: "Contratado",
|
||||||
rejected: "Rechazado"
|
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"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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() {
|
export default function InterviewsPage() {
|
||||||
const { lang } = useApp();
|
const { lang } = useApp();
|
||||||
const t = translations[lang];
|
const t = translations[lang];
|
||||||
|
|
||||||
const [interviews, setInterviews] = useState<Interview[]>([]);
|
const [interviews, setInterviews] = useState<Interview[]>([]);
|
||||||
|
const [jobs, setJobs] = useState<Job[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [updatingId, setUpdatingId] = useState<string | null>(null);
|
|
||||||
const [editStates, setEditStates] = useState<
|
const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
|
||||||
Record<string, { stage: string; feedback: string }>
|
const [selectedInterviewId, setSelectedInterviewId] = useState<string | null>(null);
|
||||||
>({});
|
|
||||||
const [actionMessage, setActionMessage] = useState<string | null>(null);
|
const [newComment, setNewComment] = useState("");
|
||||||
|
const [collapsedComments, setCollapsedComments] = useState<Record<string, boolean>>({});
|
||||||
|
const [suggesting, setSuggesting] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
|
|
||||||
async function fetchInterviews() {
|
async function loadData() {
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase
|
const [interviewsRes, jobsRes] = await Promise.all([
|
||||||
|
supabase
|
||||||
.from("interviews")
|
.from("interviews")
|
||||||
.select("*, candidates(name), jobs(title)")
|
.select("*, candidates(*), jobs(*)"),
|
||||||
.order("interview_date", { ascending: false });
|
supabase
|
||||||
|
.from("jobs")
|
||||||
|
.select("id, title")
|
||||||
|
.order("title", { ascending: true })
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (interviewsRes.error) throw interviewsRes.error;
|
||||||
|
if (jobsRes.error) throw jobsRes.error;
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
if (active) {
|
if (active) {
|
||||||
const typedData = (data as unknown as Interview[]) || [];
|
setInterviews((interviewsRes.data as unknown as Interview[]) || []);
|
||||||
setInterviews(typedData);
|
setJobs((jobsRes.data as Job[]) || []);
|
||||||
|
|
||||||
// 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);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error fetching interviews:", err);
|
console.error("Error loading data:", err);
|
||||||
if (active) {
|
if (active) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchInterviews();
|
loadData();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleStateChange = (id: string, field: "stage" | "feedback", value: string) => {
|
const updateInterviewField = async (id: string, updates: Partial<Interview>) => {
|
||||||
setEditStates((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[id]: {
|
|
||||||
...prev[id],
|
|
||||||
[field]: value,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdate = async (id: string) => {
|
|
||||||
const editState = editStates[id];
|
|
||||||
if (!editState) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setUpdatingId(id);
|
const updated_at = new Date().toISOString();
|
||||||
setActionMessage(null);
|
|
||||||
|
|
||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
.from("interviews")
|
.from("interviews")
|
||||||
.update({
|
.update({
|
||||||
stage: editState.stage,
|
...updates,
|
||||||
feedback: editState.feedback,
|
updated_at,
|
||||||
})
|
})
|
||||||
.eq("id", id);
|
.eq("id", id);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
setActionMessage(t.updateSuccess);
|
// Update state locally
|
||||||
// Hide message after 3 seconds
|
|
||||||
setTimeout(() => setActionMessage(null), 3000);
|
|
||||||
|
|
||||||
// Refresh interview data locally
|
|
||||||
setInterviews((prev) =>
|
setInterviews((prev) =>
|
||||||
prev.map((item) =>
|
prev.map((item) =>
|
||||||
item.id === id
|
item.id === id
|
||||||
? { ...item, stage: editState.stage, feedback: editState.feedback }
|
? { ...item, ...updates, updated_at }
|
||||||
: item
|
: item
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
} catch (err: unknown) {
|
} catch (err) {
|
||||||
console.error("Error updating interview:", err);
|
console.error("Error updating interview:", err);
|
||||||
setActionMessage(t.updateFailed);
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
} finally {
|
} finally {
|
||||||
setUpdatingId(null);
|
setSuggesting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -179,10 +386,21 @@ export default function InterviewsPage() {
|
||||||
{t.interviewsSubtitle}
|
{t.interviewsSubtitle}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{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">
|
{/* Back to main button */}
|
||||||
{actionMessage}
|
{(selectedJobId !== null || selectedInterviewId !== null) && (
|
||||||
</div>
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -197,42 +415,162 @@ export default function InterviewsPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6 items-start">
|
||||||
{interviews.map((interview) => {
|
{/* COLUMN 1: Open Positions Sidebar */}
|
||||||
const currentEdit = editStates[interview.id] || {
|
<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">
|
||||||
stage: interview.stage,
|
<h3 className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider px-2 mb-1">
|
||||||
feedback: interview.feedback || "",
|
{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>
|
||||||
|
|
||||||
|
{jobs.map((job) => {
|
||||||
|
const count = getInterviewCountForJob(job.id);
|
||||||
return (
|
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
|
<div
|
||||||
key={interview.id}
|
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"
|
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"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
<div className="flex items-start justify-between gap-2">
|
||||||
<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-1 min-w-0">
|
||||||
<div>
|
<h4 className="text-sm font-bold text-slate-900 dark:text-white truncate">
|
||||||
<h2 className="text-base font-bold text-slate-900 dark:text-white">
|
|
||||||
{interview.candidates?.name || t.unknownCandidate}
|
{interview.candidates?.name || t.unknownCandidate}
|
||||||
</h2>
|
</h4>
|
||||||
<p className="text-sm text-slate-600 dark:text-slate-300 font-medium">
|
<p className="text-xs text-slate-500 dark:text-slate-400 truncate mt-0.5">
|
||||||
{t.role}: {interview.jobs?.title || t.unknownJob}
|
{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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
|
<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>
|
||||||
|
</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">
|
<label className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
||||||
{t.stage}:
|
{t.stage}:
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={currentEdit.stage}
|
value={selectedInterview.stage}
|
||||||
onChange={(e) =>
|
onChange={(e) => updateInterviewField(selectedInterview.id, { stage: e.target.value })}
|
||||||
handleStateChange(interview.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"
|
||||||
}
|
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
<option value="Screening">{t.screening}</option>
|
<option value="Screening">{t.screening}</option>
|
||||||
<option value="Technical">{t.technical}</option>
|
<option value="Technical">{t.technical}</option>
|
||||||
|
|
@ -242,41 +580,102 @@ export default function InterviewsPage() {
|
||||||
<option value="Rejected">{t.rejected}</option>
|
<option value="Rejected">{t.rejected}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Feedback Area */}
|
{/* Comments Thread System */}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-4 border-t border-slate-200 dark:border-slate-800 pt-4">
|
||||||
<label className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
<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}
|
{t.interviewFeedback}
|
||||||
</label>
|
</h3>
|
||||||
<textarea
|
|
||||||
value={currentEdit.feedback}
|
{/* AI Suggestion Button */}
|
||||||
onChange={(e) =>
|
<button
|
||||||
handleStateChange(
|
onClick={handleGetAiSuggestion}
|
||||||
interview.id,
|
disabled={suggesting || cooldownActive}
|
||||||
"feedback",
|
title={cooldownActive ? t.cooldownMessage.replace("{hours}", String(cooldownHours)) : undefined}
|
||||||
e.target.value
|
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"
|
||||||
placeholder={t.feedbackPlaceholder}
|
: "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"
|
||||||
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"
|
>
|
||||||
/>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Save Button */}
|
{/* Comment List */}
|
||||||
<div className="flex justify-end">
|
<div className="max-h-72 overflow-y-auto pr-1 flex flex-col gap-3">
|
||||||
<button
|
{selectedInterviewComments.length === 0 ? (
|
||||||
onClick={() => handleUpdate(interview.id)}
|
<p className="text-slate-500 dark:text-slate-400 text-xs italic py-2">
|
||||||
disabled={updatingId === interview.id}
|
{t.noCommentsYet}
|
||||||
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"
|
</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"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{updatingId === interview.id ? t.saving : t.updateInterview}
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
77
app/api/interviews/suggest/route.ts
Normal file
77
app/api/interviews/suggest/route.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
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
1182
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -13,7 +13,8 @@
|
||||||
"next": "16.2.7",
|
"next": "16.2.7",
|
||||||
"pdf-parse": "^2.4.5",
|
"pdf-parse": "^2.4.5",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4",
|
||||||
|
"react-markdown": "^10.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|
|
||||||
|
|
@ -573,6 +573,85 @@ 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
|
// Base nodes array
|
||||||
const wNodes: any[] = [
|
const wNodes: any[] = [
|
||||||
evalWebhookNode,
|
evalWebhookNode,
|
||||||
|
|
@ -594,7 +673,13 @@ async function main() {
|
||||||
profileJobChainNode,
|
profileJobChainNode,
|
||||||
profileJobPrimaryModelNode,
|
profileJobPrimaryModelNode,
|
||||||
profileJobJsonParserNode,
|
profileJobJsonParserNode,
|
||||||
profileJobRespondWebhookNode
|
profileJobRespondWebhookNode,
|
||||||
|
|
||||||
|
suggestWebhookNode,
|
||||||
|
suggestChainNode,
|
||||||
|
suggestPrimaryModelNode,
|
||||||
|
suggestSetNode,
|
||||||
|
suggestRespondWebhookNode
|
||||||
];
|
];
|
||||||
|
|
||||||
// Base connections map
|
// Base connections map
|
||||||
|
|
@ -775,6 +860,52 @@ 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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -865,6 +996,35 @@ 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
|
// Define complete workflow object
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
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;
|
||||||
Loading…
Reference in a new issue