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 */}
-
-
-
+ {/* COLUMN 2: Candidates List */}
+
+
+
+ {t.candidates}
+
+
- {/* Save Button */}
-
-
+
+ {/* COLUMN 3: Candidate Details Pane */}
+
+ {!selectedInterview ? (
+
+
+
+ {t.selectCandidateDetails}
+
+
+ ) : (
+
+ {/* Details Header */}
+
+
+ {selectedInterview.candidates?.name || t.unknownCandidate}
+
+
+
+
+ {t.role}: {selectedInterview.jobs?.title || t.unknownJob}
+
+ |
+
+ {t.dateScheduled}: {new Date(selectedInterview.interview_date).toLocaleString()}
+
+
+
+
+ {/* Stage Dropdown Selector */}
+
+
+
+
+
+ {/* Comments Thread System */}
+
+
+ {t.interviewFeedback}
+
+
+ {/* Comment List */}
+
+ {selectedInterviewComments.length === 0 ? (
+
+ {t.noCommentsYet}
+
+ ) : (
+ selectedInterviewComments.map((comment) => (
+
+
+
+ {comment.author || t.postedByAgent}
+
+
+ {new Date(comment.timestamp).toLocaleString()}
+
+
+
+ {comment.text}
+
+
+ ))
+ )}
+
+
+ {/* Add Comment Input */}
+
- );
- })}
+ )}
+
)}
diff --git a/supabase/migrations/20260610164000_add_pinned_and_updated_at_to_interviews.sql b/supabase/migrations/20260610164000_add_pinned_and_updated_at_to_interviews.sql
new file mode 100644
index 0000000..736d11a
--- /dev/null
+++ b/supabase/migrations/20260610164000_add_pinned_and_updated_at_to_interviews.sql
@@ -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;