feat(interviews): add AI next-step suggestion, react-markdown, and comment collapse

This commit is contained in:
Gabriel Ramos 2026-06-10 17:06:53 -04:00
parent 633f1a6f2b
commit b1c2f7fbd2
4 changed files with 1430 additions and 31 deletions

View file

@ -3,12 +3,15 @@
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 {
@ -22,10 +25,21 @@ interface Interview {
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;
}
@ -68,7 +82,11 @@ const translations = {
unpinCandidate: "Unpin Candidate",
postedByAgent: "Agent",
noCandidatesInStage: "No candidates for this position.",
candidates: "Candidates"
candidates: "Candidates",
aiSuggestButton: "Get AI Suggestion",
suggesting: "Analyzing...",
cooldownMessage: "Cooldown: change stage or wait {hours}h to query AI suggestion again"
},
es: {
interviewsTitle: "Entrevistas",
@ -103,7 +121,11 @@ const translations = {
unpinCandidate: "Desfijar Candidato",
postedByAgent: "Agente",
noCandidatesInStage: "No hay candidatos para este puesto.",
candidates: "Candidatos"
candidates: "Candidatos",
aiSuggestButton: "Obtener Sugerencia IA",
suggesting: "Analizando...",
cooldownMessage: "Cooldown: cambie la etapa o espere {hours}h para volver a pedir sugerencia"
}
};
@ -132,6 +154,10 @@ function parseFeedback(feedbackText: string | null): Comment[] {
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];
@ -144,6 +170,8 @@ export default function InterviewsPage() {
const [selectedInterviewId, setSelectedInterviewId] = useState<string | null>(null);
const [newComment, setNewComment] = useState("");
const [collapsedComments, setCollapsedComments] = useState<Record<string, boolean>>({});
const [suggesting, setSuggesting] = useState(false);
useEffect(() => {
let active = true;
@ -153,7 +181,7 @@ export default function InterviewsPage() {
const [interviewsRes, jobsRes] = await Promise.all([
supabase
.from("interviews")
.select("*, candidates(name), jobs(title)"),
.select("*, candidates(*), jobs(*)"),
supabase
.from("jobs")
.select("id, title")
@ -230,7 +258,7 @@ export default function InterviewsPage() {
if (!currentInterview) return;
const existingComments = parseFeedback(currentInterview.feedback);
const commentId = Math.random().toString(36).substring(2, 9) + Date.now().toString(36);
const commentId = generateCommentId();
const newCommentItem: Comment = {
id: commentId,
@ -244,6 +272,13 @@ export default function InterviewsPage() {
setNewComment("");
};
const toggleCommentCollapse = (commentId: string) => {
setCollapsedComments((prev) => ({
...prev,
[commentId]: !prev[commentId],
}));
};
const getInterviewCountForJob = (jobId: string) => {
return interviews.filter((item) => item.job_id === jobId).length;
};
@ -277,6 +312,71 @@ export default function InterviewsPage() {
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 {
setSuggesting(false);
}
};
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
@ -483,9 +583,28 @@ export default function InterviewsPage() {
{/* Comments Thread System */}
<div className="flex flex-col gap-4 border-t border-slate-200 dark:border-slate-800 pt-4">
<h3 className="text-xs font-bold text-slate-400 dark:text-slate-500 uppercase tracking-wider">
{t.interviewFeedback}
</h3>
<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"
}`}
>
<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">
@ -494,24 +613,43 @@ export default function InterviewsPage() {
{t.noCommentsYet}
</p>
) : (
selectedInterviewComments.map((comment) => (
<div
key={comment.id}
className="p-3 bg-slate-50 dark:bg-slate-800/40 rounded-lg border border-slate-100 dark:border-slate-800/60 flex flex-col gap-1.5"
>
<div className="flex items-center justify-between text-[11px] text-slate-500 dark:text-slate-400">
<span className="font-semibold text-blue-600 dark:text-blue-400">
{comment.author || t.postedByAgent}
</span>
<span>
{new Date(comment.timestamp).toLocaleString()}
</span>
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>
<p className="text-xs text-slate-700 dark:text-slate-300 leading-relaxed whitespace-pre-line">
{comment.text}
</p>
</div>
))
);
})
)}
</div>

View file

@ -0,0 +1,90 @@
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
try {
const apiKey = process.env.GEMINI_API_KEY;
if (!apiKey) {
return NextResponse.json({ error: "Missing GEMINI_API_KEY environment variable" }, { status: 500 });
}
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 formattedComments = (commentHistory || [])
.map((c: { author?: string; text?: string; timestamp?: string }) =>
`[${c.timestamp ? new Date(c.timestamp).toLocaleString() : ""}] ${c.author || "Agent"}: ${c.text}`
)
.join("\n");
const prompt = `You are an AI recruitment co-pilot. Suggest the next step for this candidate in their interview process.
Candidate Name: ${candidateName}
Vacancy: ${jobTitle}
Current Interview Stage: ${currentStage}
Candidate Summary: ${candidateSummary || "None provided"}
Candidate Skills: ${JSON.stringify(candidateSkills || [])}
Job Requirements: ${jobRequirements || "None provided"}
Interview Comments History:
${formattedComments || "No comments yet"}
Provide your suggestion for the next steps.
Requirements:
1. MUST be extremely brief and concise (max 3-4 bullet points).
2. MUST focus on actionable suggestions based on their current stage, comment history, and candidate profile.
3. Respond in ${lang === "es" ? "Spanish" : "English"}.
4. Use standard Markdown formatting. Keep it professional.
Do not include any pre-text or post-text. Return only the markdown content.`;
const response = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${apiKey}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
contents: [{
parts: [{ text: prompt }]
}]
}),
}
);
if (!response.ok) {
const errText = await response.text();
return NextResponse.json({ error: `Gemini API error: ${response.status} - ${errText}` }, { status: 500 });
}
const data = await response.json();
const textContent = data.candidates?.[0]?.content?.parts?.[0]?.text;
if (!textContent) {
return NextResponse.json({ error: "Failed to generate suggestion from Gemini" }, { status: 500 });
}
return NextResponse.json({
success: true,
suggestion: textContent.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,7 +13,8 @@
"next": "16.2.7",
"pdf-parse": "^2.4.5",
"react": "19.2.4",
"react-dom": "19.2.4"
"react-dom": "19.2.4",
"react-markdown": "^10.1.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",