Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 42 additions & 8 deletions client/src/components/ui/ConfirmDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { useEffect, useRef } from "react";
import { useEffect, useRef, ReactNode } from "react";
import { Loader2 } from "lucide-react";

interface ConfirmDialogProps {
open: boolean;
title: string;
description: string;
description?: string;
confirmLabel?: string;
cancelLabel?: string;
onConfirm: () => void;
onCancel: () => void;
confirmVariant?: "danger" | "primary";
loading?: boolean;
children?: ReactNode;
}

export function ConfirmDialog({
Expand All @@ -18,18 +22,34 @@ export function ConfirmDialog({
cancelLabel = "Cancel",
onConfirm,
onCancel,
confirmVariant = "danger",
loading = false,
children,
}: ConfirmDialogProps) {
const cancelRef = useRef<HTMLButtonElement>(null);

// Focus management
useEffect(() => {
if (open) cancelRef.current?.focus();
}, [open]);

// Keyboard navigation & Escape key dismiss
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && !loading) {
onCancel();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [open, loading, onCancel]);

if (!open) return null;

return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="fixed inset-0 bg-black/50" onClick={onCancel} />
<div className="fixed inset-0 bg-black/50" onClick={loading ? undefined : onCancel} />
<div
role="alertdialog"
aria-modal="true"
Expand All @@ -40,25 +60,39 @@ export function ConfirmDialog({
<h2 id="confirm-title" className="text-base font-semibold text-stone-900 dark:text-white">
{title}
</h2>
<p id="confirm-desc" className="mt-2 text-sm text-stone-600 dark:text-stone-400">
{description}
</p>
<div id="confirm-desc" className="mt-2 text-sm text-stone-600 dark:text-stone-400">
{children ? (
children
) : (
<p className="leading-relaxed">
{description}
</p>
)}
</div>
<div className="mt-5 flex justify-end gap-3">
<button
ref={cancelRef}
onClick={onCancel}
className="px-4 py-2 text-sm rounded-lg border border-stone-200 dark:border-white/10 text-stone-700 dark:text-stone-300 hover:bg-stone-100 dark:hover:bg-white/5 transition-colors"
disabled={loading}
className="px-4 py-2 text-sm rounded-lg border border-stone-200 dark:border-white/10 text-stone-700 dark:text-stone-300 hover:bg-stone-100 dark:hover:bg-white/5 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{cancelLabel}
</button>
<button
onClick={onConfirm}
className="px-4 py-2 text-sm rounded-lg bg-red-600 hover:bg-red-700 text-white font-medium transition-colors"
disabled={loading}
className={`inline-flex items-center gap-1.5 px-4 py-2 text-sm rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
confirmVariant === "primary"
? "bg-black dark:bg-white text-white dark:text-stone-950 hover:bg-stone-800 dark:hover:bg-stone-200"
: "bg-red-600 hover:bg-red-700 text-white"
}`}
>
{loading && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{confirmLabel}
</button>
</div>
</div>
</div>
);
}

42 changes: 36 additions & 6 deletions client/src/module/recruiter/applications/ApplicationsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,18 @@ import api from "../../../lib/axios";
import type { Application, Pagination } from "../../../lib/types";
import { SEO } from "../../../components/SEO";
import { useDebounce } from "../../../hooks/useDebounce";
import { ConfirmDialog } from "../../../components/ui/ConfirmDialog";

export default function ApplicationsList() {
const { id: jobId } = useParams();
const queryClient = useQueryClient();
const [pagination, setPagination] = useState<Pagination | null>(null);
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300);
const [statusFilter, setStatusFilter] = useState("");
const [page, setPage] = useState(1);
const [updatingId, setUpdatingId] = useState<number | null>(null);
const [advancingIds, setAdvancingIds] = useState<Set<number>>(() => new Set());
const [pendingAdvanceApp, setPendingAdvanceApp] = useState<Application | null>(null);

// Reset to page 1 when search or filter changes
useEffect(() => {
Expand All @@ -30,15 +31,18 @@ export default function ApplicationsList() {
queryKey: ["applications", jobId, page, statusFilter, debouncedSearch],
queryFn: async () => {
const params = new URLSearchParams({ page: String(page), limit: "10" });
if (debouncedSearch) params.set("search", debouncedSearch);
if (debouncedSearch.trim()) params.set("search", debouncedSearch.trim());
if (statusFilter) params.set("status", statusFilter);
const res = await api.get(`/recruiter/jobs/${jobId}/applications?${params}`);
setPagination(res.data.pagination);
return res.data.applications as Application[];
return {
applications: res.data.applications as Application[],
pagination: res.data.pagination as Pagination,
};
},
});

const applications = data ?? [];
const applications = data?.applications ?? [];
const pagination = data?.pagination ?? null;

const handleStatusChange = async (appId: number, status: string) => {
if (updatingId === appId) return;
Expand All @@ -61,6 +65,7 @@ export default function ApplicationsList() {
await api.patch(`/recruiter/applications/${appId}/advance`);
queryClient.invalidateQueries({ queryKey: ["applications"] });
toast.success("Application advanced");
setPendingAdvanceApp(null);
} catch {
toast.error("Failed to advance application");
} finally {
Expand All @@ -75,6 +80,31 @@ export default function ApplicationsList() {
return (
<div>
<SEO title="Applications" noIndex />
<ConfirmDialog
open={pendingAdvanceApp !== null}
title="Advance Candidate?"
confirmLabel="Confirm Advance"
cancelLabel="Cancel"
confirmVariant="primary"
loading={pendingAdvanceApp ? advancingIds.has(pendingAdvanceApp.id) : false}
onCancel={() => setPendingAdvanceApp(null)}
onConfirm={() => {
if (pendingAdvanceApp && !advancingIds.has(pendingAdvanceApp.id)) {
handleAdvance(pendingAdvanceApp.id);
}
}}
>
{pendingAdvanceApp && (
<div className="space-y-4">
<p className="text-sm text-stone-600 dark:text-stone-400">
Are you sure you want to advance <strong className="font-semibold text-stone-900 dark:text-white">{pendingAdvanceApp.student?.name}</strong> to the next hiring stage?
</p>
<p className="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/20 p-2.5 rounded-lg border border-amber-200/30 dark:border-amber-900/30 leading-normal">
Warning: Advancing this candidate will update their hiring stage and create a new round submission.
</p>
</div>
)}
</ConfirmDialog>
<Link to="/recruiters/jobs" className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-500 hover:text-black dark:hover:text-white mb-4 no-underline">
<ArrowLeft className="w-4 h-4" /> Back to Jobs
</Link>
Expand Down Expand Up @@ -191,7 +221,7 @@ export default function ApplicationsList() {
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2">
<button onClick={() => handleAdvance(app.id)}
<button onClick={() => setPendingAdvanceApp(app)}
disabled={isAdvancing}
className={`inline-flex min-w-21.5 items-center justify-center gap-1.5 text-xs px-3 py-1.5 bg-black dark:bg-white text-white dark:text-gray-950 rounded-lg hover:bg-gray-800 dark:hover:bg-gray-200 transition-colors ${isAdvancing ? "cursor-not-allowed opacity-70" : ""}`}>
{isAdvancing && <Loader2 className="h-3 w-3 animate-spin" />}
Expand Down
36 changes: 31 additions & 5 deletions client/src/module/recruiter/jobs/RecruiterJobsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { JobStatus } from "../../../lib/types";
import type { Job } from "../../../lib/types";
import { SEO } from "../../../components/SEO";
import { Button } from "../../../components/ui/button";
import { ConfirmDialog } from "../../../components/ui/ConfirmDialog";

type StatusFilter = "ALL" | JobStatus;

Expand All @@ -35,6 +36,8 @@ export default function RecruiterJobsList() {
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<StatusFilter>("ALL");
const [search, setSearch] = useState("");
const [jobToDelete, setJobToDelete] = useState<Job | null>(null);
const [isDeleting, setIsDeleting] = useState(false);

useEffect(() => {
api
Expand All @@ -46,14 +49,18 @@ export default function RecruiterJobsList() {
.catch(() => setLoading(false));
}, []);

const handleDelete = async (id: number) => {
if (!confirm("Delete this job? This cannot be undone.")) return;
const handleDelete = async () => {
if (!jobToDelete) return;
setIsDeleting(true);
try {
await api.delete(`/jobs/${id}`);
setJobs((prev) => prev.filter((j) => j.id !== id));
await api.delete(`/jobs/${jobToDelete.id}`);
setJobs((prev) => prev.filter((j) => j.id !== jobToDelete.id));
toast.success("Job deleted");
setJobToDelete(null);
} catch {
toast.error("Failed to delete job");
} finally {
setIsDeleting(false);
}
};

Expand Down Expand Up @@ -104,6 +111,25 @@ export default function RecruiterJobsList() {
<div className="relative text-stone-900 dark:text-stone-50">
<SEO title="My Job Listings" noIndex />

<ConfirmDialog
open={jobToDelete !== null}
title="Delete Job?"
confirmLabel="Delete"
cancelLabel="Cancel"
confirmVariant="danger"
loading={isDeleting}
onCancel={() => setJobToDelete(null)}
onConfirm={handleDelete}
>
{jobToDelete && (
<div className="space-y-4">
<p className="text-sm text-stone-600 dark:text-stone-400">
Are you sure you want to delete <strong className="font-semibold text-stone-900 dark:text-white">{jobToDelete.title}</strong>? This action is irreversible.
</p>
</div>
)}
</ConfirmDialog>

<div
aria-hidden
className="absolute inset-0 pointer-events-none opacity-[0.04] dark:opacity-[0.05] z-0"
Expand Down Expand Up @@ -349,7 +375,7 @@ export default function RecruiterJobsList() {
label="Edit"
/>
<button
onClick={() => handleDelete(job.id)}
onClick={() => setJobToDelete(job)}
title="Delete"
aria-label="Delete"
className="p-2 rounded-md text-stone-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors border-0 bg-transparent cursor-pointer"
Expand Down
Loading