eac34705e3
Unblocks the documented core workflow and closes the AI-service exposure, without changing existing behaviour. Job/JobApplication split (additive; see ADR-002): - New Job entity (the opportunity) with owner-scoped query filter; nullable JobApplication.JobId FK. Nothing reads Job yet. - Migration AddJobEntityAndProspectStages, hand-edited to drop reconciler-owned tables the scaffolder re-emitted; verified against the real dev DB. Pipeline: 10 internal stages across three concerns kept separate — PipelineStage (workflow) / PipelineGroup (UI: NotApplied/Active/Closed) / PipelineCategory (analytics). Adds Saved/Interested/Preparing/Withdrawn; keeps Waiting and Ghosted. Kanban shows 3 grouped columns; cards keep a stage chip and full transitions; drag applies only safe transitions (never infers Ghosted/Withdrawn). DateApplied nullable + SavedAt. Cleared when leaving Applied so analytics stay accurate; the discarded date is preserved as an AppliedDateCleared JobEvent. AI service lockdown: no host port; private ai_internal network (backend is the only other member); X-Ai-Service-Token required on all non-/health endpoints; AI_SERVICE_TOKEN mandatory via compose. Verified backend-only against the live stack. Also carries two pre-existing working-tree files (views/ProfilePage.tsx, views/CareerWorkspacePage.tsx) so the tree is clean for the branch integration. Tests: +40 backend (247 total), +5 sidecar (16), +15 frontend. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
787 lines
44 KiB
TypeScript
787 lines
44 KiB
TypeScript
import React, { useCallback, 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,
|
|
Stack,
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TablePagination,
|
|
TableRow,
|
|
TableSortLabel,
|
|
TextField,
|
|
Tooltip,
|
|
Typography,
|
|
} from "@mui/material";
|
|
import useMediaQuery from "@mui/material/useMediaQuery";
|
|
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 WorkOutlineIcon from "@mui/icons-material/WorkOutline";
|
|
|
|
import { api } from "../api";
|
|
import ViewStateNotice from "./ViewStateNotice";
|
|
import { useCompanies } from "../hooks/useCompanies";
|
|
import { useDebouncedValue } from "../hooks/useDebouncedValue";
|
|
import { formatSalary } from "../salary";
|
|
import { statusLabel, statusTone } from "../pipeline";
|
|
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 { useViewResource } from "../hooks/useViewResource";
|
|
import { getWorkflowAction, needsInterviewPrep, needsWorkflowWork } from "../jobWorkflowSignals";
|
|
|
|
interface PagedResult<T> {
|
|
items: T[];
|
|
total: number;
|
|
page: number;
|
|
pageSize: number;
|
|
}
|
|
|
|
type RowActionSignal = {
|
|
label: string;
|
|
detail: string;
|
|
onClick: () => void;
|
|
variant: "contained" | "outlined";
|
|
color: "warning" | "primary";
|
|
};
|
|
|
|
type JobRowViewModel = {
|
|
job: JobApplication;
|
|
toneName: string;
|
|
overview: string;
|
|
tags: string[];
|
|
actionSignals: RowActionSignal[];
|
|
primaryAction: RowActionSignal | null;
|
|
appliedDateLabel: string;
|
|
isSelected: boolean;
|
|
isExpanded: boolean;
|
|
};
|
|
|
|
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 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 EmptyJobsState({ firstTime, onOpenSettings, t }: { firstTime: boolean; onOpenSettings: () => void; t: (key: any) => string }) {
|
|
if (!firstTime) {
|
|
return <Typography sx={{ py: 2, textAlign: "center", color: "text.secondary" }}>{t("jobTableNoJobsFound")}</Typography>;
|
|
}
|
|
return (
|
|
<Box sx={{ py: 5, textAlign: "center" }}>
|
|
<Box
|
|
sx={{
|
|
width: 56,
|
|
height: 56,
|
|
mx: "auto",
|
|
mb: 2,
|
|
borderRadius: 3,
|
|
display: "grid",
|
|
placeItems: "center",
|
|
bgcolor: (theme) => alpha(theme.palette.primary.main, 0.12),
|
|
color: "primary.main",
|
|
}}
|
|
>
|
|
<WorkOutlineIcon fontSize="medium" />
|
|
</Box>
|
|
<Typography sx={{ fontWeight: 800, mb: 0.5 }}>{t("jobTableEmptyFirstTimeTitle")}</Typography>
|
|
<Typography sx={{ color: "text.secondary", mb: 1.5, maxWidth: 440, mx: "auto" }}>{t("jobTableEmptyFirstTimeBody")}</Typography>
|
|
<Button variant="text" onClick={onOpenSettings}>{t("jobTableEmptyFirstTimeBookmarklet")}</Button>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function generateOverview(job: JobApplication): string {
|
|
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;
|
|
}
|
|
|
|
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 { 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, error: companiesError, reload: reloadCompanies } = 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]);
|
|
|
|
const jobsResource = useViewResource(
|
|
async () => {
|
|
const r = await api.get<PagedResult<JobApplication>>("/jobapplications", { params });
|
|
return r.data;
|
|
},
|
|
{
|
|
initialData: { items: [], total: 0, page: 1, pageSize },
|
|
errorMessage: "Unable to load jobs right now.",
|
|
deps: [params, refreshToken, reloadToken, pageSize],
|
|
},
|
|
);
|
|
|
|
useEffect(() => {
|
|
setJobs(jobsResource.data.items);
|
|
setTotal(jobsResource.data.total);
|
|
if (!jobsResource.error) {
|
|
setSelectedIds([]);
|
|
}
|
|
}, [jobsResource.data, jobsResource.error]);
|
|
|
|
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]);
|
|
|
|
// 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".
|
|
const noFiltersActive = !debouncedSearch.trim() && statusFilter === "All" && companyFilterId === "All"
|
|
&& !debouncedLocation.trim() && !needsFollowUpOnly && readinessFilter === "all";
|
|
const isFirstTimeEmpty = mode === "jobs" && total === 0 && noFiltersActive;
|
|
|
|
const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]);
|
|
|
|
const selectedAllOnPage = filteredJobs.length > 0 && filteredJobs.every((job) => selectedIdSet.has(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((token) => token + 1);
|
|
} catch {
|
|
toast(t("jobTableDeleteFailed"), "error");
|
|
}
|
|
};
|
|
|
|
const restore = async (id: number) => {
|
|
try {
|
|
await api.post(`/jobapplications/${id}/restore`);
|
|
toast(t("jobTableRestored"), "success");
|
|
setReloadToken((token) => token + 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((token) => token + 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) => selectedIdSet.has(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((token) => token + 1);
|
|
setSelectedIds([]);
|
|
} catch {
|
|
toast(t("jobTableBulkActionFailed"), "error");
|
|
}
|
|
};
|
|
|
|
const buildWorkflowActionSignal = useCallback((job: JobApplication): RowActionSignal | null => {
|
|
const action = getWorkflowAction(job, {
|
|
packageWork: t("jobTablePackageWork"),
|
|
followUp: t("jobTableFollowUp"),
|
|
interviewPrep: t("jobTableInterviewStage"),
|
|
readiness: t("jobTableReadiness"),
|
|
});
|
|
if (!action || job.isDeleted) return null;
|
|
|
|
return {
|
|
label: action.label,
|
|
detail: action.detail,
|
|
onClick: () => navigate(action.path),
|
|
variant: action.key === "follow-up" ? "contained" : "outlined",
|
|
color: action.key === "follow-up" ? "warning" : "primary",
|
|
};
|
|
}, [navigate, t]);
|
|
|
|
const rowModels = useMemo<JobRowViewModel[]>(() => filteredJobs.map((job) => {
|
|
const actionSignal = buildWorkflowActionSignal(job);
|
|
return {
|
|
job,
|
|
toneName: statusTone(job.status),
|
|
overview: generateOverview(job),
|
|
tags: parseTags(job.tags),
|
|
actionSignals: actionSignal ? [actionSignal] : [],
|
|
primaryAction: actionSignal,
|
|
appliedDateLabel: job.dateApplied ? new Date(job.dateApplied).toLocaleDateString() : "—",
|
|
isSelected: selectedIdSet.has(job.id),
|
|
isExpanded: expanded.includes(job.id),
|
|
};
|
|
}), [buildWorkflowActionSignal, expanded, filteredJobs, selectedIdSet]);
|
|
|
|
const statusOptions = ["Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const;
|
|
const visibleDesktopColumns = 4 + Number(columns.status) + Number(columns.dateApplied) + Number(columns.daysSince) + Number(columns.jobUrl);
|
|
|
|
return (
|
|
<Box>
|
|
{isMobile ? (
|
|
<Paper sx={{ mt: 2, p: 1.25, borderRadius: 4 }}>
|
|
<Stack spacing={1.1}>
|
|
<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> }}
|
|
fullWidth
|
|
/>
|
|
|
|
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
|
|
<FormControl fullWidth 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 fullWidth 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>
|
|
</Box>
|
|
|
|
<Box sx={{ display: "grid", gridTemplateColumns: mode === "jobs" ? "1fr 1fr" : "1fr", gap: 1 }}>
|
|
<TextField
|
|
label={t("jobTableLocation")}
|
|
value={locationFilter}
|
|
onChange={(e) => { setLocationFilter(e.target.value); setPage(0); }}
|
|
fullWidth
|
|
/>
|
|
|
|
{mode === "jobs" ? (
|
|
<FormControl fullWidth size="small">
|
|
<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}
|
|
</Box>
|
|
|
|
{mode === "jobs" ? (
|
|
<Box
|
|
sx={{
|
|
display: "grid",
|
|
gridTemplateColumns: "1fr 1fr",
|
|
gap: 0.25,
|
|
px: 0.25,
|
|
py: 0.5,
|
|
borderRadius: 3,
|
|
backgroundColor: alpha(theme.palette.primary.main, 0.03),
|
|
border: "1px solid",
|
|
borderColor: alpha(theme.palette.primary.main, 0.1),
|
|
}}
|
|
>
|
|
<FormControlLabel control={<Checkbox checked={needsFollowUpOnly} onChange={(e) => { setNeedsFollowUpOnly(e.target.checked); setPage(0); }} />} label={t("jobTableNeedsFollowUp")} sx={{ mr: 0, ml: -0.5 }} />
|
|
<FormControlLabel control={<Checkbox checked={includeDeleted} onChange={(e) => { setIncludeDeleted(e.target.checked); setPage(0); }} />} label={t("jobTableShowDeleted")} sx={{ mr: 0, ml: -0.5 }} />
|
|
</Box>
|
|
) : null}
|
|
|
|
<Box sx={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr) auto", gap: 0.75, alignItems: "center", pt: 0.25 }}>
|
|
<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 }} onApply={(p: SavedViewParams) => { setSearch(p.q ?? ""); setStatusFilter(p.status ?? "All"); setCompanyFilterId(p.companyId ?? "All"); setLocationFilter(p.location ?? ""); setNeedsFollowUpOnly(Boolean(p.needsFollowUp)); setPage(0); }} />
|
|
</Box>
|
|
<Button variant="text" size="small" startIcon={<ViewColumnIcon />} onClick={(e) => setColumnsAnchor(e.currentTarget)} sx={{ justifySelf: "end", minHeight: 40, px: 1 }}>
|
|
{t("jobTableColumns")}
|
|
</Button>
|
|
</Box>
|
|
</Stack>
|
|
</Paper>
|
|
) : (
|
|
<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={{ width: { xs: "100%", md: "auto" }, minWidth: { xs: 0, md: 320 }, flex: { xs: "1 1 100%", md: "1 1 320px" } }}
|
|
/>
|
|
|
|
<FormControl sx={{ width: { xs: "100%", sm: 180 } }} 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={{ width: { xs: "100%", sm: 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={{ width: { xs: "100%", sm: 220 }, flex: { xs: "1 1 100%", md: "1 1 200px" } }}
|
|
/>
|
|
|
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap", width: { xs: "100%", xl: "auto" } }}>
|
|
{mode === "jobs" ? <FormControlLabel control={<Checkbox checked={needsFollowUpOnly} onChange={(e) => { setNeedsFollowUpOnly(e.target.checked); setPage(0); }} />} label={t("jobTableNeedsFollowUp")} sx={{ mr: 0 }} /> : null}
|
|
{mode === "jobs" ? (
|
|
<FormControl size="small" sx={{ width: { xs: "100%", sm: 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")} 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 }} onApply={(p: SavedViewParams) => { setSearch(p.q ?? ""); setStatusFilter(p.status ?? "All"); setCompanyFilterId(p.companyId ?? "All"); setLocationFilter(p.location ?? ""); setNeedsFollowUpOnly(Boolean(p.needsFollowUp)); setPage(0); }} />
|
|
{!isMobile ? <Tooltip title={t("jobTableColumns")}><IconButton onClick={(e) => setColumnsAnchor(e.currentTarget)}><ViewColumnIcon /></IconButton></Tooltip> : null}
|
|
</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", width: { xs: "100%", sm: "auto" } }}>
|
|
{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}
|
|
</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>
|
|
|
|
<ViewStateNotice
|
|
error={jobsResource.error}
|
|
title={mode === "trash" ? "Unable to load trash" : "Unable to load jobs"}
|
|
description={mode === "trash" ? "The deleted-jobs view cannot reach the API right now." : "The jobs list cannot reach the API right now."}
|
|
onRetry={jobsResource.reload}
|
|
/>
|
|
{companiesError ? (
|
|
<ViewStateNotice
|
|
error={companiesError}
|
|
title="Unable to load company filters"
|
|
description="Company filter data is unavailable right now."
|
|
onRetry={reloadCompanies}
|
|
compact
|
|
/>
|
|
) : null}
|
|
|
|
<Paper
|
|
sx={{
|
|
mt: 2,
|
|
overflow: "hidden",
|
|
borderRadius: 4,
|
|
border: "none",
|
|
boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)",
|
|
}}
|
|
>
|
|
{isMobile ? (
|
|
<Stack spacing={1.25} sx={{ p: 1.25 }}>
|
|
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1, px: 0.5 }}>
|
|
<FormControlLabel control={<Checkbox checked={selectedAllOnPage} indeterminate={selectedIds.length > 0 && !selectedAllOnPage} onChange={(e) => toggleSelectAll(e.target.checked)} />} label={t("jobTableSelectAll")} sx={{ mr: 0 }} />
|
|
</Box>
|
|
|
|
{jobsResource.loading ? <Typography sx={{ py: 2, textAlign: "center" }}>{t("loading")}</Typography> : null}
|
|
{!jobsResource.loading && !jobsResource.error && rowModels.map(({ job, toneName, actionSignals, tags, overview, primaryAction, appliedDateLabel, isSelected }) => {
|
|
const compactTags = tags.slice(0, 6);
|
|
return (
|
|
<Paper
|
|
key={job.id}
|
|
sx={{
|
|
p: 1.5,
|
|
borderRadius: 3.5,
|
|
backgroundColor: 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)}`,
|
|
}}
|
|
>
|
|
<Stack spacing={1.25}>
|
|
<Box sx={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 1 }}>
|
|
<Box sx={{ display: "flex", alignItems: "flex-start", gap: 1, minWidth: 0, flex: 1 }}>
|
|
<Checkbox checked={isSelected} onChange={(e) => toggleSelected(job.id, e.target.checked)} sx={{ mt: -0.5, ml: -1 }} />
|
|
<Box sx={{ minWidth: 0 }}>
|
|
<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" }}>
|
|
{job.jobTitle}
|
|
</Typography>
|
|
</Box>
|
|
</Box>
|
|
{columns.status ? <Chip label={statusLabel(t, job.status)} size="small" color={toneName as any} sx={{ fontWeight: 800 }} /> : null}
|
|
</Box>
|
|
|
|
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap" }}>
|
|
{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}
|
|
clickable
|
|
onClick={signal.onClick}
|
|
sx={{ fontWeight: 700 }}
|
|
/>
|
|
))}
|
|
</Box>
|
|
|
|
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1.25 }}>
|
|
{columns.dateApplied ? (
|
|
<Box>
|
|
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("jobTableDateApplied")}</Typography>
|
|
<Typography variant="body2" sx={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>{appliedDateLabel}</Typography>
|
|
</Box>
|
|
) : null}
|
|
{columns.daysSince ? (
|
|
<Box>
|
|
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("jobTableDays")}</Typography>
|
|
<Typography variant="body2" sx={{ fontWeight: 700, fontVariantNumeric: "tabular-nums" }}>{job.daysSince}</Typography>
|
|
</Box>
|
|
) : null}
|
|
<Box>
|
|
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("jobTableLocation")}</Typography>
|
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>{job.location ?? "-"}</Typography>
|
|
</Box>
|
|
<Box>
|
|
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("addJobModalSalary")}</Typography>
|
|
<Typography variant="body2" sx={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>{formatSalary(job) ?? "-"}</Typography>
|
|
</Box>
|
|
</Box>
|
|
|
|
{columns.jobUrl && job.jobUrl ? (
|
|
<Box>
|
|
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("settingsColumnJobUrl")}</Typography>
|
|
<Typography variant="body2"><a href={job.jobUrl} target="_blank" rel="noreferrer">{t("jobTableOpenListing")}</a></Typography>
|
|
</Box>
|
|
) : null}
|
|
|
|
{compactTags.length > 0 ? (
|
|
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap" }}>
|
|
{compactTags.map((tag) => <Chip key={tag} size="small" label={tag} sx={{ borderRadius: 999 }} />)}
|
|
</Box>
|
|
) : null}
|
|
|
|
<Box>
|
|
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("jobTableOverview")}</Typography>
|
|
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25, whiteSpace: "pre-wrap", textWrap: "pretty" }}>
|
|
{overview || t("jobTableNoSummaryYet")}
|
|
</Typography>
|
|
</Box>
|
|
|
|
{primaryAction ? (
|
|
<Box>
|
|
<Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 700, display: "block", mb: 0.5 }}>
|
|
{t("editJobNextAction")}
|
|
</Typography>
|
|
<Button variant={primaryAction.variant} color={primaryAction.color} onClick={primaryAction.onClick} fullWidth sx={{ minHeight: 42, fontWeight: 700 }}>
|
|
{primaryAction.label}
|
|
</Button>
|
|
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 0.75, textWrap: "pretty" }}>
|
|
{primaryAction.detail}
|
|
</Typography>
|
|
</Box>
|
|
) : null}
|
|
|
|
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: 1 }}>
|
|
<Button variant="outlined" startIcon={<EditOutlinedIcon />} onClick={() => setEditJobId(job.id)} sx={{ minHeight: 42, fontWeight: 700 }}>
|
|
{t("jobTableEdit")}
|
|
</Button>
|
|
<Button variant="outlined" startIcon={<MoreHorizIcon />} onClick={(e) => { setStatusJobId(job.id); setStatusAnchor(e.currentTarget); }} sx={{ minHeight: 42, fontWeight: 700 }}>
|
|
{t("jobTableQuickStatus")}
|
|
</Button>
|
|
<Button variant="outlined" startIcon={<LaunchIcon />} onClick={() => setDetailsJobId(job.id)} sx={{ minHeight: 42, fontWeight: 700 }}>
|
|
{t("jobTableOpen")}
|
|
</Button>
|
|
{(mode === "trash" || (includeDeleted && job.isDeleted)) ? (
|
|
<Button variant="outlined" startIcon={<RestoreFromTrashOutlinedIcon />} onClick={() => void restore(job.id)} sx={{ minHeight: 42, fontWeight: 700 }}>
|
|
{t("jobTableRestore")}
|
|
</Button>
|
|
) : (
|
|
<Button color="error" variant="outlined" startIcon={<DeleteOutlineIcon />} onClick={() => void softDelete(job)} sx={{ minHeight: 42, fontWeight: 700 }}>
|
|
{t("jobTableSoftDelete")}
|
|
</Button>
|
|
)}
|
|
</Box>
|
|
</Stack>
|
|
</Paper>
|
|
);
|
|
})}
|
|
{filteredJobs.length === 0 && !jobsResource.loading && !jobsResource.error ? (
|
|
<EmptyJobsState firstTime={isFirstTimeEmpty} onOpenSettings={() => navigate("/settings")} t={t} />
|
|
) : null}
|
|
</Stack>
|
|
) : (
|
|
<Box sx={{ overflowX: "auto" }}>
|
|
<Table size="small" sx={{ minWidth: 980 }}>
|
|
<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>
|
|
{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, primaryAction, appliedDateLabel, overview, tags, isSelected, isExpanded }) => {
|
|
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 detailTags = tags.slice(0, 8);
|
|
return (
|
|
<React.Fragment key={job.id}>
|
|
<TableRow sx={{ backgroundColor: alpha(tone, theme.palette.mode === "dark" ? 0.1 : 0.06) }}>
|
|
<TableCell padding="checkbox"><Checkbox checked={isSelected} onChange={(e) => toggleSelected(job.id, e.target.checked)} /></TableCell>
|
|
<TableCell><IconButton size="small" onClick={() => toggleExpanded(job.id)}>{isExpanded ? <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={statusLabel(t, job.status)} size="small" color={toneName as any} /></TableCell> : null}
|
|
{columns.dateApplied ? <TableCell>{appliedDateLabel}</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={visibleDesktopColumns}>
|
|
<Collapse in={isExpanded} 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>{formatSalary(job) ?? "-"}</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 }}>{detailTags.length ? detailTags.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" }}>{overview || t("jobTableNoSummaryYet")}</Typography></Box>
|
|
</Box>
|
|
</Collapse>
|
|
</TableCell>
|
|
</TableRow>
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
{filteredJobs.length === 0 && !jobsResource.loading && !jobsResource.error ? (
|
|
<TableRow><TableCell colSpan={visibleDesktopColumns}><EmptyJobsState firstTime={isFirstTimeEmpty} onOpenSettings={() => navigate("/settings")} t={t} /></TableCell></TableRow>
|
|
) : null}
|
|
</TableBody>
|
|
</Table>
|
|
</Box>
|
|
)}
|
|
<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((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>)}
|
|
</Menu>
|
|
</Box>
|
|
);
|
|
}
|