feat(jobs): integrate table and board workspace

This commit is contained in:
cesnimda
2026-09-29 23:01:05 +02:00
parent 6fc8937701
commit 384dfa8a4b
35 changed files with 1191 additions and 60 deletions
+50 -13
View File
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import React, { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link as RouterLink, useLocation, useNavigate } from "react-router-dom";
import {
Box,
@@ -95,6 +95,7 @@ interface Props {
}
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"));
@@ -150,6 +151,8 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
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}`);
@@ -180,6 +183,19 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
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);
@@ -299,6 +315,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
},
{
initialData: { items: [], total: 0, page: 1, pageSize },
enabled: !boardView,
errorMessage: t("jobTableJobsLoadFailed"),
deps: [params, refreshToken, reloadToken, pageSize, t],
},
@@ -325,12 +342,12 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
useEffect(() => {
const focusJobId = window.history.state?.jobListFocusId ?? (location.state as { focusJobId?: unknown } | null)?.focusJobId;
if (typeof focusJobId !== "number" || restoredFocusJobIdRef.current === focusJobId || jobsResource.loading) return;
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();
}, [filteredJobs, jobsResource.loading, location.state]);
}, [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".
@@ -453,6 +470,26 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
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}>
@@ -529,11 +566,11 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
<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>
<Button variant="text" size="small" startIcon={<ViewColumnIcon />} onClick={(e) => setColumnsAnchor(e.currentTarget)} sx={{ justifySelf: "end", minHeight: 40, px: 1 }}>
{!boardView ? <Button variant="text" size="small" startIcon={<ViewColumnIcon />} onClick={(e) => setColumnsAnchor(e.currentTarget)} sx={{ justifySelf: "end", minHeight: 40, px: 1 }}>
{t("jobTableColumns")}
</Button>
</Button> : null}
</Box>
{densityControl}
{!boardView ? densityControl : null}
</Stack>
</Paper>
) : (
@@ -587,13 +624,13 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
) : 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} />
{!isMobile ? <Tooltip title={t("jobTableColumns")}><IconButton aria-label={t("jobTableColumns")} onClick={(e) => setColumnsAnchor(e.currentTarget)}><ViewColumnIcon /></IconButton></Tooltip> : null}
{densityControl}
{!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>
)}
{selectedIds.length > 0 ? (
{!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 } }}>
@@ -617,7 +654,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
</Menu>
<ViewStateNotice
error={jobsResource.error}
error={boardView ? null : jobsResource.error}
title={mode === "trash" ? t("jobTableTrashLoadFailed") : t("jobTableJobsLoadFailed")}
description={mode === "trash" ? t("jobTableTrashLoadFailedBody") : t("jobTableJobsLoadFailedBody")}
onRetry={jobsResource.reload}
@@ -632,7 +669,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
/>
) : null}
<Paper
{boardView ? <Suspense fallback={<ViewStateNotice loading title={t("loading")} />}><KanbanBoard refreshToken={refreshToken} onResultCount={setBoardCount} /></Suspense> : <Paper
data-content-density={density}
sx={{
mt: 1.5,
@@ -862,7 +899,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
</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>
</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); }}>