feat(jobs): redesign table and mobile cards with preserved workflows

This commit is contained in:
cesnimda
2026-09-28 23:30:43 +02:00
parent 0f54007015
commit 7127005e48
20 changed files with 192 additions and 63 deletions
+62 -47
View File
@@ -28,7 +28,7 @@ import {
Typography,
} from "@mui/material";
import useMediaQuery from "@mui/material/useMediaQuery";
import { alpha, useTheme } from "@mui/material/styles";
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";
@@ -111,7 +111,7 @@ function queryCompany(search: string): number | "All" {
function queryStatus(search: string): string {
const value = new URLSearchParams(search).get("status");
return value && JOB_STATUS_FILTERS.includes(value) ? value : "All";
return value || "All";
}
function queryReadiness(search: string): ReadinessFilter {
@@ -168,15 +168,15 @@ function isInteractiveTarget(target: EventTarget | null): boolean {
}
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 { t, language } = useI18n();
const { confirmAction } = useDialogActions();
const location = useLocation();
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));
@@ -197,11 +197,13 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
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>(() => querySort(location.search));
const [sortDir, setSortDir] = useState<"asc" | "desc">(() => new URLSearchParams(location.search).get("sortDir") === "asc" ? "asc" : "desc");
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);
@@ -293,6 +295,8 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
};
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]);
@@ -327,7 +331,8 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
setJobs(jobsResource.data.items);
setTotal(jobsResource.data.total);
if (!jobsResource.error) {
setSelectedIds([]);
setSelectedIds(retainedSelectionAfterReloadRef.current?.filter((id) => jobsResource.data.items.some((job) => job.id === id)) ?? []);
retainedSelectionAfterReloadRef.current = null;
}
}, [jobsResource.data, jobsResource.error]);
@@ -342,7 +347,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
const filteredJobs = jobs;
useEffect(() => {
const focusJobId = (location.state as { focusJobId?: unknown } | null)?.focusJobId;
const focusJobId = window.history.state?.jobListFocusId ?? (location.state as { focusJobId?: unknown } | null)?.focusJobId;
if (typeof focusJobId !== "number" || restoredFocusJobIdRef.current === focusJobId || jobsResource.loading) return;
const row = document.querySelector<HTMLElement>(`[data-job-row-id="${focusJobId}"]`);
if (!row) return;
@@ -401,7 +406,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
const setStatusQuick = async (id: number, status: string) => {
try {
await api.patch(`/jobapplications/${id}/status`, { status });
toast(t("jobTableStatusSet", { status }), "success");
toast(t("jobTableStatusSet", { status: statusLabel(t, status) }), "success");
setReloadToken((token) => token + 1);
} catch {
toast(t("jobTableStatusUpdateFailed"), "error");
@@ -412,17 +417,17 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
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) => {
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 });
}));
toast(t("jobTableUpdatedJobs", { count: selectedIds.length }), "success");
}));
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);
setSelectedIds([]);
} catch {
toast(t("jobTableBulkActionFailed"), "error");
}
};
@@ -451,10 +456,10 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
toneName: statusTone(job.status),
tags: parseTags(job.tags),
actionSignals: actionSignal ? [actionSignal] : [],
appliedDateLabel: job.dateApplied ? new Date(job.dateApplied).toLocaleDateString() : "—",
appliedDateLabel: job.dateApplied ? new Date(job.dateApplied).toLocaleDateString(language === "nb" ? "nb-NO" : "en") : "—",
isSelected: selectedIdSet.has(job.id),
};
}), [buildWorkflowActionSignal, filteredJobs, selectedIdSet]);
}), [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);
@@ -472,7 +477,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
return (
<Box>
{isMobile ? (
<Paper sx={{ mt: 2, p: 1.25, borderRadius: 4 }}>
<Paper variant="outlined" sx={{ mt: 2, p: 1.25, borderRadius: 2.5, boxShadow: "none" }}>
<Stack spacing={1.1}>
<TextField
label={t("jobTableSearch")}
@@ -488,7 +493,9 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
<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)}>
{[t("jobTableAll"), t("statusApplied"), t("statusWaiting"), t("statusInterview"), t("statusOffer"), t("statusRejected"), t("statusGhosted")].map((s) => <MenuItem key={s} value={s === t("jobTableAll") ? "All" : s === t("statusApplied") ? "Applied" : s === t("statusWaiting") ? "Waiting" : s === t("statusInterview") ? "Interview" : s === t("statusOffer") ? "Offer" : s === t("statusRejected") ? "Rejected" : "Ghosted"}>{s}</MenuItem>)}
<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>
@@ -530,10 +537,10 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
gap: 0.25,
px: 0.25,
py: 0.5,
borderRadius: 3,
backgroundColor: alpha(theme.palette.primary.main, 0.03),
borderRadius: 2,
backgroundColor: "action.hover",
border: "1px solid",
borderColor: alpha(theme.palette.primary.main, 0.1),
borderColor: "divider",
}}
>
<FormControlLabel control={<Checkbox checked={needsFollowUpOnly} onChange={(e) => changeNeedsFollowUp(e.target.checked)} />} label={t("jobTableNeedsFollowUp")} sx={{ mr: 0, ml: -0.5 }} />
@@ -553,7 +560,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
</Stack>
</Paper>
) : (
<Box sx={{ display: "flex", gap: 2, alignItems: "center", justifyContent: "space-between", mt: 2, flexWrap: "wrap" }}>
<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}
@@ -567,7 +574,9 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
<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)}>
{[t("jobTableAll"), t("statusApplied"), t("statusWaiting"), t("statusInterview"), t("statusOffer"), t("statusRejected"), t("statusGhosted")].map((s) => <MenuItem key={s} value={s === t("jobTableAll") ? "All" : s === t("statusApplied") ? "Applied" : s === t("statusWaiting") ? "Waiting" : s === t("statusInterview") ? "Interview" : s === t("statusOffer") ? "Offer" : s === t("statusRejected") ? "Rejected" : "Ghosted"}>{s}</MenuItem>)}
<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>
@@ -604,18 +613,22 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
{!isMobile ? <Tooltip title={t("jobTableColumns")}><IconButton aria-label={t("jobTableColumns")} onClick={(e) => setColumnsAnchor(e.currentTarget)}><ViewColumnIcon /></IconButton></Tooltip> : null}
{densityControl}
</Box>
</Box>
</Paper>
)}
{selectedIds.length > 0 ? (
<Paper sx={{ mt: 2, p: 1.5, display: "flex", alignItems: "center", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
<Typography sx={{ fontWeight: 800 }}>{t("jobTableSelected", { count: selectedIds.length })}</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", width: { xs: "100%", sm: "auto" } }}>
<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" ? statusOptions.map((status) => <Button key={status} variant="outlined" onClick={() => void runBulkAction("status", status)} sx={{ width: { xs: "100%", sm: "auto" } }}>{status}</Button>) : null}
{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]) => (
@@ -645,11 +658,12 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
<Paper
data-content-density={density}
sx={{
mt: 2,
mt: 1.5,
overflow: "hidden",
borderRadius: 4,
border: "none",
boxShadow: "var(--customShadows-section, none)",
borderRadius: 2.5,
border: "1px solid",
borderColor: "divider",
boxShadow: "none",
}}
>
{isMobile ? (
@@ -677,13 +691,14 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
openJob(job.id);
}
}}
variant="outlined"
sx={{
p: "var(--app-list-item-padding)",
borderRadius: 3.5,
borderRadius: 2,
cursor: mode === "jobs" && !job.isDeleted ? "pointer" : "default",
backgroundColor: isSelected ? "action.selected" : 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)}`,
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 },
}}
@@ -696,12 +711,12 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>
{job.company?.name ?? t("jobTableCompany")}
</Typography>
<Typography sx={{ fontWeight: 900, fontSize: 21, lineHeight: 1.1, letterSpacing: -0.4, textWrap: "balance", overflowWrap: "anywhere" }}>
<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} sx={{ fontWeight: 800, maxWidth: "100%", height: "auto", minHeight: 24, "& .MuiChip-label": { whiteSpace: "normal", overflowWrap: "anywhere", py: 0.5 } }} /> : null}
{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" }}>
@@ -756,7 +771,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
</Box>
) : null}
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: 1 }}>
<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>
@@ -801,7 +816,6 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
<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 }) => {
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 (
<TableRow
key={job.id}
@@ -821,15 +835,16 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
}}
sx={{
cursor: mode === "jobs" && !job.isDeleted ? "pointer" : "default",
backgroundColor: alpha(tone, theme.palette.mode === "dark" ? 0.1 : 0.06),
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, fontWeight: 700 }}>{job.company?.name ?? ""}</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: 800, overflowWrap: "anywhere" }}>{job.jobTitle}</Typography>
<Typography component="span" sx={{ fontWeight: 700, overflowWrap: "anywhere" }}>{job.jobTitle}</Typography>
{actionSignals.map((signal) => (
<Chip
key={`${job.id}-${signal.label}-${signal.detail}`}
@@ -838,7 +853,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
color={signal.color}
variant={signal.variant === "contained" ? "filled" : "outlined"}
title={signal.detail}
sx={{ fontWeight: 800, cursor: "pointer" }}
sx={{ fontWeight: 700, cursor: "pointer" }}
clickable
onClick={signal.onClick}
aria-label={t("jobTableWorkflowSignal", { title: job.jobTitle, signal: signal.label })}
@@ -847,10 +862,10 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
</Box>
</TableCell>
<TableCell sx={{ minWidth: 130 }}>{job.location || "—"}</TableCell>
{columns.status ? <TableCell><Chip label={statusLabel(t, job.status)} size="small" color={toneName as any} /></TableCell> : null}
{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() : "—"}</TableCell>
<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" }}>
@@ -874,7 +889,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
<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 })}</MenuItem>)}
{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>
);