Files
jobtrackingapp/job-tracker-ui/src/components/JobTable.tsx
T
cesnimda 9adbde3f5e feat(S05/T01): Unified workflow trust signals across the API, table, da…
- JobTrackerApi/Controllers/JobApplicationsController.cs
- JobTrackerApi.Tests/JobApplicationsWorkflowSignalsTests.cs
- job-tracker-ui/src/jobWorkflowSignals.ts
- job-tracker-ui/src/components/JobTable.tsx
- job-tracker-ui/src/components/DashboardView.tsx
- job-tracker-ui/src/components/RemindersView.tsx
- job-tracker-ui/src/workflow-trust-signals.test.tsx
2026-03-24 14:28:01 +01:00

468 lines
26 KiB
TypeScript

import React, { useEffect, useMemo, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import {
Box,
Button,
Checkbox,
Chip,
Collapse,
FormControl,
FormControlLabel,
IconButton,
InputAdornment,
InputLabel,
Menu,
MenuItem,
Paper,
Select,
Table,
TableBody,
TableCell,
TableHead,
TablePagination,
TableRow,
TableSortLabel,
TextField,
Tooltip,
Typography,
} from "@mui/material";
import { alpha, useTheme } from "@mui/material/styles";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import LaunchIcon from "@mui/icons-material/Launch";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
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 { api } from "../api";
import { useCompanies } from "../hooks/useCompanies";
import { useDebouncedValue } from "../hooks/useDebouncedValue";
import JobDetailsDialog from "./JobDetailsDialog";
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 { getWorkflowAction, needsInterviewPrep, needsWorkflowWork } from "../jobWorkflowSignals";
interface PagedResult<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
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";
}
function normalizeStatus(status: string): string {
return status === "Interviewing" ? "Interview" : status;
}
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 statusTone(status: string): string {
switch (normalizeStatus(status)) {
case "Offer":
return "success";
case "Rejected":
return "error";
case "Waiting":
case "Ghosted":
return "warning";
case "Interview":
return "info";
default:
return "primary";
}
}
export default function JobTable({ refreshToken, pageSize, onPageSizeChange, columns, onColumnsChange, mode = "jobs" }: Props) {
const theme = useTheme();
const { toast } = useToast();
const { t } = useI18n();
const { confirmAction } = useDialogActions();
const location = useLocation();
const navigate = useNavigate();
const [jobs, setJobs] = useState<JobApplication[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const [expanded, setExpanded] = useState<number[]>([]);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [search, setSearch] = useState("");
const debouncedSearch = useDebouncedValue(search, 250);
const [includeDeleted, setIncludeDeleted] = useState(mode === "trash");
const [columnsAnchor, setColumnsAnchor] = useState<null | HTMLElement>(null);
const [statusFilter, setStatusFilter] = useState("All");
const [locationFilter, setLocationFilter] = useState("");
const debouncedLocation = useDebouncedValue(locationFilter, 250);
const [needsFollowUpOnly, setNeedsFollowUpOnly] = useState(false);
const [readinessFilter, setReadinessFilter] = useState<"all" | "needs-work" | "interview">("all");
const { companies } = useCompanies();
const [companyFilterId, setCompanyFilterId] = useState<number | "All">("All");
const [detailsJobId, setDetailsJobId] = useState<number | null>(null);
const [detailsInitialTab, setDetailsInitialTab] = useState(0);
const [detailsFollowUpMode, setDetailsFollowUpMode] = useState<string | undefined>(undefined);
const [editJobId, setEditJobId] = useState<number | null>(null);
const [reloadToken, setReloadToken] = useState(0);
const [statusAnchor, setStatusAnchor] = useState<null | HTMLElement>(null);
const [statusJobId, setStatusJobId] = useState<number | null>(null);
const [sortBy, setSortBy] = useState<"dateApplied" | "company" | "jobTitle" | "status" | "daysSince" | "location">("dateApplied");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
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,
}), [page, pageSize, debouncedSearch, statusFilter, companyFilterId, debouncedLocation, includeDeleted, mode, sortBy, sortDir, needsFollowUpOnly]);
useEffect(() => {
api.get<PagedResult<JobApplication>>("/jobapplications", { params }).then((r) => {
setJobs(r.data.items);
setTotal(r.data.total);
setSelectedIds([]);
});
}, [params, refreshToken, reloadToken]);
useEffect(() => {
const paramsSearch = new URLSearchParams(location.search);
const openId = Number(paramsSearch.get("open") || 0);
const tabIndex = Number(paramsSearch.get("tab") || 0);
const followMode = paramsSearch.get("followMode") || undefined;
if (!openId || jobs.length === 0) return;
const job = jobs.find((j) => j.id === openId);
if (!job) return;
setDetailsJobId(openId);
setDetailsInitialTab(Number.isFinite(tabIndex) ? Math.max(0, Math.min(9, tabIndex)) : 0);
setDetailsFollowUpMode(followMode);
paramsSearch.delete("open");
paramsSearch.delete("tab");
navigate({ pathname: location.pathname, search: paramsSearch.toString() ? `?${paramsSearch.toString()}` : "" }, { replace: true });
}, [jobs, location.pathname, location.search, navigate]);
const requestSort = (key: typeof sortBy) => {
if (sortBy === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
else {
setSortBy(key);
setSortDir("asc");
}
setPage(0);
};
const toggleExpanded = (id: number) => {
setExpanded((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
};
const filteredJobs = useMemo(() => {
if (readinessFilter === "all") return jobs;
if (readinessFilter === "interview") return jobs.filter((job) => needsInterviewPrep(job));
return jobs.filter((job) => needsWorkflowWork(job));
}, [jobs, readinessFilter]);
const selectedAllOnPage = filteredJobs.length > 0 && filteredJobs.every((job) => selectedIds.includes(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((t) => t + 1);
} catch {
toast(t("jobTableDeleteFailed"), "error");
}
};
const restore = async (id: number) => {
try {
await api.post(`/jobapplications/${id}/restore`);
toast(t("jobTableRestored"), "success");
setReloadToken((t) => t + 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 }), "success");
setReloadToken((t) => t + 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) => selectedIds.includes(job.id));
if (action === "delete" && !(await confirmDelete(selectedJobs))) return;
try {
await Promise.all(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");
setReloadToken((t) => t + 1);
setSelectedIds([]);
} catch {
toast(t("jobTableBulkActionFailed"), "error");
}
};
const generateOverview = (job: JobApplication) => {
if (job.fullSummary) return job.fullSummary;
if (job.shortSummary) return job.shortSummary;
const src = (job.description || job.notes || "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
return src.length > 220 ? `${src.slice(0, 220)}...` : src;
};
const buildWorkflowActionDetail = (job: JobApplication) => getWorkflowAction(job, {
packageWork: t("jobTablePackageWork"),
followUp: t("jobTableFollowUp"),
interviewPrep: t("jobTableInterviewStage"),
readiness: t("jobTableReadiness"),
});
const getActionSignals = (job: JobApplication) => {
const action = buildWorkflowActionDetail(job);
if (!action || job.isDeleted) return [];
return [{
label: action.label,
detail: action.detail,
onClick: () => navigate(action.path),
variant: action.key === "follow-up" ? "contained" as const : "outlined" as const,
color: action.key === "follow-up" ? "warning" as const : "primary" as const,
}];
};
const getPrimaryAction = (job: JobApplication) => {
const action = buildWorkflowActionDetail(job);
if (!action || job.isDeleted) return null;
return {
label: action.label,
detail: action.detail,
onClick: () => navigate(action.path),
variant: action.key === "follow-up" ? "contained" as const : "outlined" as const,
color: action.key === "follow-up" ? "warning" as const : "primary" as const,
};
};
return (
<Box>
<Box sx={{ display: "flex", gap: 2, alignItems: "center", justifyContent: "space-between", mt: 2, flexWrap: "wrap" }}>
<TextField label={t("jobTableSearch")} value={search} onChange={(e) => { setSearch(e.target.value); setPage(0); }} placeholder={t("jobTableSearchPlaceholder")} size="small" InputProps={{ startAdornment: <InputAdornment position="start"><SearchIcon fontSize="small" /></InputAdornment> }} sx={{ minWidth: 320, flex: "1 1 320px" }} />
<FormControl sx={{ minWidth: 160 }} size="small">
<InputLabel>{t("jobTableStatus")}</InputLabel>
<Select value={statusFilter} label={t("jobTableStatus")} onChange={(e) => { setStatusFilter(e.target.value); setPage(0); }}>
{[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>)}
</Select>
</FormControl>
<FormControl sx={{ minWidth: 220 }} size="small">
<InputLabel>{t("jobTableCompany")}</InputLabel>
<Select value={companyFilterId} label={t("jobTableCompany")} onChange={(e) => { setCompanyFilterId(e.target.value as any); setPage(0); }}>
<MenuItem value="All">{t("jobTableAll")}</MenuItem>
{companies.map((c) => <MenuItem key={c.id} value={c.id}>{c.name}</MenuItem>)}
</Select>
</FormControl>
<TextField label={t("jobTableLocation")} value={locationFilter} onChange={(e) => { setLocationFilter(e.target.value); setPage(0); }} sx={{ minWidth: 200, flex: "1 1 200px" }} />
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
{mode === "jobs" ? <FormControlLabel control={<Checkbox checked={needsFollowUpOnly} onChange={(e) => { setNeedsFollowUpOnly(e.target.checked); setPage(0); }} />} label={t("jobTableNeedsFollowUp")} /> : null}
{mode === "jobs" ? (
<FormControl size="small" sx={{ minWidth: 180 }}>
<InputLabel>{t("jobTableReadiness")}</InputLabel>
<Select value={readinessFilter} label={t("jobTableReadiness")} onChange={(e) => setReadinessFilter(e.target.value as any)}>
<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) => { setIncludeDeleted(e.target.checked); setPage(0); }} />} label={t("jobTableShowDeleted")} /> : 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 }} onApply={(p: SavedViewParams) => { setSearch(p.q ?? ""); setStatusFilter(p.status ?? "All"); setCompanyFilterId(p.companyId ?? "All"); setLocationFilter(p.location ?? ""); setNeedsFollowUpOnly(Boolean(p.needsFollowUp)); setPage(0); }} />
<Tooltip title={t("jobTableColumns")}><IconButton onClick={(e) => setColumnsAnchor(e.currentTarget)}><ViewColumnIcon /></IconButton></Tooltip>
</Box>
</Box>
{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" }}>
{mode === "trash" ? <Button variant="outlined" onClick={() => void runBulkAction("restore")}>{t("jobTableRestoreSelected")}</Button> : <Button variant="outlined" color="error" onClick={() => void runBulkAction("delete")}>{t("jobTableDeleteSelected")}</Button>}
{mode === "jobs" ? ["Waiting", "Interview", "Rejected", "Ghosted", "Offer"].map((s) => <Button key={s} variant="outlined" onClick={() => void runBulkAction("status", s)}>{s}</Button>) : null}
</Box>
</Paper>
) : null}
<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} onClick={() => onColumnsChange({ ...columns, [key]: !columns[key] })}>
<Checkbox checked={columns[key]} />
{label}
</MenuItem>
))}
</Menu>
<Paper sx={{ mt: 2 }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell padding="checkbox"><Checkbox checked={selectedAllOnPage} indeterminate={selectedIds.length > 0 && !selectedAllOnPage} onChange={(e) => toggleSelectAll(e.target.checked)} /></TableCell>
<TableCell width={1} />
<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>
{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}
{columns.jobUrl ? <TableCell>{t("settingsColumnJobUrl")}</TableCell> : null}
<TableCell align="right">{t("jobTableActions")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{filteredJobs.map((job) => {
const open = expanded.includes(job.id);
const toneName = statusTone(job.status);
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;
const primaryAction = getPrimaryAction(job);
const actionSignals = getActionSignals(job);
return (
<React.Fragment key={job.id}>
<TableRow sx={{ backgroundColor: alpha(tone, theme.palette.mode === "dark" ? 0.1 : 0.06) }}>
<TableCell padding="checkbox"><Checkbox checked={selectedIds.includes(job.id)} onChange={(e) => toggleSelected(job.id, e.target.checked)} /></TableCell>
<TableCell><IconButton size="small" onClick={() => toggleExpanded(job.id)}>{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}</IconButton></TableCell>
<TableCell>{job.company?.name ?? ""}</TableCell>
<TableCell>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
<span>{job.jobTitle}</span>
{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: 800, cursor: "pointer" }}
clickable
onClick={signal.onClick}
aria-label={`${job.jobTitle}${signal.label} signal`}
/>
))}
</Box>
</TableCell>
{columns.status ? <TableCell><Chip label={normalizeStatus(job.status)} size="small" color={toneName as any} /></TableCell> : null}
{columns.dateApplied ? <TableCell>{new Date(job.dateApplied).toLocaleDateString()}</TableCell> : null}
{columns.daysSince ? <TableCell>{job.daysSince}</TableCell> : null}
{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", flexDirection: "column", alignItems: "flex-end", gap: 0.75 }}>
{primaryAction ? (
<>
<Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 700 }}>
{t("editJobNextAction")}
</Typography>
<Button size="small" variant={primaryAction.variant} color={primaryAction.color} onClick={primaryAction.onClick} aria-label={`${t("editJobNextAction")}: ${job.jobTitle}${primaryAction.label}`}>
{primaryAction.label}
</Button>
<Typography variant="caption" sx={{ color: "text.secondary", maxWidth: 220, textAlign: "right" }}>
{primaryAction.detail}
</Typography>
</>
) : null}
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 0.5 }}>
<Tooltip title={t("jobTableEdit")}><IconButton size="small" onClick={() => setEditJobId(job.id)}><EditOutlinedIcon fontSize="small" /></IconButton></Tooltip>
<Tooltip title={t("jobTableQuickStatus")}><IconButton size="small" onClick={(e) => { setStatusJobId(job.id); setStatusAnchor(e.currentTarget); }}><MoreHorizIcon fontSize="small" /></IconButton></Tooltip>
<Tooltip title={t("jobTableOpen")}><IconButton size="small" onClick={() => setDetailsJobId(job.id)}><LaunchIcon fontSize="small" /></IconButton></Tooltip>
{(mode === "trash" || (includeDeleted && job.isDeleted)) ? <Tooltip title={t("jobTableRestore")}><IconButton size="small" onClick={() => void restore(job.id)}><RestoreFromTrashOutlinedIcon fontSize="small" /></IconButton></Tooltip> : <Tooltip title={t("jobTableSoftDelete")}><IconButton size="small" onClick={() => void softDelete(job)}><DeleteOutlineIcon fontSize="small" /></IconButton></Tooltip>}
</Box>
</Box>
</TableCell>
</TableRow>
<TableRow>
<TableCell sx={{ py: 0 }} colSpan={columns.status && columns.dateApplied && columns.daysSince && columns.jobUrl ? 9 : 8}>
<Collapse in={open} timeout="auto" unmountOnExit>
<Box sx={{ p: 2, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr" }, gap: 2 }}>
<Box><Typography variant="overline">{t("jobTableLocation")}</Typography><Typography>{job.location ?? "-"}</Typography></Box>
<Box><Typography variant="overline">{t("addJobModalSalary")}</Typography><Typography>{job.salary ?? "-"}</Typography></Box>
<Box><Typography variant="overline">{t("settingsColumnJobUrl")}</Typography><Typography>{job.jobUrl ? <a href={job.jobUrl} target="_blank" rel="noreferrer">{t("jobTableOpenListing")}</a> : "-"}</Typography></Box>
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">{t("jobTableSkills")}</Typography><Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.5 }}>{parseTags(job.tags).length ? parseTags(job.tags).slice(0, 8).map((tag) => <Chip key={tag} label={tag} size="small" />) : <Typography sx={{ color: "text.secondary" }}>{t("jobTableNoTags")}</Typography>}</Box></Box>
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">{t("jobTableOverview")}</Typography><Typography sx={{ whiteSpace: "pre-wrap" }}>{generateOverview(job) || t("jobTableNoSummaryYet")}</Typography></Box>
</Box>
</Collapse>
</TableCell>
</TableRow>
</React.Fragment>
);
})}
{filteredJobs.length === 0 ? <TableRow><TableCell colSpan={9}><Typography sx={{ py: 2, textAlign: "center" }}>{t("jobTableNoJobsFound")}</Typography></TableCell></TableRow> : null}
</TableBody>
</Table>
<TablePagination component="div" count={total} page={page} onPageChange={(_, next) => setPage(next)} rowsPerPage={pageSize} onRowsPerPageChange={(e) => { onPageSizeChange(Number(e.target.value) as 15 | 20 | 25); setPage(0); }} rowsPerPageOptions={[15, 20, 25]} />
</Paper>
<JobDetailsDialog open={detailsJobId !== null} jobId={detailsJobId} initialTab={detailsInitialTab} initialFollowUpMode={detailsFollowUpMode} onClose={() => { setDetailsJobId(null); setDetailsInitialTab(0); setDetailsFollowUpMode(undefined); }} />
<EditJobDialog open={editJobId !== null} jobId={editJobId} onClose={() => setEditJobId(null)} onSaved={() => setReloadToken((t) => t + 1)} />
<Menu anchorEl={statusAnchor} open={Boolean(statusAnchor)} onClose={() => { setStatusAnchor(null); setStatusJobId(null); }}>
{(["Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const).map((s) => <MenuItem key={s} onClick={() => { if (statusJobId) void setStatusQuick(statusJobId, s); setStatusAnchor(null); setStatusJobId(null); }}>{t("jobTableSetStatus", { status: s })}</MenuItem>)}
</Menu>
</Box>
);
}