Files
jobtrackingapp/job-tracker-ui/src/components/JobTable.tsx
T

911 lines
51 KiB
TypeScript

import React, { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link as RouterLink, 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 } 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";
import type { ContentDensity } from "../theme";
import { jobsQueryFromSearch, JobSortKey, ReadinessFilter } from "../jobsQuery";
interface PagedResult<T> {
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";
}
const JOB_STATUS_FILTERS = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"];
const KanbanBoard = lazy(() => import("./KanbanBoard"));
function queryPage(search: string): number {
const value = Number(new URLSearchParams(search).get("page"));
return Number.isInteger(value) && value > 1 ? value - 1 : 0;
}
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 <Typography sx={{ py: 2, textAlign: "center", color: "text.secondary" }}>{t("jobTableNoJobsFound")}</Typography>;
}
return (
<Box sx={{ py: 5, textAlign: "center" }}>
<Box
sx={{
width: 56,
height: 56,
mx: "auto",
mb: 2,
borderRadius: 3,
display: "grid",
placeItems: "center",
bgcolor: (theme) => alpha(theme.palette.primary.main, 0.12),
color: "primary.main",
}}
>
<WorkOutlineIcon fontSize="medium" />
</Box>
<Typography sx={{ fontWeight: 800, mb: 0.5 }}>{t("jobTableEmptyFirstTimeTitle")}</Typography>
<Typography sx={{ color: "text.secondary", mb: 1.5, maxWidth: 440, mx: "auto" }}>{t("jobTableEmptyFirstTimeBody")}</Typography>
<Button variant="text" onClick={onOpenSettings}>{t("jobTableEmptyFirstTimeBookmarklet")}</Button>
</Box>
);
}
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 isMobile = useMediaQuery("(max-width:767.95px)");
const { toast } = useToast();
const { t, language } = useI18n();
const { confirmAction } = useDialogActions();
const location = useLocation();
const boardView = mode === "jobs" && new URLSearchParams(location.search).get("view") === "board";
const [boardCount, setBoardCount] = useState<number | null>(null);
const routeQuery = useMemo(() => jobsQueryFromSearch(location.search), [location.search]);
const navigate = useNavigate();
const listRouteRef = useRef(`${location.pathname}${location.search}`);
const restoredFocusJobIdRef = useRef<number | null>(null);
const retainedSelectionAfterReloadRef = useRef<number[] | null>(null);
const [jobs, setJobs] = useState<JobApplication[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(() => queryPage(location.search));
// Presentation only: not an account preference, saved-view field or API input.
const [density, setDensity] = useState<ContentDensity>("comfortable");
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [search, setSearch] = useState(routeQuery.q);
const debouncedSearch = useDebouncedValue(search, 250);
const [includeDeleted, setIncludeDeleted] = useState(() => mode === "trash" || routeQuery.includeDeleted);
const [columnsAnchor, setColumnsAnchor] = useState<null | HTMLElement>(null);
const [statusFilter, setStatusFilter] = useState(routeQuery.status);
const [locationFilter, setLocationFilter] = useState(routeQuery.location);
const debouncedLocation = useDebouncedValue(locationFilter, 250);
const [needsFollowUpOnly, setNeedsFollowUpOnly] = useState(routeQuery.needsFollowUp);
const [readinessFilter, setReadinessFilter] = useState<ReadinessFilter>(routeQuery.readiness ?? "all");
const { companies, error: companiesError, reload: reloadCompanies } = useCompanies();
const [companyFilterId, setCompanyFilterId] = useState<number | "All">(routeQuery.companyId ?? "All");
const [editJobId, setEditJobId] = useState<number | null>(null);
const [reloadToken, setReloadToken] = useState(0);
const [statusAnchor, setStatusAnchor] = useState<null | HTMLElement>(null);
const [bulkStatusAnchor, setBulkStatusAnchor] = useState<null | HTMLElement>(null);
const [statusJobId, setStatusJobId] = useState<number | null>(null);
const [sortBy, setSortBy] = useState<JobSortKey>(routeQuery.sortBy);
const [sortDir, setSortDir] = useState<"asc" | "desc">(routeQuery.sortDir);
// Selection belongs to the current Table page, never to an invisible view.
useEffect(() => {
setSelectedIds([]);
setBoardCount(null);
retainedSelectionAfterReloadRef.current = null;
}, [boardView]);
const viewDestination = (view: "table" | "board") => {
const query = new URLSearchParams(location.search);
query.set("view", view);
return { pathname: "/jobs", search: `?${query}`, hash: location.hash };
};
const updateListRoute = useCallback((updates: Record<string, string | null>) => {
setSelectedIds([]);
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(() => {
setSearch(routeQuery.q);
setStatusFilter(routeQuery.status);
setCompanyFilterId(routeQuery.companyId ?? "All");
setLocationFilter(routeQuery.location);
setNeedsFollowUpOnly(routeQuery.needsFollowUp);
setReadinessFilter(routeQuery.readiness ?? "all");
setIncludeDeleted(mode === "trash" || routeQuery.includeDeleted);
setSortBy(routeQuery.sortBy);
setSortDir(routeQuery.sortDir);
setPage(queryPage(location.search));
}, [location.search, routeQuery, 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) => {
// Keep the originating row on the list history entry so browser Back can restore focus.
window.history.replaceState({ ...window.history.state, jobListFocusId: jobId }, "");
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<PagedResult<JobApplication>>("/jobapplications", { params });
return r.data;
},
{
initialData: { items: [], total: 0, page: 1, pageSize },
enabled: !boardView,
errorMessage: t("jobTableJobsLoadFailed"),
deps: [params, refreshToken, reloadToken, pageSize, t],
},
);
useEffect(() => {
setJobs(jobsResource.data.items);
setTotal(jobsResource.data.total);
if (!jobsResource.error) {
setSelectedIds(retainedSelectionAfterReloadRef.current?.filter((id) => jobsResource.data.items.some((job) => job.id === id)) ?? []);
retainedSelectionAfterReloadRef.current = null;
}
}, [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 = window.history.state?.jobListFocusId ?? (location.state as { focusJobId?: unknown } | null)?.focusJobId;
if (boardView || typeof focusJobId !== "number" || restoredFocusJobIdRef.current === focusJobId || jobsResource.loading) return;
const row = document.querySelector<HTMLElement>(`[data-job-row-id="${focusJobId}"]`);
if (!row) return;
restoredFocusJobIdRef.current = focusJobId;
row.focus();
}, [boardView, 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: statusLabel(t, 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;
const results = await Promise.allSettled(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 });
}));
const failedIds = selectedIds.filter((_, index) => results[index].status === "rejected");
if (failedIds.length) toast(t("jobTableBulkActionFailed"), "error");
else toast(t("jobTableUpdatedJobs", { count: selectedIds.length }), "success");
if (failedIds.length !== selectedIds.length) {
retainedSelectionAfterReloadRef.current = failedIds;
setReloadToken((token) => token + 1);
}
};
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<JobRowViewModel[]>(() => 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(language === "nb" ? "nb-NO" : "en") : "—",
isSelected: selectedIdSet.has(job.id),
};
}), [buildWorkflowActionSignal, filteredJobs, selectedIdSet, language]);
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);
const densityControl = (
<FormControl size="small" sx={{ minWidth: 150, width: { xs: "100%", sm: "auto" } }}>
<InputLabel id="job-table-density-label">{t("listDensity")}</InputLabel>
<Select labelId="job-table-density-label" label={t("listDensity")} value={density} onChange={(event) => setDensity(event.target.value as ContentDensity)}>
<MenuItem value="comfortable">{t("densityComfortable")}</MenuItem>
<MenuItem value="compact">{t("densityCompact")}</MenuItem>
</Select>
</FormControl>
);
return (
<Box>
{mode === "jobs" ? (
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5, flexWrap: "wrap", mt: 2 }}>
<Box role="group" aria-label={t("jobsView")} sx={{ display: "flex", gap: 0.5 }}>
{(["table", "board"] as const).map((view) => (
<Button key={view} component={RouterLink} to={viewDestination(view)}
state={{ ...location.state, focusJobId: undefined }} size="small"
onClick={(event) => { if (boardView === (view === "board")) event.preventDefault(); }}
variant={boardView === (view === "board") ? "contained" : "outlined"}
aria-current={boardView === (view === "board") ? "page" : undefined}>
{t(view === "table" ? "jobsTableView" : "jobsBoardView")}
</Button>
))}
</Box>
<Typography variant="body2" role="status" sx={{ color: "text.secondary" }}>
{boardView
? boardCount !== null ? t("jobsMatchingCount", { count: boardCount }) : ""
: jobsResource.loading || jobsResource.refreshing || jobsResource.error ? "" : t("jobsMatchingCount", { count: total })}
</Typography>
</Box>
) : null}
{isMobile ? (
<Paper variant="outlined" sx={{ mt: 2, p: 1.25, borderRadius: 2.5, boxShadow: "none" }}>
<Stack spacing={1.1}>
<TextField
label={t("jobTableSearch")}
value={search}
onChange={(e) => changeSearch(e.target.value)}
placeholder={t("jobTableSearchPlaceholder")}
size="small"
InputProps={{ startAdornment: <InputAdornment position="start"><SearchIcon fontSize="small" /></InputAdornment> }}
fullWidth
/>
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
<FormControl fullWidth size="small">
<InputLabel id="job-table-status-label">{t("jobTableStatus")}</InputLabel>
<Select labelId="job-table-status-label" value={statusFilter} label={t("jobTableStatus")} onChange={(e) => changeStatus(e.target.value)}>
<MenuItem value="All">{t("jobTableAll")}</MenuItem>
{JOB_STATUS_FILTERS.map((status) => <MenuItem key={status} value={status}>{statusLabel(t, status)}</MenuItem>)}
{statusFilter !== "All" && !JOB_STATUS_FILTERS.includes(statusFilter) ? <MenuItem value={statusFilter}>{statusFilter}</MenuItem> : null}
</Select>
</FormControl>
<FormControl fullWidth size="small">
<InputLabel id="job-table-company-label">{t("jobTableCompany")}</InputLabel>
<Select labelId="job-table-company-label" value={companyFilterId} label={t("jobTableCompany")} onChange={(e) => changeCompany(e.target.value as number | "All")}>
<MenuItem value="All">{t("jobTableAll")}</MenuItem>
{selectedCompanyIsLoading ? <MenuItem value={companyFilterId}>{t("jobTableCompany")} {companyFilterId}</MenuItem> : null}
{companies.map((c) => <MenuItem key={c.id} value={c.id}>{c.name}</MenuItem>)}
</Select>
</FormControl>
</Box>
<Box sx={{ display: "grid", gridTemplateColumns: mode === "jobs" ? "1fr 1fr" : "1fr", gap: 1 }}>
<TextField
label={t("jobTableLocation")}
value={locationFilter}
onChange={(e) => changeLocation(e.target.value)}
fullWidth
/>
{mode === "jobs" ? (
<FormControl fullWidth size="small">
<InputLabel id="job-table-readiness-label">{t("jobTableReadiness")}</InputLabel>
<Select labelId="job-table-readiness-label" value={readinessFilter} label={t("jobTableReadiness")} onChange={(e) => changeReadiness(e.target.value as ReadinessFilter)}>
<MenuItem value="all">{t("jobTableAllReadiness")}</MenuItem>
<MenuItem value="needs-work">{t("jobTableNeedsWork")}</MenuItem>
<MenuItem value="interview">{t("jobTableInterviewStage")}</MenuItem>
</Select>
</FormControl>
) : null}
</Box>
{mode === "jobs" ? (
<Box
sx={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 0.25,
px: 0.25,
py: 0.5,
borderRadius: 2,
backgroundColor: "action.hover",
border: "1px solid",
borderColor: "divider",
}}
>
<FormControlLabel control={<Checkbox checked={needsFollowUpOnly} onChange={(e) => changeNeedsFollowUp(e.target.checked)} />} label={t("jobTableNeedsFollowUp")} sx={{ mr: 0, ml: -0.5 }} />
<FormControlLabel control={<Checkbox checked={includeDeleted} onChange={(e) => changeIncludeDeleted(e.target.checked)} />} label={t("jobTableShowDeleted")} sx={{ mr: 0, ml: -0.5 }} />
</Box>
) : null}
<Box sx={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr) auto", gap: 0.75, alignItems: "center", pt: 0.25 }}>
<Box sx={{ minWidth: 0 }}>
<SavedViewsMenu current={{ q: search.trim() || undefined, status: statusFilter !== "All" ? statusFilter : undefined, companyId: companyFilterId === "All" ? undefined : (companyFilterId as number), location: locationFilter.trim() || undefined, needsFollowUp: needsFollowUpOnly ? true : undefined, readiness: readinessFilter === "all" ? undefined : readinessFilter }} onApply={applySavedView} />
</Box>
{!boardView ? <Button variant="text" size="small" startIcon={<ViewColumnIcon />} onClick={(e) => setColumnsAnchor(e.currentTarget)} sx={{ justifySelf: "end", minHeight: 40, px: 1 }}>
{t("jobTableColumns")}
</Button> : null}
</Box>
{!boardView ? densityControl : null}
</Stack>
</Paper>
) : (
<Paper variant="outlined" sx={{ display: "flex", gap: 1.5, alignItems: "center", mt: 2, p: 1.5, flexWrap: "wrap", borderRadius: 2.5, boxShadow: "none" }}>
<TextField
label={t("jobTableSearch")}
value={search}
onChange={(e) => changeSearch(e.target.value)}
placeholder={t("jobTableSearchPlaceholder")}
size="small"
InputProps={{ startAdornment: <InputAdornment position="start"><SearchIcon fontSize="small" /></InputAdornment> }}
sx={{ width: { xs: "100%", md: "auto" }, minWidth: { xs: 0, md: 320 }, flex: { xs: "1 1 100%", md: "1 1 320px" } }}
/>
<FormControl sx={{ width: { xs: "100%", sm: 180 } }} size="small">
<InputLabel id="job-table-status-label">{t("jobTableStatus")}</InputLabel>
<Select labelId="job-table-status-label" value={statusFilter} label={t("jobTableStatus")} onChange={(e) => changeStatus(e.target.value)}>
<MenuItem value="All">{t("jobTableAll")}</MenuItem>
{JOB_STATUS_FILTERS.map((status) => <MenuItem key={status} value={status}>{statusLabel(t, status)}</MenuItem>)}
{statusFilter !== "All" && !JOB_STATUS_FILTERS.includes(statusFilter) ? <MenuItem value={statusFilter}>{statusFilter}</MenuItem> : null}
</Select>
</FormControl>
<FormControl sx={{ width: { xs: "100%", sm: 220 } }} size="small">
<InputLabel id="job-table-company-label">{t("jobTableCompany")}</InputLabel>
<Select labelId="job-table-company-label" value={companyFilterId} label={t("jobTableCompany")} onChange={(e) => changeCompany(e.target.value as number | "All")}>
<MenuItem value="All">{t("jobTableAll")}</MenuItem>
{selectedCompanyIsLoading ? <MenuItem value={companyFilterId}>{t("jobTableCompany")} {companyFilterId}</MenuItem> : null}
{companies.map((c) => <MenuItem key={c.id} value={c.id}>{c.name}</MenuItem>)}
</Select>
</FormControl>
<TextField
label={t("jobTableLocation")}
value={locationFilter}
onChange={(e) => changeLocation(e.target.value)}
sx={{ width: { xs: "100%", sm: 220 }, flex: { xs: "1 1 100%", md: "1 1 200px" } }}
/>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap", width: { xs: "100%", xl: "auto" } }}>
{mode === "jobs" ? <FormControlLabel control={<Checkbox checked={needsFollowUpOnly} onChange={(e) => changeNeedsFollowUp(e.target.checked)} />} label={t("jobTableNeedsFollowUp")} sx={{ mr: 0 }} /> : null}
{mode === "jobs" ? (
<FormControl size="small" sx={{ width: { xs: "100%", sm: 180 } }}>
<InputLabel id="job-table-readiness-label">{t("jobTableReadiness")}</InputLabel>
<Select labelId="job-table-readiness-label" value={readinessFilter} label={t("jobTableReadiness")} onChange={(e) => changeReadiness(e.target.value as ReadinessFilter)}>
<MenuItem value="all">{t("jobTableAllReadiness")}</MenuItem>
<MenuItem value="needs-work">{t("jobTableNeedsWork")}</MenuItem>
<MenuItem value="interview">{t("jobTableInterviewStage")}</MenuItem>
</Select>
</FormControl>
) : null}
{mode === "jobs" ? <FormControlLabel control={<Checkbox checked={includeDeleted} onChange={(e) => changeIncludeDeleted(e.target.checked)} />} label={t("jobTableShowDeleted")} sx={{ mr: 0 }} /> : null}
<SavedViewsMenu current={{ q: search.trim() || undefined, status: statusFilter !== "All" ? statusFilter : undefined, companyId: companyFilterId === "All" ? undefined : (companyFilterId as number), location: locationFilter.trim() || undefined, needsFollowUp: needsFollowUpOnly ? true : undefined, readiness: readinessFilter === "all" ? undefined : readinessFilter }} onApply={applySavedView} />
{!boardView && !isMobile ? <Tooltip title={t("jobTableColumns")}><IconButton aria-label={t("jobTableColumns")} onClick={(e) => setColumnsAnchor(e.currentTarget)}><ViewColumnIcon /></IconButton></Tooltip> : null}
{!boardView ? densityControl : null}
</Box>
</Paper>
)}
{!boardView && selectedIds.length > 0 ? (
<Paper variant="outlined" sx={{ mt: 1.5, p: 1.5, display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1.5, flexWrap: "wrap", borderColor: "primary.main", borderRadius: 2.5, boxShadow: "none" }}>
<Typography sx={{ fontWeight: 700 }}>{t("jobTableSelected", { count: selectedIds.length })}</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", width: { xs: "100%", sm: "auto" }, "& .MuiButton-root": { minHeight: isMobile ? 44 : undefined } }}>
{mode === "trash" ? <Button variant="outlined" onClick={() => void runBulkAction("restore")} sx={{ width: { xs: "100%", sm: "auto" } }}>{t("jobTableRestoreSelected")}</Button> : <Button variant="outlined" color="error" onClick={() => void runBulkAction("delete")} sx={{ width: { xs: "100%", sm: "auto" } }}>{t("jobTableDeleteSelected")}</Button>}
{mode === "jobs" && isMobile ? <Button variant="outlined" onClick={(event) => setBulkStatusAnchor(event.currentTarget)} sx={{ width: "100%" }}>{t("jobTableStatus")}</Button> : null}
{mode === "jobs" && !isMobile ? statusOptions.map((status) => <Button key={status} variant="outlined" onClick={() => void runBulkAction("status", status)}>{statusLabel(t, status)}</Button>) : null}
</Box>
</Paper>
) : null}
<Menu anchorEl={bulkStatusAnchor} open={Boolean(bulkStatusAnchor)} onClose={() => setBulkStatusAnchor(null)} MenuListProps={{ "aria-label": t("jobTableStatus") }}>
{statusOptions.map((status) => <MenuItem key={status} onClick={() => { setBulkStatusAnchor(null); void runBulkAction("status", status); }}>{statusLabel(t, status)}</MenuItem>)}
</Menu>
<Menu anchorEl={columnsAnchor} open={Boolean(columnsAnchor)} onClose={() => setColumnsAnchor(null)}>
{([ ["status", t("settingsColumnStatus")], ["dateApplied", t("settingsColumnDateApplied")], ["daysSince", t("settingsColumnDays")], ["jobUrl", t("settingsColumnJobUrl")] ] as const).map(([key, label]) => (
<MenuItem key={key} aria-label={label} onClick={() => onColumnsChange({ ...columns, [key]: !columns[key] })}>
<Checkbox checked={columns[key]} inputProps={{ "aria-label": label }} />
{label}
</MenuItem>
))}
</Menu>
<ViewStateNotice
error={boardView ? null : jobsResource.error}
title={mode === "trash" ? t("jobTableTrashLoadFailed") : t("jobTableJobsLoadFailed")}
description={mode === "trash" ? t("jobTableTrashLoadFailedBody") : t("jobTableJobsLoadFailedBody")}
onRetry={jobsResource.reload}
/>
{companiesError ? (
<ViewStateNotice
error={companiesError}
title={t("jobTableCompanyFiltersLoadFailed")}
description={t("jobTableCompanyFiltersLoadFailedBody")}
onRetry={reloadCompanies}
compact
/>
) : null}
{boardView ? <Suspense fallback={<ViewStateNotice loading title={t("loading")} />}><KanbanBoard refreshToken={refreshToken} onResultCount={setBoardCount} /></Suspense> : <Paper
data-content-density={density}
sx={{
mt: 1.5,
overflow: "hidden",
borderRadius: 2.5,
border: "1px solid",
borderColor: "divider",
boxShadow: "none",
}}
>
{isMobile ? (
<Stack spacing="var(--app-list-gap)" sx={{ p: 1.25 }}>
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1, px: 0.5 }}>
<FormControlLabel control={<Checkbox checked={selectedAllOnPage} indeterminate={selectedIds.length > 0 && !selectedAllOnPage} onChange={(e) => toggleSelectAll(e.target.checked)} />} label={t("jobTableSelectAll")} sx={{ mr: 0 }} />
</Box>
{jobsResource.loading ? <Typography sx={{ py: 2, textAlign: "center" }}>{t("loading")}</Typography> : null}
{!jobsResource.loading && !jobsResource.error && rowModels.map(({ job, toneName, actionSignals, tags, appliedDateLabel, isSelected }) => {
const compactTags = tags.slice(0, 6);
return (
<Paper
key={job.id}
data-job-row-id={job.id}
role={mode === "jobs" && !job.isDeleted ? "link" : undefined}
tabIndex={mode === "jobs" && !job.isDeleted ? 0 : undefined}
aria-label={mode === "jobs" && !job.isDeleted ? t("jobTableOpenJob", { title: job.jobTitle, company: job.company?.name ?? t("jobTableCompany") }) : undefined}
onClick={(event) => {
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);
}
}}
variant="outlined"
sx={{
p: "var(--app-list-item-padding)",
borderRadius: 2,
cursor: mode === "jobs" && !job.isDeleted ? "pointer" : "default",
backgroundColor: isSelected ? "action.selected" : "background.paper",
borderColor: "divider",
boxShadow: "none",
"&:hover": mode === "jobs" && !job.isDeleted ? { backgroundColor: "action.hover" } : undefined,
"&:focus-visible": { outline: "3px solid", outlineColor: "primary.main", outlineOffset: 2 },
}}
>
<Stack spacing="var(--app-list-gap)">
<Box sx={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
<Box sx={{ display: "flex", alignItems: "flex-start", gap: 1, minWidth: 0, flex: "1 1 180px" }}>
<Checkbox inputProps={{ "aria-label": t("jobTableSelectJob", { title: job.jobTitle }) }} checked={isSelected} onChange={(e) => toggleSelected(job.id, e.target.checked)} sx={{ mt: -0.5, ml: -1 }} />
<Box sx={{ minWidth: 0 }}>
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>
{job.company?.name ?? t("jobTableCompany")}
</Typography>
<Typography sx={{ fontWeight: 700, fontSize: 16, lineHeight: 1.35, overflowWrap: "anywhere" }}>
{job.jobTitle}
</Typography>
</Box>
</Box>
{columns.status ? <Chip label={statusLabel(t, job.status)} size="small" color={toneName as any} variant="outlined" sx={{ fontWeight: 700, maxWidth: "100%", height: "auto", minHeight: 24, "& .MuiChip-label": { whiteSpace: "normal", overflowWrap: "anywhere", py: 0.5 } }} /> : null}
</Box>
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap" }}>
{actionSignals.map((signal) => (
<Chip
key={`${job.id}-${signal.label}-${signal.detail}`}
size="small"
label={signal.label}
color={signal.color}
variant={signal.variant === "contained" ? "filled" : "outlined"}
title={signal.detail}
clickable
onClick={signal.onClick}
sx={{ fontWeight: 700 }}
/>
))}
</Box>
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1.25 }}>
{columns.dateApplied ? (
<Box>
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("jobTableDateApplied")}</Typography>
<Typography variant="body2" sx={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>{appliedDateLabel}</Typography>
</Box>
) : null}
{columns.daysSince ? (
<Box>
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("jobTableDays")}</Typography>
<Typography variant="body2" sx={{ fontWeight: 700, fontVariantNumeric: "tabular-nums" }}>{job.daysSince}</Typography>
</Box>
) : null}
<Box>
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("jobTableLocation")}</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{job.location ?? "-"}</Typography>
</Box>
<Box>
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("addJobModalSalary")}</Typography>
<Typography variant="body2" sx={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>{formatSalary(job) ?? "-"}</Typography>
</Box>
</Box>
{columns.jobUrl && job.jobUrl ? (
<Box>
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("settingsColumnJobUrl")}</Typography>
<Typography variant="body2"><a href={job.jobUrl} target="_blank" rel="noreferrer">{t("jobTableOpenListing")}</a></Typography>
</Box>
) : null}
{compactTags.length > 0 ? (
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap" }}>
{compactTags.map((tag) => <Chip key={tag} size="small" label={tag} sx={{ borderRadius: 999 }} />)}
</Box>
) : null}
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: 1, "& .MuiButton-root": { minHeight: 44, whiteSpace: "normal", lineHeight: 1.25 } }}>
<Button variant="outlined" startIcon={<EditOutlinedIcon />} onClick={() => setEditJobId(job.id)} sx={{ minHeight: 42, fontWeight: 700 }}>
{t("jobTableEdit")}
</Button>
<Button variant="outlined" startIcon={<MoreHorizIcon />} onClick={(e) => { setStatusJobId(job.id); setStatusAnchor(e.currentTarget); }} sx={{ minHeight: 42, fontWeight: 700 }}>
{t("jobTableQuickStatus")}
</Button>
{(mode === "trash" || (includeDeleted && job.isDeleted)) ? (
<Button variant="outlined" startIcon={<RestoreFromTrashOutlinedIcon />} onClick={() => void restore(job.id)} sx={{ minHeight: 42, fontWeight: 700 }}>
{t("jobTableRestore")}
</Button>
) : (
<Button color="error" variant="outlined" startIcon={<DeleteOutlineIcon />} onClick={() => void softDelete(job)} sx={{ minHeight: 42, fontWeight: 700 }}>
{t("jobTableSoftDelete")}
</Button>
)}
</Box>
</Stack>
</Paper>
);
})}
{filteredJobs.length === 0 && !jobsResource.loading && !jobsResource.error ? (
<EmptyJobsState firstTime={isFirstTimeEmpty} onOpenSettings={() => navigate("/settings")} t={t} />
) : null}
</Stack>
) : (
<Box role="region" aria-label={t("jobTableScrollRegion")} tabIndex={0} sx={{ overflowX: "auto", "&:focus-visible": { outlineOffset: -3 } }}>
<Table aria-label={t(mode === "trash" ? "trash" : "jobApplications")} size="small" sx={{ minWidth: 980 }}>
<TableHead>
<TableRow>
<TableCell padding="checkbox"><Checkbox inputProps={{ "aria-label": t("jobTableSelectAll") }} checked={selectedAllOnPage} indeterminate={selectedIds.length > 0 && !selectedAllOnPage} onChange={(e) => toggleSelectAll(e.target.checked)} /></TableCell>
<TableCell sortDirection={sortBy === "company" ? sortDir : false}><TableSortLabel active={sortBy === "company"} direction={sortBy === "company" ? sortDir : "asc"} onClick={() => requestSort("company")}>{t("jobTableCompany")}</TableSortLabel></TableCell>
<TableCell sortDirection={sortBy === "jobTitle" ? sortDir : false}><TableSortLabel active={sortBy === "jobTitle"} direction={sortBy === "jobTitle" ? sortDir : "asc"} onClick={() => requestSort("jobTitle")}>{t("jobTableRole")}</TableSortLabel></TableCell>
<TableCell sortDirection={sortBy === "location" ? sortDir : false}><TableSortLabel active={sortBy === "location"} direction={sortBy === "location" ? sortDir : "asc"} onClick={() => requestSort("location")}>{t("jobTableLocation")}</TableSortLabel></TableCell>
{columns.status ? <TableCell sortDirection={sortBy === "status" ? sortDir : false}><TableSortLabel active={sortBy === "status"} direction={sortBy === "status" ? sortDir : "asc"} onClick={() => requestSort("status")}>{t("jobTableStatus")}</TableSortLabel></TableCell> : null}
{columns.dateApplied ? <TableCell sortDirection={sortBy === "dateApplied" ? sortDir : false}><TableSortLabel active={sortBy === "dateApplied"} direction={sortBy === "dateApplied" ? sortDir : "asc"} onClick={() => requestSort("dateApplied")}>{t("jobTableDateApplied")}</TableSortLabel></TableCell> : null}
{columns.daysSince ? <TableCell sortDirection={sortBy === "daysSince" ? sortDir : false}><TableSortLabel active={sortBy === "daysSince"} direction={sortBy === "daysSince" ? sortDir : "asc"} onClick={() => requestSort("daysSince")}>{t("jobTableDays")}</TableSortLabel></TableCell> : null}
<TableCell>{t("jobDetailsDeadline")}</TableCell>
{columns.jobUrl ? <TableCell>{t("settingsColumnJobUrl")}</TableCell> : null}
<TableCell align="right">{t("jobTableActions")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{jobsResource.loading ? <TableRow><TableCell colSpan={visibleDesktopColumns}><Typography sx={{ py: 2, textAlign: "center" }}>{t("loading")}</Typography></TableCell></TableRow> : null}
{!jobsResource.loading && !jobsResource.error && rowModels.map(({ job, toneName, actionSignals, appliedDateLabel, isSelected }) => {
return (
<TableRow
key={job.id}
data-job-row-id={job.id}
hover={mode === "jobs" && !job.isDeleted}
selected={isSelected}
tabIndex={mode === "jobs" && !job.isDeleted ? 0 : undefined}
aria-label={mode === "jobs" && !job.isDeleted ? t("jobTableOpenJob", { title: job.jobTitle, company: job.company?.name ?? t("jobTableCompany") }) : undefined}
onClick={(event) => {
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: isSelected ? "action.selected" : "background.paper",
"&:hover": { backgroundColor: "action.hover" },
"&:focus-visible": { outline: "3px solid", outlineColor: "primary.main", outlineOffset: -3 },
}}
>
<TableCell padding="checkbox"><Checkbox inputProps={{ "aria-label": t("jobTableSelectJob", { title: job.jobTitle }) }} checked={isSelected} onChange={(e) => toggleSelected(job.id, e.target.checked)} /></TableCell>
<TableCell sx={{ minWidth: 140, color: "text.secondary", fontWeight: 600 }}>{job.company?.name ?? ""}</TableCell>
<TableCell sx={{ minWidth: 260 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
<Typography component="span" sx={{ fontWeight: 700, overflowWrap: "anywhere" }}>{job.jobTitle}</Typography>
{actionSignals.map((signal) => (
<Chip
key={`${job.id}-${signal.label}-${signal.detail}`}
size="small"
label={signal.label}
color={signal.color}
variant={signal.variant === "contained" ? "filled" : "outlined"}
title={signal.detail}
sx={{ fontWeight: 700, cursor: "pointer" }}
clickable
onClick={signal.onClick}
aria-label={t("jobTableWorkflowSignal", { title: job.jobTitle, signal: signal.label })}
/>
))}
</Box>
</TableCell>
<TableCell sx={{ minWidth: 130 }}>{job.location || "—"}</TableCell>
{columns.status ? <TableCell><Chip label={statusLabel(t, job.status)} size="small" color={toneName as any} variant="outlined" sx={{ fontWeight: 600 }} /></TableCell> : null}
{columns.dateApplied ? <TableCell>{appliedDateLabel}</TableCell> : null}
{columns.daysSince ? <TableCell>{job.daysSince ?? "—"}</TableCell> : null}
<TableCell>{job.deadline ? new Date(job.deadline).toLocaleDateString(language === "nb" ? "nb-NO" : "en") : "—"}</TableCell>
{columns.jobUrl ? <TableCell>{job.jobUrl ? <a href={job.jobUrl} target="_blank" rel="noreferrer">{t("jobTableLink")}</a> : ""}</TableCell> : null}
<TableCell align="right">
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 0.5, whiteSpace: "nowrap" }}>
<Tooltip title={t("jobTableEdit")}><IconButton size="small" aria-label={`${t("jobTableEdit")}: ${job.jobTitle}`} onClick={() => setEditJobId(job.id)}><EditOutlinedIcon fontSize="small" /></IconButton></Tooltip>
<Tooltip title={t("jobTableQuickStatus")}><IconButton size="small" aria-label={`${t("jobTableQuickStatus")}: ${job.jobTitle}`} onClick={(e) => { setStatusJobId(job.id); setStatusAnchor(e.currentTarget); }}><MoreHorizIcon fontSize="small" /></IconButton></Tooltip>
{(mode === "trash" || (includeDeleted && job.isDeleted)) ? <Tooltip title={t("jobTableRestore")}><IconButton size="small" aria-label={`${t("jobTableRestore")}: ${job.jobTitle}`} onClick={() => void restore(job.id)}><RestoreFromTrashOutlinedIcon fontSize="small" /></IconButton></Tooltip> : <Tooltip title={t("jobTableSoftDelete")}><IconButton size="small" aria-label={`${t("jobTableSoftDelete")}: ${job.jobTitle}`} onClick={() => void softDelete(job)}><DeleteOutlineIcon fontSize="small" /></IconButton></Tooltip>}
</Box>
</TableCell>
</TableRow>
);
})}
{filteredJobs.length === 0 && !jobsResource.loading && !jobsResource.error ? (
<TableRow><TableCell colSpan={visibleDesktopColumns}><EmptyJobsState firstTime={isFirstTimeEmpty} onOpenSettings={() => navigate("/settings")} t={t} /></TableCell></TableRow>
) : null}
</TableBody>
</Table>
</Box>
)}
<TablePagination component="div" labelRowsPerPage={t("settingsRowsPerPage")} labelDisplayedRows={({ from, to, count }) => t("tableDisplayedRows", { from, to, count })} getItemAriaLabel={(type) => t(type === "next" ? "tableNextPage" : type === "previous" ? "tablePreviousPage" : type === "first" ? "tableFirstPage" : "tableLastPage")} count={total} page={page} onPageChange={(_, next) => { 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]} />
</Paper>}
<EditJobDialog open={editJobId !== null} jobId={editJobId} onClose={() => setEditJobId(null)} onSaved={() => setReloadToken((token) => token + 1)} />
<Menu anchorEl={statusAnchor} open={Boolean(statusAnchor)} onClose={() => { setStatusAnchor(null); setStatusJobId(null); }}>
{statusOptions.map((status) => <MenuItem key={status} onClick={() => { if (statusJobId) void setStatusQuick(statusJobId, status); setStatusAnchor(null); setStatusJobId(null); }}>{t("jobTableSetStatus", { status: statusLabel(t, status) })}</MenuItem>)}
</Menu>
</Box>
);
}