import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { Box, Button, Checkbox, Chip, FormControl, FormControlLabel, IconButton, InputAdornment, InputLabel, Menu, MenuItem, Paper, Select, Stack, Table, TableBody, TableCell, TableHead, TablePagination, TableRow, TableSortLabel, TextField, Tooltip, Typography, } from "@mui/material"; import useMediaQuery from "@mui/material/useMediaQuery"; import { alpha, useTheme } from "@mui/material/styles"; import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; 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 WorkOutlineIcon from "@mui/icons-material/WorkOutline"; import { api } from "../api"; import ViewStateNotice from "./ViewStateNotice"; import { useCompanies } from "../hooks/useCompanies"; import { useDebouncedValue } from "../hooks/useDebouncedValue"; import { formatSalary } from "../salary"; import { statusLabel, statusTone } from "../pipeline"; import EditJobDialog from "./EditJobDialog"; import { useToast } from "../toast"; import SavedViewsMenu, { SavedViewParams } from "./SavedViewsMenu"; import { useDialogActions } from "../dialogs"; import { useI18n } from "../i18n/I18nProvider"; import { JobApplication } from "../types"; import { useViewResource } from "../hooks/useViewResource"; import { getWorkflowAction } from "../jobWorkflowSignals"; interface PagedResult { items: T[]; total: number; page: number; pageSize: number; } type RowActionSignal = { label: string; detail: string; onClick: () => void; variant: "contained" | "outlined"; color: "warning" | "primary"; }; type JobRowViewModel = { job: JobApplication; toneName: string; tags: string[]; actionSignals: RowActionSignal[]; appliedDateLabel: string; isSelected: boolean; }; 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"; } type JobSortKey = "dateApplied" | "company" | "jobTitle" | "status" | "daysSince" | "location"; type ReadinessFilter = "all" | "needs-work" | "interview"; const JOB_SORT_KEYS: JobSortKey[] = ["dateApplied", "company", "jobTitle", "status", "daysSince", "location"]; const JOB_STATUS_FILTERS = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"]; function queryPage(search: string): number { const value = Number(new URLSearchParams(search).get("page")); return Number.isInteger(value) && value > 1 ? value - 1 : 0; } function queryCompany(search: string): number | "All" { const value = Number(new URLSearchParams(search).get("companyId")); return Number.isInteger(value) && value > 0 ? value : "All"; } function queryStatus(search: string): string { const value = new URLSearchParams(search).get("status"); return value && JOB_STATUS_FILTERS.includes(value) ? value : "All"; } function queryReadiness(search: string): ReadinessFilter { const value = new URLSearchParams(search).get("readiness"); return value === "needs-work" || value === "interview" ? value : "all"; } function querySort(search: string): JobSortKey { const value = new URLSearchParams(search).get("sortBy") as JobSortKey | null; return value && JOB_SORT_KEYS.includes(value) ? value : "dateApplied"; } 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 EmptyJobsState({ firstTime, onOpenSettings, t }: { firstTime: boolean; onOpenSettings: () => void; t: (key: any) => string }) { if (!firstTime) { return {t("jobTableNoJobsFound")}; } return ( alpha(theme.palette.primary.main, 0.12), color: "primary.main", }} > {t("jobTableEmptyFirstTimeTitle")} {t("jobTableEmptyFirstTimeBody")} ); } function isInteractiveTarget(target: EventTarget | null): boolean { return target instanceof Element && Boolean(target.closest("button, a, input, select, textarea, [role='button'], [role='menuitem'], [role='checkbox']")); } export default function JobTable({ refreshToken, pageSize, onPageSizeChange, columns, onColumnsChange, mode = "jobs" }: Props) { const theme = useTheme(); const isMobile = useMediaQuery("(max-width:767.95px)"); const { toast } = useToast(); const { t } = useI18n(); const { confirmAction } = useDialogActions(); const location = useLocation(); const navigate = useNavigate(); const listRouteRef = useRef(`${location.pathname}${location.search}`); const restoredFocusJobIdRef = useRef(null); const [jobs, setJobs] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(() => queryPage(location.search)); const [selectedIds, setSelectedIds] = useState([]); const [search, setSearch] = useState(() => new URLSearchParams(location.search).get("q") ?? ""); const debouncedSearch = useDebouncedValue(search, 250); const [includeDeleted, setIncludeDeleted] = useState(() => mode === "trash" || new URLSearchParams(location.search).get("includeDeleted") === "1"); const [columnsAnchor, setColumnsAnchor] = useState(null); const [statusFilter, setStatusFilter] = useState(() => queryStatus(location.search)); const [locationFilter, setLocationFilter] = useState(() => new URLSearchParams(location.search).get("location") ?? ""); const debouncedLocation = useDebouncedValue(locationFilter, 250); const [needsFollowUpOnly, setNeedsFollowUpOnly] = useState(() => new URLSearchParams(location.search).get("needsFollowUp") === "1"); const [readinessFilter, setReadinessFilter] = useState(() => queryReadiness(location.search)); const { companies, error: companiesError, reload: reloadCompanies } = useCompanies(); const [companyFilterId, setCompanyFilterId] = useState(() => queryCompany(location.search)); const [editJobId, setEditJobId] = useState(null); const [reloadToken, setReloadToken] = useState(0); const [statusAnchor, setStatusAnchor] = useState(null); const [statusJobId, setStatusJobId] = useState(null); const [sortBy, setSortBy] = useState(() => querySort(location.search)); const [sortDir, setSortDir] = useState<"asc" | "desc">(() => new URLSearchParams(location.search).get("sortDir") === "asc" ? "asc" : "desc"); const updateListRoute = useCallback((updates: Record) => { const next = new URLSearchParams(location.search); Object.entries(updates).forEach(([key, value]) => { if (value) next.set(key, value); else next.delete(key); }); const search = next.toString() ? `?${next.toString()}` : ""; listRouteRef.current = `${location.pathname}${search}`; navigate({ pathname: location.pathname, search }, { replace: true, state: location.state }); }, [location.pathname, location.search, location.state, navigate]); useEffect(() => { listRouteRef.current = `${location.pathname}${location.search}`; }, [location.pathname, location.search]); useEffect(() => { const next = new URLSearchParams(location.search); setSearch(next.get("q") ?? ""); setStatusFilter(queryStatus(location.search)); setCompanyFilterId(queryCompany(location.search)); setLocationFilter(next.get("location") ?? ""); setNeedsFollowUpOnly(next.get("needsFollowUp") === "1"); setReadinessFilter(queryReadiness(location.search)); setIncludeDeleted(mode === "trash" || next.get("includeDeleted") === "1"); setSortBy(querySort(location.search)); setSortDir(next.get("sortDir") === "asc" ? "asc" : "desc"); setPage(queryPage(location.search)); }, [location.search, mode]); const changeSearch = (value: string) => { setSearch(value); setPage(0); updateListRoute({ q: value || null, page: null }); }; const changeStatus = (value: string) => { setStatusFilter(value); setPage(0); updateListRoute({ status: value === "All" ? null : value, page: null }); }; const changeCompany = (value: number | "All") => { setCompanyFilterId(value); setPage(0); updateListRoute({ companyId: value === "All" ? null : String(value), page: null }); }; const changeLocation = (value: string) => { setLocationFilter(value); setPage(0); updateListRoute({ location: value || null, page: null }); }; const changeNeedsFollowUp = (value: boolean) => { setNeedsFollowUpOnly(value); setPage(0); updateListRoute({ needsFollowUp: value ? "1" : null, page: null }); }; const changeReadiness = (value: ReadinessFilter) => { setReadinessFilter(value); setPage(0); updateListRoute({ readiness: value === "all" ? null : value, page: null }); }; const changeIncludeDeleted = (value: boolean) => { setIncludeDeleted(value); setPage(0); updateListRoute({ includeDeleted: value && mode !== "trash" ? "1" : null, page: null }); }; const applySavedView = (view: SavedViewParams) => { const nextCompany = view.companyId ?? "All"; setSearch(view.q ?? ""); setStatusFilter(view.status ?? "All"); setCompanyFilterId(nextCompany); setLocationFilter(view.location ?? ""); setNeedsFollowUpOnly(Boolean(view.needsFollowUp)); setReadinessFilter(view.readiness ?? "all"); setPage(0); updateListRoute({ q: view.q || null, status: view.status || null, companyId: nextCompany === "All" ? null : String(nextCompany), location: view.location || null, needsFollowUp: view.needsFollowUp ? "1" : null, readiness: view.readiness ?? null, page: null, }); }; const openJob = useCallback((jobId: number, path?: string) => { navigate(path ?? `/jobs/${jobId}`, { state: { from: listRouteRef.current, focusJobId: jobId } }); }, [navigate]); 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, readiness: readinessFilter === "all" ? undefined : readinessFilter, }), [page, pageSize, debouncedSearch, statusFilter, companyFilterId, debouncedLocation, includeDeleted, mode, sortBy, sortDir, needsFollowUpOnly, readinessFilter]); const jobsResource = useViewResource( async () => { const r = await api.get>("/jobapplications", { params }); return r.data; }, { initialData: { items: [], total: 0, page: 1, pageSize }, errorMessage: t("jobTableJobsLoadFailed"), deps: [params, refreshToken, reloadToken, pageSize, t], }, ); useEffect(() => { setJobs(jobsResource.data.items); setTotal(jobsResource.data.total); if (!jobsResource.error) { setSelectedIds([]); } }, [jobsResource.data, jobsResource.error]); const requestSort = (key: JobSortKey) => { const nextDirection = sortBy === key ? (sortDir === "asc" ? "desc" : "asc") : "asc"; setSortBy(key); setSortDir(nextDirection); setPage(0); updateListRoute({ sortBy: key === "dateApplied" ? null : key, sortDir: key === "dateApplied" && nextDirection === "desc" ? null : nextDirection, page: null }); }; const filteredJobs = jobs; useEffect(() => { const focusJobId = (location.state as { focusJobId?: unknown } | null)?.focusJobId; if (typeof focusJobId !== "number" || restoredFocusJobIdRef.current === focusJobId || jobsResource.loading) return; const row = document.querySelector(`[data-job-row-id="${focusJobId}"]`); if (!row) return; restoredFocusJobIdRef.current = focusJobId; row.focus(); }, [filteredJobs, jobsResource.loading, location.state]); // Distinguishes "you have zero jobs, period" from "no results match your filters" so the // empty state can actually help a first-time user instead of just saying "nothing here". const noFiltersActive = !debouncedSearch.trim() && statusFilter === "All" && companyFilterId === "All" && !debouncedLocation.trim() && !needsFollowUpOnly && readinessFilter === "all"; const isFirstTimeEmpty = mode === "jobs" && total === 0 && noFiltersActive; const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]); const selectedAllOnPage = filteredJobs.length > 0 && filteredJobs.every((job) => selectedIdSet.has(job.id)); const toggleSelectAll = (checked: boolean) => { setSelectedIds(checked ? filteredJobs.map((job) => job.id) : []); }; const toggleSelected = (id: number, checked: boolean) => { setSelectedIds((prev) => checked ? [...prev, id] : prev.filter((x) => x !== id)); }; const confirmDelete = async (jobsToDelete: JobApplication[]) => { if (jobsToDelete.length === 0) return false; if (jobsToDelete.length === 1) { const job = jobsToDelete[0]; return confirmAction(t("jobTableMoveOneConfirm", { title: job.jobTitle, company: job.company?.name ?? t("jobTableCompany") }), { title: t("jobTableMoveToTrashTitle"), confirmLabel: t("jobTableMove"), destructive: true }); } return confirmAction(t("jobTableMoveManyConfirm", { count: jobsToDelete.length }), { title: t("jobTableMoveJobsToTrashTitle"), confirmLabel: t("jobTableMove"), destructive: true }); }; const softDelete = async (job: JobApplication) => { if (!(await confirmDelete([job]))) return; try { await api.delete(`/jobapplications/${job.id}`); toast(t("jobTableMovedToTrash"), "success", { label: "Undo", onClick: () => { void restore(job.id); } }); setReloadToken((token) => token + 1); } catch { toast(t("jobTableDeleteFailed"), "error"); } }; const restore = async (id: number) => { try { await api.post(`/jobapplications/${id}/restore`); toast(t("jobTableRestored"), "success"); setReloadToken((token) => token + 1); } catch { toast(t("jobTableRestoreFailed"), "error"); } }; const setStatusQuick = async (id: number, status: string) => { try { await api.patch(`/jobapplications/${id}/status`, { status }); toast(t("jobTableStatusSet", { status }), "success"); setReloadToken((token) => token + 1); } catch { toast(t("jobTableStatusUpdateFailed"), "error"); } }; const runBulkAction = async (action: "delete" | "restore" | "status", value?: string) => { if (selectedIds.length === 0) return; const selectedJobs = jobs.filter((job) => selectedIdSet.has(job.id)); if (action === "delete" && !(await 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(t("jobTableUpdatedJobs", { count: selectedIds.length }), "success"); setReloadToken((token) => token + 1); setSelectedIds([]); } catch { toast(t("jobTableBulkActionFailed"), "error"); } }; const buildWorkflowActionSignal = useCallback((job: JobApplication): RowActionSignal | null => { const action = getWorkflowAction(job, { packageWork: t("jobTablePackageWork"), followUp: t("jobTableFollowUp"), interviewPrep: t("jobTableInterviewStage"), readiness: t("jobTableReadiness"), }); if (!action || job.isDeleted) return null; return { label: action.label, detail: action.detail, onClick: () => openJob(job.id, action.path), variant: action.key === "follow-up" ? "contained" : "outlined", color: action.key === "follow-up" ? "warning" : "primary", }; }, [openJob, t]); const rowModels = useMemo(() => filteredJobs.map((job) => { const actionSignal = buildWorkflowActionSignal(job); return { job, toneName: statusTone(job.status), tags: parseTags(job.tags), actionSignals: actionSignal ? [actionSignal] : [], appliedDateLabel: job.dateApplied ? new Date(job.dateApplied).toLocaleDateString() : "—", isSelected: selectedIdSet.has(job.id), }; }), [buildWorkflowActionSignal, filteredJobs, selectedIdSet]); const statusOptions = ["Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const; const visibleDesktopColumns = 6 + Number(columns.status) + Number(columns.dateApplied) + Number(columns.daysSince) + Number(columns.jobUrl); const selectedCompanyIsLoading = companyFilterId !== "All" && !companies.some((company) => company.id === companyFilterId); return ( {isMobile ? ( changeSearch(e.target.value)} placeholder={t("jobTableSearchPlaceholder")} size="small" InputProps={{ startAdornment: }} fullWidth /> {t("jobTableStatus")} {t("jobTableCompany")} changeLocation(e.target.value)} fullWidth /> {mode === "jobs" ? ( {t("jobTableReadiness")} ) : null} {mode === "jobs" ? ( changeNeedsFollowUp(e.target.checked)} />} label={t("jobTableNeedsFollowUp")} sx={{ mr: 0, ml: -0.5 }} /> changeIncludeDeleted(e.target.checked)} />} label={t("jobTableShowDeleted")} sx={{ mr: 0, ml: -0.5 }} /> ) : null} ) : ( changeSearch(e.target.value)} placeholder={t("jobTableSearchPlaceholder")} size="small" InputProps={{ startAdornment: }} sx={{ width: { xs: "100%", md: "auto" }, minWidth: { xs: 0, md: 320 }, flex: { xs: "1 1 100%", md: "1 1 320px" } }} /> {t("jobTableStatus")} {t("jobTableCompany")} changeLocation(e.target.value)} sx={{ width: { xs: "100%", sm: 220 }, flex: { xs: "1 1 100%", md: "1 1 200px" } }} /> {mode === "jobs" ? changeNeedsFollowUp(e.target.checked)} />} label={t("jobTableNeedsFollowUp")} sx={{ mr: 0 }} /> : null} {mode === "jobs" ? ( {t("jobTableReadiness")} ) : null} {mode === "jobs" ? changeIncludeDeleted(e.target.checked)} />} label={t("jobTableShowDeleted")} sx={{ mr: 0 }} /> : null} {!isMobile ? setColumnsAnchor(e.currentTarget)}> : null} )} {selectedIds.length > 0 ? ( {t("jobTableSelected", { count: selectedIds.length })} {mode === "trash" ? : } {mode === "jobs" ? statusOptions.map((status) => ) : null} ) : null} setColumnsAnchor(null)}> {([ ["status", t("settingsColumnStatus")], ["dateApplied", t("settingsColumnDateApplied")], ["daysSince", t("settingsColumnDays")], ["jobUrl", t("settingsColumnJobUrl")] ] as const).map(([key, label]) => ( onColumnsChange({ ...columns, [key]: !columns[key] })}> {label} ))} {companiesError ? ( ) : null} {isMobile ? ( 0 && !selectedAllOnPage} onChange={(e) => toggleSelectAll(e.target.checked)} />} label={t("jobTableSelectAll")} sx={{ mr: 0 }} /> {jobsResource.loading ? {t("loading")} : null} {!jobsResource.loading && !jobsResource.error && rowModels.map(({ job, toneName, actionSignals, tags, appliedDateLabel, isSelected }) => { const compactTags = tags.slice(0, 6); return ( { if (mode === "jobs" && !job.isDeleted && !isInteractiveTarget(event.target)) openJob(job.id); }} onKeyDown={(event) => { if (mode === "jobs" && !job.isDeleted && event.target === event.currentTarget && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); openJob(job.id); } }} sx={{ p: 1.5, borderRadius: 3.5, cursor: mode === "jobs" && !job.isDeleted ? "pointer" : "default", backgroundColor: alpha(theme.palette.primary.main, 0.03), borderColor: alpha(theme.palette.primary.main, 0.08), boxShadow: `0 10px 24px ${alpha(theme.palette.common.black, theme.palette.mode === "dark" ? 0.18 : 0.06)}`, "&:hover": mode === "jobs" && !job.isDeleted ? { backgroundColor: "action.hover" } : undefined, "&:focus-visible": { outline: "3px solid", outlineColor: "primary.main", outlineOffset: 2 }, }} > toggleSelected(job.id, e.target.checked)} sx={{ mt: -0.5, ml: -1 }} /> {job.company?.name ?? t("jobTableCompany")} {job.jobTitle} {columns.status ? : null} {actionSignals.map((signal) => ( ))} {columns.dateApplied ? ( {t("jobTableDateApplied")} {appliedDateLabel} ) : null} {columns.daysSince ? ( {t("jobTableDays")} {job.daysSince} ) : null} {t("jobTableLocation")} {job.location ?? "-"} {t("addJobModalSalary")} {formatSalary(job) ?? "-"} {columns.jobUrl && job.jobUrl ? ( {t("settingsColumnJobUrl")} {t("jobTableOpenListing")} ) : null} {compactTags.length > 0 ? ( {compactTags.map((tag) => )} ) : null} {(mode === "trash" || (includeDeleted && job.isDeleted)) ? ( ) : ( )} ); })} {filteredJobs.length === 0 && !jobsResource.loading && !jobsResource.error ? ( navigate("/settings")} t={t} /> ) : null} ) : ( 0 && !selectedAllOnPage} onChange={(e) => toggleSelectAll(e.target.checked)} /> requestSort("company")}>{t("jobTableCompany")} requestSort("jobTitle")}>{t("jobTableRole")} requestSort("location")}>{t("jobTableLocation")} {columns.status ? requestSort("status")}>{t("jobTableStatus")} : null} {columns.dateApplied ? requestSort("dateApplied")}>{t("jobTableDateApplied")} : null} {columns.daysSince ? requestSort("daysSince")}>{t("jobTableDays")} : null} {t("jobDetailsDeadline")} {columns.jobUrl ? {t("settingsColumnJobUrl")} : null} {t("jobTableActions")} {jobsResource.loading ? {t("loading")} : null} {!jobsResource.loading && !jobsResource.error && rowModels.map(({ job, toneName, actionSignals, appliedDateLabel, isSelected }) => { 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 ( { if (mode === "jobs" && !job.isDeleted && !isInteractiveTarget(event.target)) openJob(job.id); }} onKeyDown={(event) => { if (mode === "jobs" && !job.isDeleted && event.target === event.currentTarget && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); openJob(job.id); } }} sx={{ cursor: mode === "jobs" && !job.isDeleted ? "pointer" : "default", backgroundColor: alpha(tone, theme.palette.mode === "dark" ? 0.1 : 0.06), "&:focus-visible": { outline: "3px solid", outlineColor: "primary.main", outlineOffset: -3 }, }} > toggleSelected(job.id, e.target.checked)} /> {job.company?.name ?? ""} {job.jobTitle} {actionSignals.map((signal) => ( ))} {job.location || "—"} {columns.status ? : null} {columns.dateApplied ? {appliedDateLabel} : null} {columns.daysSince ? {job.daysSince ?? "—"} : null} {job.deadline ? new Date(job.deadline).toLocaleDateString() : "—"} {columns.jobUrl ? {job.jobUrl ? {t("jobTableLink")} : ""} : null} setEditJobId(job.id)}> { setStatusJobId(job.id); setStatusAnchor(e.currentTarget); }}> {(mode === "trash" || (includeDeleted && job.isDeleted)) ? void restore(job.id)}> : void softDelete(job)}>} ); })} {filteredJobs.length === 0 && !jobsResource.loading && !jobsResource.error ? ( navigate("/settings")} t={t} /> ) : null}
)} { setPage(next); updateListRoute({ page: next > 0 ? String(next + 1) : null }); }} rowsPerPage={pageSize} onRowsPerPageChange={(e) => { onPageSizeChange(Number(e.target.value) as 15 | 20 | 25); setPage(0); updateListRoute({ page: null }); }} rowsPerPageOptions={[15, 20, 25]} />
setEditJobId(null)} onSaved={() => setReloadToken((token) => token + 1)} /> { setStatusAnchor(null); setStatusJobId(null); }}> {statusOptions.map((status) => { if (statusJobId) void setStatusQuick(statusJobId, status); setStatusAnchor(null); setStatusJobId(null); }}>{t("jobTableSetStatus", { status })})}
); }