feat(jobs): integrate table and board workspace
This commit is contained in:
@@ -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); }}>
|
||||
|
||||
Reference in New Issue
Block a user