First Commit
This commit is contained in:
@@ -0,0 +1,802 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Chip,
|
||||
Collapse,
|
||||
Checkbox,
|
||||
FormControlLabel,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
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";
|
||||
|
||||
interface JobApplication {
|
||||
id: number;
|
||||
jobTitle: string;
|
||||
status: string;
|
||||
dateApplied: string;
|
||||
daysSince: number;
|
||||
jobUrl?: string | null;
|
||||
coverLetterText?: string | null;
|
||||
notes?: string | null;
|
||||
location?: string | null;
|
||||
salary?: string | null;
|
||||
tags?: string | null;
|
||||
description?: string | null;
|
||||
isDeleted?: boolean;
|
||||
company: { name: string };
|
||||
companyId?: number;
|
||||
needsFollowUp?: boolean;
|
||||
followUpReason?: string | null;
|
||||
shortSummary?: string | null;
|
||||
fullSummary?: string | null;
|
||||
}
|
||||
|
||||
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 {
|
||||
if (status === "Interviewing") return "Interview";
|
||||
return status;
|
||||
}
|
||||
|
||||
function statusTone(status: string): "applied" | "waiting" | "interview" | "offer" | "rejected" | "ghosted" {
|
||||
switch (normalizeStatus(status)) {
|
||||
case "Offer":
|
||||
return "offer";
|
||||
case "Rejected":
|
||||
return "rejected";
|
||||
case "Waiting":
|
||||
return "waiting";
|
||||
case "Ghosted":
|
||||
return "ghosted";
|
||||
case "Interview":
|
||||
return "interview";
|
||||
case "Applied":
|
||||
default:
|
||||
return "applied";
|
||||
}
|
||||
}
|
||||
|
||||
function StatusChip({ status }: { status: string }) {
|
||||
const theme = useTheme();
|
||||
const tone = statusTone(status);
|
||||
|
||||
const c =
|
||||
tone === "rejected"
|
||||
? theme.palette.error.main
|
||||
: tone === "waiting" || tone === "ghosted"
|
||||
? theme.palette.warning.main
|
||||
: tone === "offer"
|
||||
? theme.palette.success.main
|
||||
: theme.palette.primary.main;
|
||||
|
||||
return (
|
||||
<Chip
|
||||
label={normalizeStatus(status)}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 24,
|
||||
fontWeight: 800,
|
||||
letterSpacing: "0.01em",
|
||||
color: alpha(c, theme.palette.mode === "dark" ? 0.95 : 0.92),
|
||||
backgroundColor: alpha(c, theme.palette.mode === "dark" ? 0.16 : 0.12),
|
||||
border: `1px solid ${alpha(c, theme.palette.mode === "dark" ? 0.35 : 0.22)}`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function JobTable({
|
||||
refreshToken,
|
||||
pageSize,
|
||||
onPageSizeChange,
|
||||
columns,
|
||||
onColumnsChange,
|
||||
mode = "jobs",
|
||||
}: Props) {
|
||||
const theme = useTheme();
|
||||
const { toast } = useToast();
|
||||
const [jobs, setJobs] = useState<JobApplication[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(0); // 0-based for TablePagination
|
||||
const [expanded, setExpanded] = 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 { companies } = useCompanies();
|
||||
const [companyFilterId, setCompanyFilterId] = useState<number | "All">("All");
|
||||
const [detailsJobId, setDetailsJobId] = useState<number | null>(null);
|
||||
const [editJobId, setEditJobId] = useState<number | null>(null);
|
||||
const [reloadToken, setReloadToken] = useState(0);
|
||||
const [fullSummaries, setFullSummaries] = useState<Record<number, string | null | undefined>>({});
|
||||
const [loadingFull, setLoadingFull] = useState<Record<number, boolean>>({});
|
||||
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(() => {
|
||||
const q = debouncedSearch.trim();
|
||||
return {
|
||||
page: page + 1,
|
||||
pageSize,
|
||||
q: q.length ? q : undefined,
|
||||
status: statusFilter !== "All" ? statusFilter : undefined,
|
||||
companyId: companyFilterId === "All" ? undefined : companyFilterId,
|
||||
location: debouncedLocation.trim().length ? debouncedLocation.trim() : undefined,
|
||||
includeDeleted,
|
||||
deletedOnly: mode === "trash" ? true : undefined,
|
||||
sortBy,
|
||||
sortDir,
|
||||
needsFollowUp: needsFollowUpOnly ? true : undefined,
|
||||
};
|
||||
}, [
|
||||
page,
|
||||
pageSize,
|
||||
debouncedSearch,
|
||||
includeDeleted,
|
||||
statusFilter,
|
||||
companyFilterId,
|
||||
debouncedLocation,
|
||||
mode,
|
||||
sortBy,
|
||||
sortDir,
|
||||
needsFollowUpOnly,
|
||||
]);
|
||||
|
||||
const requestSort = (key: typeof sortBy) => {
|
||||
if (sortBy === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
else {
|
||||
setSortBy(key);
|
||||
setSortDir("asc");
|
||||
}
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get<PagedResult<JobApplication>>("/jobapplications", { params })
|
||||
.then((r) => {
|
||||
setJobs(r.data.items);
|
||||
setTotal(r.data.total);
|
||||
});
|
||||
}, [params, refreshToken, reloadToken]);
|
||||
|
||||
const toggle = (id: number) => {
|
||||
setExpanded((prev) =>
|
||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
|
||||
);
|
||||
};
|
||||
|
||||
const loadFullSummary = async (id: number) => {
|
||||
if (fullSummaries[id] !== undefined) {
|
||||
// already loaded (could be null meaning not available)
|
||||
return;
|
||||
}
|
||||
setLoadingFull((s) => ({ ...s, [id]: true }));
|
||||
try {
|
||||
const res = await api.get<JobApplication>(`/jobapplications/${id}`);
|
||||
setFullSummaries((s) => ({ ...s, [id]: res.data.fullSummary ?? res.data.shortSummary ?? null }));
|
||||
// also merge shortSummary into jobs array for lists
|
||||
setJobs((prev) => prev.map((j) => (j.id === id ? { ...j, shortSummary: res.data.shortSummary ?? j.shortSummary } : j)));
|
||||
} catch {
|
||||
setFullSummaries((s) => ({ ...s, [id]: null }));
|
||||
} finally {
|
||||
setLoadingFull((s) => ({ ...s, [id]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const generateOverview = (job: JobApplication) => {
|
||||
const src = (job.description || job.notes || "").replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
|
||||
if (!src) return "";
|
||||
// Try to take up to two short sentences, fallback to truncated text.
|
||||
const sentences = src.split(/(?<=[.!?])\s+/);
|
||||
const first = sentences.slice(0, 2).join(" ");
|
||||
const out = first.length >= 100 ? (first.length > 250 ? first.slice(0, 250) + "…" : first) : (src.length > 250 ? src.slice(0, 250) + "…" : src);
|
||||
return out;
|
||||
};
|
||||
|
||||
const softDelete = async (id: number) => {
|
||||
try {
|
||||
await api.delete(`/jobapplications/${id}`);
|
||||
toast("Job moved to trash.", "success", {
|
||||
label: "Undo",
|
||||
onClick: () => {
|
||||
void restore(id);
|
||||
},
|
||||
});
|
||||
setReloadToken((t) => t + 1);
|
||||
} catch {
|
||||
toast("Failed to delete job.", "error");
|
||||
}
|
||||
};
|
||||
|
||||
const restore = async (id: number) => {
|
||||
try {
|
||||
await api.post(`/jobapplications/${id}/restore`);
|
||||
toast("Job restored.", "success");
|
||||
setReloadToken((t) => t + 1);
|
||||
} catch {
|
||||
toast("Failed to restore job.", "error");
|
||||
}
|
||||
};
|
||||
|
||||
const setStatusQuick = async (id: number, status: string) => {
|
||||
try {
|
||||
await api.patch(`/jobapplications/${id}/status`, { status });
|
||||
toast(`Status set to ${status}.`, "success");
|
||||
setReloadToken((t) => t + 1);
|
||||
} catch {
|
||||
toast("Failed to update status.", "error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
gap: 2,
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
mt: 2,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
label="Search"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
placeholder="Title, company, notes, messages"
|
||||
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 id="status-filter-label">Status</InputLabel>
|
||||
<Select
|
||||
labelId="status-filter-label"
|
||||
label="Status"
|
||||
value={statusFilter}
|
||||
onChange={(e) => {
|
||||
setStatusFilter(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
>
|
||||
{["All", "Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"].map((s) => (
|
||||
<MenuItem key={s} value={s}>
|
||||
{s}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl sx={{ minWidth: 220 }} size="small">
|
||||
<InputLabel id="company-filter-label">Company</InputLabel>
|
||||
<Select
|
||||
labelId="company-filter-label"
|
||||
label="Company"
|
||||
value={companyFilterId}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value as unknown as number | "All";
|
||||
setCompanyFilterId(v);
|
||||
setPage(0);
|
||||
}}
|
||||
>
|
||||
<MenuItem value="All">All</MenuItem>
|
||||
{companies.map((c) => (
|
||||
<MenuItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
label="Location"
|
||||
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 }}>
|
||||
{mode === "jobs" && (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={needsFollowUpOnly}
|
||||
onChange={(e) => {
|
||||
setNeedsFollowUpOnly(e.target.checked);
|
||||
setPage(0);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label="Needs follow-up"
|
||||
/>
|
||||
)}
|
||||
|
||||
{mode === "jobs" && (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={includeDeleted}
|
||||
onChange={(e) => {
|
||||
setIncludeDeleted(e.target.checked);
|
||||
setPage(0);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label="Show deleted"
|
||||
/>
|
||||
)}
|
||||
|
||||
<SavedViewsMenu
|
||||
current={{
|
||||
q: search.trim().length ? search.trim() : undefined,
|
||||
status: statusFilter !== "All" ? statusFilter : undefined,
|
||||
companyId: companyFilterId === "All" ? undefined : (companyFilterId as number),
|
||||
location: locationFilter.trim().length ? 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="Columns">
|
||||
<IconButton onClick={(e) => setColumnsAnchor(e.currentTarget)}>
|
||||
<ViewColumnIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Menu
|
||||
anchorEl={columnsAnchor}
|
||||
open={Boolean(columnsAnchor)}
|
||||
onClose={() => setColumnsAnchor(null)}
|
||||
>
|
||||
{(
|
||||
[
|
||||
["status", "Status"],
|
||||
["dateApplied", "Date applied"],
|
||||
["daysSince", "Days"],
|
||||
["jobUrl", "Job URL"],
|
||||
] as const
|
||||
).map(([key, label]) => (
|
||||
<MenuItem
|
||||
key={key}
|
||||
onClick={() =>
|
||||
onColumnsChange({ ...columns, [key]: !columns[key] })
|
||||
}
|
||||
>
|
||||
<Checkbox checked={columns[key]} />
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Paper sx={{ mt: 0 }}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell width={1} />
|
||||
<TableCell sortDirection={sortBy === "company" ? sortDir : false}>
|
||||
<TableSortLabel
|
||||
active={sortBy === "company"}
|
||||
direction={sortBy === "company" ? sortDir : "asc"}
|
||||
onClick={() => requestSort("company")}
|
||||
>
|
||||
Company
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
<TableCell sortDirection={sortBy === "jobTitle" ? sortDir : false}>
|
||||
<TableSortLabel
|
||||
active={sortBy === "jobTitle"}
|
||||
direction={sortBy === "jobTitle" ? sortDir : "asc"}
|
||||
onClick={() => requestSort("jobTitle")}
|
||||
>
|
||||
Role
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
{columns.status && (
|
||||
<TableCell sortDirection={sortBy === "status" ? sortDir : false}>
|
||||
<TableSortLabel
|
||||
active={sortBy === "status"}
|
||||
direction={sortBy === "status" ? sortDir : "asc"}
|
||||
onClick={() => requestSort("status")}
|
||||
>
|
||||
Status
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
)}
|
||||
{columns.dateApplied && (
|
||||
<TableCell sortDirection={sortBy === "dateApplied" ? sortDir : false}>
|
||||
<TableSortLabel
|
||||
active={sortBy === "dateApplied"}
|
||||
direction={sortBy === "dateApplied" ? sortDir : "asc"}
|
||||
onClick={() => requestSort("dateApplied")}
|
||||
>
|
||||
Date Applied
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
)}
|
||||
{columns.daysSince && (
|
||||
<TableCell sortDirection={sortBy === "daysSince" ? sortDir : false}>
|
||||
<TableSortLabel
|
||||
active={sortBy === "daysSince"}
|
||||
direction={sortBy === "daysSince" ? sortDir : "asc"}
|
||||
onClick={() => requestSort("daysSince")}
|
||||
>
|
||||
Days
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
)}
|
||||
{columns.jobUrl && <TableCell>Job URL</TableCell>}
|
||||
<TableCell width={1} align="right">
|
||||
Actions
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
|
||||
<TableBody>
|
||||
{jobs.map((job) => {
|
||||
const open = expanded.includes(job.id);
|
||||
const tone = statusTone(job.status);
|
||||
const stripe =
|
||||
tone === "rejected"
|
||||
? theme.palette.error.main
|
||||
: tone === "waiting" || tone === "ghosted"
|
||||
? theme.palette.warning.main
|
||||
: tone === "offer"
|
||||
? theme.palette.success.main
|
||||
: theme.palette.primary.main;
|
||||
const bg = alpha(stripe, theme.palette.mode === "dark" ? 0.10 : 0.06);
|
||||
const fg = theme.palette.text.primary;
|
||||
return (
|
||||
<React.Fragment key={job.id}>
|
||||
<TableRow
|
||||
sx={{
|
||||
backgroundColor: bg,
|
||||
color: fg,
|
||||
"& a": { color: fg },
|
||||
"& td": { borderBottomColor: alpha(stripe, 0.12) },
|
||||
}}
|
||||
>
|
||||
<TableCell>
|
||||
<IconButton size="small" onClick={() => toggle(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>
|
||||
{job.needsFollowUp ? (
|
||||
<Chip
|
||||
size="small"
|
||||
label="Follow up"
|
||||
title={job.followUpReason ?? undefined}
|
||||
sx={{ fontWeight: 800 }}
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
</TableCell>
|
||||
{columns.status && (
|
||||
<TableCell>
|
||||
<StatusChip status={job.status} />
|
||||
</TableCell>
|
||||
)}
|
||||
{columns.dateApplied && (
|
||||
<TableCell>
|
||||
{new Date(job.dateApplied).toLocaleDateString()}
|
||||
</TableCell>
|
||||
)}
|
||||
{columns.daysSince && <TableCell>{job.daysSince}</TableCell>}
|
||||
{columns.jobUrl && (
|
||||
<TableCell>
|
||||
{job.jobUrl ? (
|
||||
<a href={job.jobUrl} target="_blank" rel="noreferrer">
|
||||
Link
|
||||
</a>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
<TableCell align="right">
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
alignItems: "center",
|
||||
gap: 0.5,
|
||||
flexWrap: "nowrap",
|
||||
}}
|
||||
>
|
||||
<Tooltip title="Edit">
|
||||
<IconButton size="small" onClick={() => setEditJobId(job.id)}>
|
||||
<EditOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Quick status">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
setStatusJobId(job.id);
|
||||
setStatusAnchor(e.currentTarget);
|
||||
}}
|
||||
>
|
||||
<MoreHorizIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Open">
|
||||
<IconButton size="small" onClick={() => setDetailsJobId(job.id)}>
|
||||
<LaunchIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{(mode === "trash" || (includeDeleted && job.isDeleted)) ? (
|
||||
<Tooltip title="Restore">
|
||||
<IconButton size="small" onClick={() => restore(job.id)}>
|
||||
<RestoreFromTrashOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title="Soft delete">
|
||||
<IconButton size="small" onClick={() => softDelete(job.id)}>
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{open && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} sx={{ py: 0 }}>
|
||||
<Collapse in={open} timeout="auto" unmountOnExit>
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr 1fr",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="overline">Location</Typography>
|
||||
<Typography>{job.location ?? ""}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="overline">Skills</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.5 }}>
|
||||
{(() => {
|
||||
try {
|
||||
const parsed = job.tags ? JSON.parse(job.tags) : [];
|
||||
return Array.isArray(parsed) ? parsed.filter((x: any) => typeof x === "string").slice(0, 8).map((t: string) => (
|
||||
<Chip key={t} label={t} size="small" />
|
||||
)) : <Typography sx={{ color: "text.secondary" }}>—</Typography>;
|
||||
} catch {
|
||||
return <Typography sx={{ color: "text.secondary" }}>—</Typography>;
|
||||
}
|
||||
})()}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="overline">Job URL</Typography>
|
||||
<Typography>
|
||||
{job.jobUrl ? (
|
||||
<a href={job.jobUrl} target="_blank" rel="noreferrer">
|
||||
Link
|
||||
</a>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</Typography>
|
||||
</Box>
|
||||
{/* Deleted flag removed from expanded view as requested */}
|
||||
|
||||
<Box sx={{ gridColumn: "1 / -1" }}>
|
||||
<Typography variant="overline">Overview</Typography>
|
||||
<Typography sx={{ whiteSpace: "pre-wrap" }}>
|
||||
{fullSummaries[job.id] ?? job.shortSummary ?? generateOverview(job)}
|
||||
</Typography>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
{fullSummaries[job.id] ? (
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
color: theme.palette.primary.main,
|
||||
fontWeight: 800,
|
||||
}}
|
||||
onClick={() => setFullSummaries((s) => ({ ...s, [job.id]: undefined }))}
|
||||
>
|
||||
Show less
|
||||
</button>
|
||||
) : (
|
||||
(job.shortSummary || job.description || job.notes) && (
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
color: theme.palette.primary.main,
|
||||
fontWeight: 800,
|
||||
}}
|
||||
onClick={() => void loadFullSummary(job.id)}
|
||||
>
|
||||
{loadingFull[job.id] ? "Loading…" : "Show more"}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
{jobs.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8}>
|
||||
<Typography sx={{ py: 2, textAlign: "center" }}>
|
||||
No jobs found.
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={total}
|
||||
page={page}
|
||||
onPageChange={(_, next) => setPage(next)}
|
||||
rowsPerPage={pageSize}
|
||||
onRowsPerPageChange={(e) => {
|
||||
const next = Number(e.target.value) as 15 | 20 | 25;
|
||||
onPageSizeChange(next);
|
||||
setPage(0);
|
||||
}}
|
||||
rowsPerPageOptions={[15, 20, 25]}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<JobDetailsDialog
|
||||
open={detailsJobId !== null}
|
||||
jobId={detailsJobId}
|
||||
onClose={() => setDetailsJobId(null)}
|
||||
/>
|
||||
|
||||
<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", "Rejected", "Ghosted"].map((s) => (
|
||||
<MenuItem
|
||||
key={s}
|
||||
onClick={() => {
|
||||
if (statusJobId) void setStatusQuick(statusJobId, s);
|
||||
setStatusAnchor(null);
|
||||
setStatusJobId(null);
|
||||
}}
|
||||
>
|
||||
Set {s}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user