feat(interviews): implement 3-pane layout, pinning, and comment timeline

This commit is contained in:
Gabriel Ramos 2026-06-10 16:44:24 -04:00
parent 774e5044ca
commit 633f1a6f2b
2 changed files with 403 additions and 140 deletions

View file

@ -4,6 +4,13 @@ import React, { useState, useEffect } from "react";
import { supabase } from "@/lib/supabase";
import { useApp } from "@/components/AppContext";
interface Comment {
id: string;
text: string;
timestamp: string; // ISO string
author?: string;
}
interface Interview {
id: string;
candidate_id: string;
@ -12,6 +19,8 @@ interface Interview {
stage: string;
feedback: string | null;
created_at: string;
updated_at: string;
pinned: boolean;
candidates: {
name: string;
} | null;
@ -20,6 +29,11 @@ interface Interview {
} | null;
}
interface Job {
id: string;
title: string;
}
const translations = {
en: {
interviewsTitle: "Interviews",
@ -31,10 +45,9 @@ const translations = {
role: "Role",
dateScheduled: "Date Scheduled",
stage: "Stage",
interviewFeedback: "Interview Feedback",
feedbackPlaceholder: "Write detailed assessment feedback, questions, or observations...",
interviewFeedback: "Interview Feedback & Timeline",
feedbackPlaceholder: "Write assessment feedback, observations or notes...",
saving: "Saving...",
updateInterview: "Update Interview",
updateSuccess: "Interview updated successfully!",
updateFailed: "Failed to update interview.",
screening: "Screening",
@ -42,7 +55,20 @@ const translations = {
cultural: "Cultural",
offer: "Offer",
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"
},
es: {
interviewsTitle: "Entrevistas",
@ -54,10 +80,9 @@ const translations = {
role: "Puesto",
dateScheduled: "Fecha Programada",
stage: "Etapa",
interviewFeedback: "Comentarios de la Entrevista",
feedbackPlaceholder: "Escriba comentarios detallados de la evaluación, preguntas u observaciones...",
interviewFeedback: "Comentarios y Cronología",
feedbackPlaceholder: "Escriba comentarios, observaciones o notas de la evaluación...",
saving: "Guardando...",
updateInterview: "Actualizar Entrevista",
updateSuccess: "¡Entrevista actualizada con éxito!",
updateFailed: "Error al actualizar la entrevista.",
screening: "Preselección",
@ -65,111 +90,193 @@ const translations = {
cultural: "Cultural",
offer: "Oferta",
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"
}
};
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() }];
}
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 [updatingId, setUpdatingId] = useState<string | null>(null);
const [editStates, setEditStates] = useState<
Record<string, { stage: string; feedback: string }>
>({});
const [actionMessage, setActionMessage] = useState<string | null>(null);
const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
const [selectedInterviewId, setSelectedInterviewId] = useState<string | null>(null);
const [newComment, setNewComment] = useState("");
useEffect(() => {
let active = true;
async function fetchInterviews() {
async function loadData() {
try {
const { data, error } = await supabase
const [interviewsRes, jobsRes] = await Promise.all([
supabase
.from("interviews")
.select("*, candidates(name), jobs(title)")
.order("interview_date", { ascending: false });
.select("*, candidates(name), jobs(title)"),
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) {
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);
setInterviews((interviewsRes.data as unknown as Interview[]) || []);
setJobs((jobsRes.data as Job[]) || []);
setLoading(false);
}
} catch (err) {
console.error("Error fetching interviews:", err);
console.error("Error loading data:", err);
if (active) {
setLoading(false);
}
}
}
fetchInterviews();
loadData();
return () => {
active = false;
};
}, []);
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;
const updateInterviewField = async (id: string, updates: Partial<Interview>) => {
try {
setUpdatingId(id);
setActionMessage(null);
const updated_at = new Date().toISOString();
const { error } = await supabase
.from("interviews")
.update({
stage: editState.stage,
feedback: editState.feedback,
...updates,
updated_at,
})
.eq("id", id);
if (error) throw error;
setActionMessage(t.updateSuccess);
// Hide message after 3 seconds
setTimeout(() => setActionMessage(null), 3000);
// Refresh interview data locally
// Update state locally
setInterviews((prev) =>
prev.map((item) =>
item.id === id
? { ...item, stage: editState.stage, feedback: editState.feedback }
? { ...item, ...updates, updated_at }
: item
)
);
} catch (err: unknown) {
} catch (err) {
console.error("Error updating interview:", err);
setActionMessage(t.updateFailed);
} finally {
setUpdatingId(null);
}
};
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 = Math.random().toString(36).substring(2, 9) + Date.now().toString(36);
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 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) : [];
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
@ -179,10 +286,21 @@ export default function InterviewsPage() {
{t.interviewsSubtitle}
</p>
</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">
{actionMessage}
</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>
)}
</div>
@ -197,42 +315,162 @@ export default function InterviewsPage() {
</p>
</div>
) : (
<div className="grid grid-cols-1 gap-6">
{interviews.map((interview) => {
const currentEdit = editStates[interview.id] || {
stage: interview.stage,
feedback: interview.feedback || "",
};
<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>
{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}
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 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">
<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}
</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()}
</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-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">
{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"
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>
@ -242,41 +480,64 @@ export default function InterviewsPage() {
<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">
{/* 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}
</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"
/>
</h3>
{/* 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) => (
<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>
</div>
<p className="text-xs text-slate-700 dark:text-slate-300 leading-relaxed whitespace-pre-line">
{comment.text}
</p>
</div>
))
)}
</div>
{/* Save Button */}
{/* 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={() => 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"
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"
>
{updatingId === interview.id ? t.saving : t.updateInterview}
{t.addComment}
</button>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
)}
</div>

View file

@ -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;