feat(interviews): implement 3-pane layout, pinning, and comment timeline
This commit is contained in:
parent
774e5044ca
commit
633f1a6f2b
2 changed files with 403 additions and 140 deletions
|
|
@ -4,6 +4,13 @@ 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";
|
||||||
|
|
||||||
|
interface Comment {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
timestamp: string; // ISO string
|
||||||
|
author?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface Interview {
|
interface Interview {
|
||||||
id: string;
|
id: string;
|
||||||
candidate_id: string;
|
candidate_id: string;
|
||||||
|
|
@ -12,6 +19,8 @@ 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: {
|
||||||
name: string;
|
name: string;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
@ -20,6 +29,11 @@ interface Interview {
|
||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Job {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
const translations = {
|
const translations = {
|
||||||
en: {
|
en: {
|
||||||
interviewsTitle: "Interviews",
|
interviewsTitle: "Interviews",
|
||||||
|
|
@ -31,10 +45,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 +55,20 @@ 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"
|
||||||
},
|
},
|
||||||
es: {
|
es: {
|
||||||
interviewsTitle: "Entrevistas",
|
interviewsTitle: "Entrevistas",
|
||||||
|
|
@ -54,10 +80,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,111 +90,193 @@ 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"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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() {
|
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("");
|
||||||
|
|
||||||
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([
|
||||||
.from("interviews")
|
supabase
|
||||||
.select("*, candidates(name), jobs(title)")
|
.from("interviews")
|
||||||
.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) {
|
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);
|
|
||||||
} 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 (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<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}
|
{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,86 +315,229 @@ 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>
|
||||||
|
|
||||||
return (
|
{jobs.map((job) => {
|
||||||
<div
|
const count = getInterviewCountForJob(job.id);
|
||||||
key={interview.id}
|
return (
|
||||||
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"
|
<button
|
||||||
>
|
key={job.id}
|
||||||
{/* Header */}
|
onClick={() => handleSelectJob(job.id)}
|
||||||
<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">
|
className={`w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center justify-between cursor-pointer ${
|
||||||
<div>
|
selectedJobId === job.id
|
||||||
<h2 className="text-base font-bold text-slate-900 dark:text-white">
|
? "bg-slate-100 dark:bg-slate-800 text-slate-900 dark:text-white font-medium"
|
||||||
{interview.candidates?.name || t.unknownCandidate}
|
: "text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800/50"
|
||||||
</h2>
|
}`}
|
||||||
<p className="text-sm text-slate-600 dark:text-slate-300 font-medium">
|
>
|
||||||
{t.role}: {interview.jobs?.title || t.unknownJob}
|
<span className="truncate mr-2" title={job.title}>{job.title}</span>
|
||||||
</p>
|
<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">
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
{count}
|
||||||
{t.dateScheduled}:{" "}
|
</span>
|
||||||
{new Date(interview.interview_date).toLocaleString()}
|
</button>
|
||||||
</p>
|
);
|
||||||
</div>
|
})}
|
||||||
<div className="flex items-center gap-2">
|
</div>
|
||||||
<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"
|
|
||||||
>
|
|
||||||
<option value="Screening">{t.screening}</option>
|
|
||||||
<option value="Technical">{t.technical}</option>
|
|
||||||
<option value="Cultural">{t.cultural}</option>
|
|
||||||
<option value="Offer">{t.offer}</option>
|
|
||||||
<option value="Hired">{t.hired}</option>
|
|
||||||
<option value="Rejected">{t.rejected}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Feedback Area */}
|
{/* COLUMN 2: Candidates List */}
|
||||||
<div className="flex flex-col gap-2">
|
<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">
|
||||||
<label className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
<div className="border-b border-slate-200 dark:border-slate-800 pb-2">
|
||||||
{t.interviewFeedback}
|
<h3 className="text-sm font-bold text-slate-900 dark:text-white">
|
||||||
</label>
|
{t.candidates}
|
||||||
<textarea
|
</h3>
|
||||||
value={currentEdit.feedback}
|
</div>
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Save Button */}
|
{sortedInterviews.length === 0 ? (
|
||||||
<div className="flex justify-end">
|
<p className="text-slate-500 dark:text-slate-400 text-sm py-4 italic">
|
||||||
<button
|
{t.noCandidatesInStage}
|
||||||
onClick={() => handleUpdate(interview.id)}
|
</p>
|
||||||
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"
|
<div className="flex flex-col gap-3">
|
||||||
|
{sortedInterviews.map((interview) => (
|
||||||
|
<div
|
||||||
|
key={interview.id}
|
||||||
|
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"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{updatingId === interview.id ? t.saving : t.updateInterview}
|
<div className="flex items-start justify-between gap-2">
|
||||||
</button>
|
<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}
|
||||||
|
</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-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={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>
|
||||||
|
<option value="Cultural">{t.cultural}</option>
|
||||||
|
<option value="Offer">{t.offer}</option>
|
||||||
|
<option value="Hired">{t.hired}</option>
|
||||||
|
<option value="Rejected">{t.rejected}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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