From 633f1a6f2b15cc0f8863afea5a35f5593de354ba Mon Sep 17 00:00:00 2001 From: Gabriel Ramos Date: Wed, 10 Jun 2026 16:44:24 -0400 Subject: [PATCH] feat(interviews): implement 3-pane layout, pinning, and comment timeline --- app/(dashboard)/interviews/page.tsx | 541 +++++++++++++----- ...dd_pinned_and_updated_at_to_interviews.sql | 2 + 2 files changed, 403 insertions(+), 140 deletions(-) create mode 100644 supabase/migrations/20260610164000_add_pinned_and_updated_at_to_interviews.sql diff --git a/app/(dashboard)/interviews/page.tsx b/app/(dashboard)/interviews/page.tsx index ebe6d1f..8da5912 100644 --- a/app/(dashboard)/interviews/page.tsx +++ b/app/(dashboard)/interviews/page.tsx @@ -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([]); + const [jobs, setJobs] = useState([]); const [loading, setLoading] = useState(true); - const [updatingId, setUpdatingId] = useState(null); - const [editStates, setEditStates] = useState< - Record - >({}); - const [actionMessage, setActionMessage] = useState(null); + + const [selectedJobId, setSelectedJobId] = useState(null); + const [selectedInterviewId, setSelectedInterviewId] = useState(null); + + const [newComment, setNewComment] = useState(""); useEffect(() => { let active = true; - async function fetchInterviews() { + async function loadData() { try { - const { data, error } = await supabase - .from("interviews") - .select("*, candidates(name), jobs(title)") - .order("interview_date", { ascending: false }); + const [interviewsRes, jobsRes] = await Promise.all([ + supabase + .from("interviews") + .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 = {}; - 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) => { 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 (
@@ -179,10 +286,21 @@ export default function InterviewsPage() { {t.interviewsSubtitle}

- {actionMessage && ( -
- {actionMessage} -
+ + {/* Back to main button */} + {(selectedJobId !== null || selectedInterviewId !== null) && ( + )}
@@ -197,86 +315,229 @@ export default function InterviewsPage() {

) : ( -
- {interviews.map((interview) => { - const currentEdit = editStates[interview.id] || { - stage: interview.stage, - feedback: interview.feedback || "", - }; +
+ {/* COLUMN 1: Open Positions Sidebar */} +
+

+ {t.openPositions} +

+ + - return ( -
- {/* Header */} -
-
-

- {interview.candidates?.name || t.unknownCandidate} -

-

- {t.role}: {interview.jobs?.title || t.unknownJob} -

-

- {t.dateScheduled}:{" "} - {new Date(interview.interview_date).toLocaleString()} -

-
-
- - -
-
+ {jobs.map((job) => { + const count = getInterviewCountForJob(job.id); + return ( + + ); + })} +
- {/* Feedback Area */} -
- -