fix(jobs): align table and board query contracts

This commit is contained in:
cesnimda
2026-09-29 17:26:08 +02:00
parent 42a696bc86
commit 6fc8937701
21 changed files with 541 additions and 100 deletions
+21 -44
View File
@@ -52,6 +52,7 @@ 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[];
@@ -93,10 +94,6 @@ interface Props {
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 {
@@ -104,26 +101,6 @@ function queryPage(search: string): number {
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 || "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 {
@@ -173,6 +150,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
const { t, language } = useI18n();
const { confirmAction } = useDialogActions();
const location = useLocation();
const routeQuery = useMemo(() => jobsQueryFromSearch(location.search), [location.search]);
const navigate = useNavigate();
const listRouteRef = useRef(`${location.pathname}${location.search}`);
const restoredFocusJobIdRef = useRef<number | null>(null);
@@ -183,24 +161,24 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
// 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(() => new URLSearchParams(location.search).get("q") ?? "");
const [search, setSearch] = useState(routeQuery.q);
const debouncedSearch = useDebouncedValue(search, 250);
const [includeDeleted, setIncludeDeleted] = useState(() => mode === "trash" || new URLSearchParams(location.search).get("includeDeleted") === "1");
const [includeDeleted, setIncludeDeleted] = useState(() => mode === "trash" || routeQuery.includeDeleted);
const [columnsAnchor, setColumnsAnchor] = useState<null | HTMLElement>(null);
const [statusFilter, setStatusFilter] = useState(() => queryStatus(location.search));
const [locationFilter, setLocationFilter] = useState(() => new URLSearchParams(location.search).get("location") ?? "");
const [statusFilter, setStatusFilter] = useState(routeQuery.status);
const [locationFilter, setLocationFilter] = useState(routeQuery.location);
const debouncedLocation = useDebouncedValue(locationFilter, 250);
const [needsFollowUpOnly, setNeedsFollowUpOnly] = useState(() => new URLSearchParams(location.search).get("needsFollowUp") === "1");
const [readinessFilter, setReadinessFilter] = useState<ReadinessFilter>(() => queryReadiness(location.search));
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">(() => queryCompany(location.search));
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>(() => querySort(location.search));
const [sortDir, setSortDir] = useState<"asc" | "desc">(() => new URLSearchParams(location.search).get("sortDir") === "asc" ? "asc" : "desc");
const [sortBy, setSortBy] = useState<JobSortKey>(routeQuery.sortBy);
const [sortDir, setSortDir] = useState<"asc" | "desc">(routeQuery.sortDir);
const updateListRoute = useCallback((updates: Record<string, string | null>) => {
setSelectedIds([]);
@@ -219,18 +197,17 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
}, [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");
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, mode]);
}, [location.search, routeQuery, mode]);
const changeSearch = (value: string) => {
setSearch(value);
+22 -17
View File
@@ -23,6 +23,7 @@ import ViewStateNotice from "./ViewStateNotice";
import { JobApplication } from "../types";
import { useI18n } from "../i18n/I18nProvider";
import { useViewResource } from "../hooks/useViewResource";
import { jobsQueryFromSearch } from "../jobsQuery";
import {
GROUP_ENTRY_STATUS,
PIPELINE_GROUPS,
@@ -77,8 +78,10 @@ export default function KanbanBoard() {
const palette = theme.vars?.palette ?? theme.palette;
const { t, language } = useI18n();
const location = useLocation();
const params = useMemo(() => jobsQueryFromSearch(location.search), [location.search]);
const boardRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
const focusAfterMoveRef = useRef<number | null>(null);
const [focusAfterMoveId, setFocusAfterMoveId] = useState<number | null>(null);
const restoredFocusRef = useRef<number | null>(null);
const [dragJobId, setDragJobId] = useState<number | null>(null);
const [dragOverColumn, setDragOverColumn] = useState<ColumnKey | null>(null);
@@ -89,29 +92,31 @@ export default function KanbanBoard() {
const jobsResource = useViewResource(
async () => {
const response = await api.get<JobApplication[]>("/jobapplications/board");
const response = await api.get<JobApplication[]>("/jobapplications/board", { params });
return response.data;
},
{
initialData: [],
errorMessage: t("kanbanLoadFailed"),
deps: [t],
deps: [t, params],
},
);
const jobs = jobsResource.data;
useEffect(() => {
if (jobsResource.loading || jobsResource.error) return;
if (jobsResource.loading || jobsResource.refreshing || jobsResource.error) return;
const returnId = window.history.state?.jobBoardFocusId ?? (location.state as { focusJobId?: unknown } | null)?.focusJobId;
const targetId = focusAfterMoveRef.current ?? (typeof returnId === "number" && restoredFocusRef.current !== returnId ? returnId : null);
const targetId = focusAfterMoveId ?? (typeof returnId === "number" && restoredFocusRef.current !== returnId ? returnId : null);
if (targetId == null) return;
const card = document.querySelector<HTMLElement>(`[data-kanban-card-id="${targetId}"] [role="button"]`);
if (!card) return;
card.focus();
focusAfterMoveRef.current = null;
// A successful move may leave the filtered population. Keep keyboard context in the board.
const target = card ?? (focusAfterMoveId != null ? boardRef.current?.querySelector<HTMLElement>('[role="group"]') : null);
if (!target) return;
target.focus();
setFocusAfterMoveId(null);
restoredFocusRef.current = targetId;
}, [jobs, jobsResource.loading, jobsResource.error, location.state]);
}, [jobs, jobsResource.loading, jobsResource.refreshing, jobsResource.error, location.state, focusAfterMoveId]);
const openJob = (id: number) => {
window.history.replaceState({ ...window.history.state, jobBoardFocusId: id }, "");
@@ -151,8 +156,8 @@ export default function KanbanBoard() {
setMoveError("");
try {
await api.patch(`/jobapplications/${movingJobId}/status`, { status });
focusAfterMoveRef.current = movingJobId;
jobsResource.setData((prev) => prev.map((j) => (j.id === movingJobId ? { ...j, status } : j)));
await jobsResource.reload();
setFocusAfterMoveId(movingJobId);
setAnnouncement(t("kanbanMoveSucceeded", { status: statusLabel(t, status) }));
} catch (error: any) {
setMoveError(getApiErrorMessage(error, t("kanbanMoveFailed")));
@@ -165,8 +170,8 @@ export default function KanbanBoard() {
setMoveError("");
try {
await api.patch(`/jobapplications/${id}/status`, { status });
focusAfterMoveRef.current = id;
jobsResource.setData((prev) => prev.map((j) => (j.id === id ? { ...j, status } : j)));
await jobsResource.reload();
setFocusAfterMoveId(id);
setAnnouncement(t("kanbanMoveSucceeded", { status: statusLabel(t, status) }));
} catch (error: any) {
setMoveError(getApiErrorMessage(error, t("kanbanMoveFailed")));
@@ -192,27 +197,27 @@ export default function KanbanBoard() {
}, [groups, t]);
return (
<Box sx={{ mt: 2, width: "100%", minWidth: 0, maxWidth: "100%", overflowX: "clip", contain: "inline-size" }}>
<Box ref={boardRef} sx={{ mt: 2, width: "100%", minWidth: 0, maxWidth: "100%", overflowX: "clip", contain: "inline-size" }}>
<Box sx={{ display: "flex", alignItems: { xs: "stretch", sm: "center" }, justifyContent: "space-between", gap: 1.5, flexDirection: { xs: "column", sm: "row" }, mb: 2 }}>
<Box>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("kanbanHint")}</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>{t("kanbanSeparateViews")}</Typography>
</Box>
<Box sx={{ display: "flex", gap: 0.75, flexShrink: 0 }}>
<Button component={RouterLink} to="/jobs" variant="outlined" size="small">{t("jobApplications")}</Button>
<Button component={RouterLink} to={{ pathname: "/jobs", search: location.search }} variant="outlined" size="small">{t("jobApplications")}</Button>
<Button variant="contained" size="small" aria-current="page" disabled>{t("kanbanBoard")}</Button>
</Box>
</Box>
<ViewStateNotice
loading={jobsResource.loading}
loading={jobsResource.loading || jobsResource.refreshing}
error={jobsResource.error}
title={t("kanbanLoadFailed")}
description={t("kanbanLoadFailedBody")}
onRetry={jobsResource.reload}
/>
{!jobsResource.loading && !jobsResource.error ? (
{!jobsResource.loading && !jobsResource.refreshing && !jobsResource.error ? (
<Box
sx={{
display: { xs: "flex", lg: "grid" },