import React, { useEffect, useMemo, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { Box, Button, Checkbox, Chip, Collapse, FormControl, FormControlLabel, IconButton, InputAdornment, InputLabel, Menu, MenuItem, Paper, Select, Table, TableBody, TableCell, TableHead, TablePagination, TableRow, TableSortLabel, TextField, Tooltip, Typography, } from "@mui/material"; import { alpha, useTheme } from "@mui/material/styles"; import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; import LaunchIcon from "@mui/icons-material/Launch"; import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; import RestoreFromTrashOutlinedIcon from "@mui/icons-material/RestoreFromTrashOutlined"; import MoreHorizIcon from "@mui/icons-material/MoreHoriz"; import ViewColumnIcon from "@mui/icons-material/ViewColumn"; import SearchIcon from "@mui/icons-material/Search"; import { api } from "../api"; import { useCompanies } from "../hooks/useCompanies"; import { useDebouncedValue } from "../hooks/useDebouncedValue"; import JobDetailsDialog from "./JobDetailsDialog"; import EditJobDialog from "./EditJobDialog"; import { useToast } from "../toast"; import SavedViewsMenu, { SavedViewParams } from "./SavedViewsMenu"; interface JobApplication { id: number; jobTitle: string; status: string; dateApplied: string; daysSince: number; jobUrl?: string | null; notes?: string | null; location?: string | null; salary?: string | null; tags?: string | null; description?: string | null; isDeleted?: boolean; company: { name: string }; companyId?: number; needsFollowUp?: boolean; followUpReason?: string | null; shortSummary?: string | null; fullSummary?: string | null; } interface PagedResult { items: T[]; total: number; page: number; pageSize: number; } export type JobTableColumns = { status: boolean; dateApplied: boolean; daysSince: boolean; jobUrl: boolean; }; interface Props { refreshToken: number; pageSize: 15 | 20 | 25; onPageSizeChange: (n: 15 | 20 | 25) => void; columns: JobTableColumns; onColumnsChange: (next: JobTableColumns) => void; mode?: "jobs" | "trash"; } function normalizeStatus(status: string): string { return status === "Interviewing" ? "Interview" : status; } function parseTags(raw?: string | null): string[] { if (!raw) return []; try { const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed.filter((x) => typeof x === "string") : []; } catch { return raw.split(/[,;\n]/).map((x) => x.trim()).filter(Boolean); } } function statusTone(status: string): string { switch (normalizeStatus(status)) { case "Offer": return "success"; case "Rejected": return "error"; case "Waiting": case "Ghosted": return "warning"; case "Interview": return "info"; default: return "primary"; } } export default function JobTable({ refreshToken, pageSize, onPageSizeChange, columns, onColumnsChange, mode = "jobs" }: Props) { const theme = useTheme(); const { toast } = useToast(); const location = useLocation(); const navigate = useNavigate(); const [jobs, setJobs] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(0); const [expanded, setExpanded] = useState([]); const [selectedIds, setSelectedIds] = useState([]); const [search, setSearch] = useState(""); const debouncedSearch = useDebouncedValue(search, 250); const [includeDeleted, setIncludeDeleted] = useState(mode === "trash"); const [columnsAnchor, setColumnsAnchor] = useState(null); const [statusFilter, setStatusFilter] = useState("All"); const [locationFilter, setLocationFilter] = useState(""); const debouncedLocation = useDebouncedValue(locationFilter, 250); const [needsFollowUpOnly, setNeedsFollowUpOnly] = useState(false); const { companies } = useCompanies(); const [companyFilterId, setCompanyFilterId] = useState("All"); const [detailsJobId, setDetailsJobId] = useState(null); const [editJobId, setEditJobId] = useState(null); const [reloadToken, setReloadToken] = useState(0); const [statusAnchor, setStatusAnchor] = useState(null); const [statusJobId, setStatusJobId] = useState(null); const [sortBy, setSortBy] = useState<"dateApplied" | "company" | "jobTitle" | "status" | "daysSince" | "location">("dateApplied"); const [sortDir, setSortDir] = useState<"asc" | "desc">("desc"); const params = useMemo(() => ({ page: page + 1, pageSize, q: debouncedSearch.trim() || undefined, status: statusFilter !== "All" ? statusFilter : undefined, companyId: companyFilterId === "All" ? undefined : companyFilterId, location: debouncedLocation.trim() || undefined, includeDeleted, deletedOnly: mode === "trash" ? true : undefined, sortBy, sortDir, needsFollowUp: needsFollowUpOnly ? true : undefined, }), [page, pageSize, debouncedSearch, statusFilter, companyFilterId, debouncedLocation, includeDeleted, mode, sortBy, sortDir, needsFollowUpOnly]); useEffect(() => { api.get>("/jobapplications", { params }).then((r) => { setJobs(r.data.items); setTotal(r.data.total); setSelectedIds([]); }); }, [params, refreshToken, reloadToken]); useEffect(() => { const paramsSearch = new URLSearchParams(location.search); const openId = Number(paramsSearch.get("open") || 0); if (!openId || jobs.length === 0) return; const job = jobs.find((j) => j.id === openId); if (!job) return; setDetailsJobId(openId); paramsSearch.delete("open"); navigate({ pathname: location.pathname, search: paramsSearch.toString() ? `?${paramsSearch.toString()}` : "" }, { replace: true }); }, [jobs, location.pathname, location.search, navigate]); const requestSort = (key: typeof sortBy) => { if (sortBy === key) setSortDir((d) => (d === "asc" ? "desc" : "asc")); else { setSortBy(key); setSortDir("asc"); } setPage(0); }; const toggleExpanded = (id: number) => { setExpanded((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])); }; const selectedAllOnPage = jobs.length > 0 && jobs.every((job) => selectedIds.includes(job.id)); const toggleSelectAll = (checked: boolean) => { setSelectedIds(checked ? jobs.map((job) => job.id) : []); }; const toggleSelected = (id: number, checked: boolean) => { setSelectedIds((prev) => checked ? [...prev, id] : prev.filter((x) => x !== id)); }; const confirmDelete = (jobsToDelete: JobApplication[]) => { if (jobsToDelete.length === 0) return false; if (jobsToDelete.length === 1) { const job = jobsToDelete[0]; return window.confirm(`Move "${job.jobTitle}" at ${job.company?.name ?? "this company"} to trash?`); } return window.confirm(`Move ${jobsToDelete.length} selected jobs to trash?`); }; const softDelete = async (job: JobApplication) => { if (!confirmDelete([job])) return; try { await api.delete(`/jobapplications/${job.id}`); toast("Job moved to trash.", "success", { label: "Undo", onClick: () => { void restore(job.id); } }); setReloadToken((t) => t + 1); } catch { toast("Failed to delete job.", "error"); } }; const restore = async (id: number) => { try { await api.post(`/jobapplications/${id}/restore`); toast("Job restored.", "success"); setReloadToken((t) => t + 1); } catch { toast("Failed to restore job.", "error"); } }; const setStatusQuick = async (id: number, status: string) => { try { await api.patch(`/jobapplications/${id}/status`, { status }); toast(`Status set to ${status}.`, "success"); setReloadToken((t) => t + 1); } catch { toast("Failed to update status.", "error"); } }; const runBulkAction = async (action: "delete" | "restore" | "status", value?: string) => { if (selectedIds.length === 0) return; const selectedJobs = jobs.filter((job) => selectedIds.includes(job.id)); if (action === "delete" && !confirmDelete(selectedJobs)) return; try { await Promise.all(selectedIds.map((id) => { if (action === "delete") return api.delete(`/jobapplications/${id}`); if (action === "restore") return api.post(`/jobapplications/${id}/restore`); return api.patch(`/jobapplications/${id}/status`, { status: value }); })); toast(`Updated ${selectedIds.length} jobs.`, "success"); setReloadToken((t) => t + 1); setSelectedIds([]); } catch { toast("Bulk action failed.", "error"); } }; const generateOverview = (job: JobApplication) => { if (job.shortSummary) return job.shortSummary; const src = (job.description || job.notes || "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim(); return src.length > 220 ? `${src.slice(0, 220)}...` : src; }; return ( { setSearch(e.target.value); setPage(0); }} placeholder="Title, company, notes, messages" size="small" InputProps={{ startAdornment: }} sx={{ minWidth: 320, flex: "1 1 320px" }} /> Status Company { setLocationFilter(e.target.value); setPage(0); }} sx={{ minWidth: 200, flex: "1 1 200px" }} /> {mode === "jobs" ? { setNeedsFollowUpOnly(e.target.checked); setPage(0); }} />} label="Needs follow-up" /> : null} {mode === "jobs" ? { setIncludeDeleted(e.target.checked); setPage(0); }} />} label="Show deleted" /> : null} { setSearch(p.q ?? ""); setStatusFilter(p.status ?? "All"); setCompanyFilterId(p.companyId ?? "All"); setLocationFilter(p.location ?? ""); setNeedsFollowUpOnly(Boolean(p.needsFollowUp)); setPage(0); }} /> setColumnsAnchor(e.currentTarget)}> {selectedIds.length > 0 ? ( {selectedIds.length} selected {mode === "trash" ? : } {mode === "jobs" ? ["Waiting", "Interview", "Rejected", "Ghosted", "Offer"].map((s) => ) : null} ) : null} setColumnsAnchor(null)}> {([ ["status", "Status"], ["dateApplied", "Date applied"], ["daysSince", "Days"], ["jobUrl", "Job URL"] ] as const).map(([key, label]) => ( onColumnsChange({ ...columns, [key]: !columns[key] })}> {label} ))} 0 && !selectedAllOnPage} onChange={(e) => toggleSelectAll(e.target.checked)} /> requestSort("company")}>Company requestSort("jobTitle")}>Role {columns.status ? requestSort("status")}>Status : null} {columns.dateApplied ? requestSort("dateApplied")}>Date Applied : null} {columns.daysSince ? requestSort("daysSince")}>Days : null} {columns.jobUrl ? Job URL : null} Actions {jobs.map((job) => { const open = expanded.includes(job.id); const toneName = statusTone(job.status); const tone = toneName === "error" ? theme.palette.error.main : toneName === "warning" ? theme.palette.warning.main : toneName === "success" ? theme.palette.success.main : toneName === "info" ? theme.palette.info.main : theme.palette.primary.main; return ( toggleSelected(job.id, e.target.checked)} /> toggleExpanded(job.id)}>{open ? : } {job.company?.name ?? ""} {job.jobTitle} {job.needsFollowUp ? : null} {columns.status ? : null} {columns.dateApplied ? {new Date(job.dateApplied).toLocaleDateString()} : null} {columns.daysSince ? {job.daysSince} : null} {columns.jobUrl ? {job.jobUrl ? Link : ""} : null} setEditJobId(job.id)}> { setStatusJobId(job.id); setStatusAnchor(e.currentTarget); }}> setDetailsJobId(job.id)}> {(mode === "trash" || (includeDeleted && job.isDeleted)) ? void restore(job.id)}> : void softDelete(job)}>} Location{job.location ?? "-"} Salary{job.salary ?? "-"} Job URL{job.jobUrl ? Open listing : "-"} Skills{parseTags(job.tags).length ? parseTags(job.tags).slice(0, 8).map((tag) => ) : No tags} Overview{generateOverview(job) || "No summary yet."} ); })} {jobs.length === 0 ? No jobs found. : null}
setPage(next)} rowsPerPage={pageSize} onRowsPerPageChange={(e) => { onPageSizeChange(Number(e.target.value) as 15 | 20 | 25); setPage(0); }} rowsPerPageOptions={[15, 20, 25]} />
setDetailsJobId(null)} /> setEditJobId(null)} onSaved={() => setReloadToken((t) => t + 1)} /> { setStatusAnchor(null); setStatusJobId(null); }}> {["Waiting", "Interview", "Offer", "Rejected", "Ghosted"].map((s) => { if (statusJobId) void setStatusQuick(statusJobId, s); setStatusAnchor(null); setStatusJobId(null); }}>Set {s})}
); }