Dashboard upgrades, workflows added and assitant emailer

This commit is contained in:
cesnimda
2026-03-21 13:25:13 +01:00
parent 8cc4b0dfce
commit 51a539068f
9 changed files with 1358 additions and 1421 deletions
+156 -582
View File
@@ -2,14 +2,15 @@ import React, { useEffect, useMemo, useState } from "react";
import {
Box,
Button,
Checkbox,
Chip,
Collapse,
Checkbox,
FormControlLabel,
FormControl,
InputLabel,
FormControlLabel,
IconButton,
InputAdornment,
InputLabel,
Menu,
MenuItem,
Paper,
@@ -25,9 +26,7 @@ import {
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";
@@ -41,7 +40,6 @@ 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";
@@ -54,7 +52,6 @@ interface JobApplication {
dateApplied: string;
daysSince: number;
jobUrl?: string | null;
coverLetterText?: string | null;
notes?: string | null;
location?: string | null;
salary?: string | null;
@@ -93,77 +90,47 @@ interface Props {
}
function normalizeStatus(status: string): string {
if (status === "Interviewing") return "Interview";
return status;
return status === "Interviewing" ? "Interview" : 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 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 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)}`,
}}
/>
);
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) {
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 [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 [columnsAnchor, setColumnsAnchor] = useState<null | HTMLElement>(null);
const [statusFilter, setStatusFilter] = useState("All");
const [locationFilter, setLocationFilter] = useState("");
const debouncedLocation = useDebouncedValue(locationFilter, 250);
@@ -173,43 +140,32 @@ export default function JobTable({
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 [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,
const params = useMemo(() => ({
page: page + 1,
pageSize,
debouncedSearch,
q: debouncedSearch.trim() || undefined,
status: statusFilter !== "All" ? statusFilter : undefined,
companyId: companyFilterId === "All" ? undefined : companyFilterId,
location: debouncedLocation.trim() || undefined,
includeDeleted,
statusFilter,
companyFilterId,
debouncedLocation,
mode,
deletedOnly: mode === "trash" ? true : undefined,
sortBy,
sortDir,
needsFollowUpOnly,
]);
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]);
const requestSort = (key: typeof sortBy) => {
if (sortBy === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
@@ -220,58 +176,24 @@ export default function JobTable({
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 toggleExpanded = (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 selectedAllOnPage = jobs.length > 0 && jobs.every((job) => selectedIds.includes(job.id));
const toggleSelectAll = (checked: boolean) => {
setSelectedIds(checked ? jobs.map((job) => job.id) : []);
};
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 toggleSelected = (id: number, checked: boolean) => {
setSelectedIds((prev) => checked ? [...prev, id] : prev.filter((x) => x !== id));
};
const softDelete = async (id: number) => {
try {
await api.delete(`/jobapplications/${id}`);
toast("Job moved to trash.", "success", {
label: "Undo",
onClick: () => {
void restore(id);
},
});
toast("Job moved to trash.", "success", { label: "Undo", onClick: () => { void restore(id); } });
setReloadToken((t) => t + 1);
} catch {
toast("Failed to delete job.", "error");
@@ -298,504 +220,156 @@ export default function JobTable({
}
};
const runBulkAction = async (action: "delete" | "restore" | "status", value?: string) => {
if (selectedIds.length === 0) 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(`Updated ${selectedIds.length} jobs.`, "success");
setReloadToken((t) => t + 1);
setSelectedIds([]);
} catch {
toast("Bulk action failed.", "error");
}
};
const generateOverview = (job: JobApplication) => {
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;
};
return (
<Box>
<Box
sx={{
display: "flex",
gap: 2,
alignItems: "center",
justifyContent: "space-between",
mt: 2,
flexWrap: "wrap",
}}
>
<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);
}}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
placeholder="Title, company, notes, messages"
size="small"
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" />
</InputAdornment>
),
}}
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>
))}
<InputLabel>Status</InputLabel>
<Select value={statusFilter} label="Status" 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);
}}
>
<InputLabel>Company</InputLabel>
<Select value={companyFilterId} label="Company" onChange={(e) => { setCompanyFilterId(e.target.value as any); setPage(0); }}>
<MenuItem value="All">All</MenuItem>
{companies.map((c) => (
<MenuItem key={c.id} value={c.id}>
{c.name}
</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" }}
/>
<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 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="Needs follow-up" /> : null}
{mode === "jobs" ? <FormControlLabel control={<Checkbox checked={includeDeleted} onChange={(e) => { setIncludeDeleted(e.target.checked); setPage(0); }} />} label="Show deleted" /> : 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="Columns"><IconButton onClick={(e) => setColumnsAnchor(e.currentTarget)}><ViewColumnIcon /></IconButton></Tooltip>
</Box>
</Box>
<Paper sx={{ mt: 0 }}>
{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 }}>{selectedIds.length} selected</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
{mode === "trash" ? <Button variant="outlined" onClick={() => void runBulkAction("restore")}>Restore selected</Button> : <Button variant="outlined" color="error" onClick={() => void runBulkAction("delete")}>Delete selected</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", "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>
<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")}
>
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>
<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> : null}
{columns.dateApplied ? <TableCell sortDirection={sortBy === "dateApplied" ? sortDir : false}><TableSortLabel active={sortBy === "dateApplied"} direction={sortBy === "dateApplied" ? sortDir : "asc"} onClick={() => requestSort("dateApplied")}>Date Applied</TableSortLabel></TableCell> : null}
{columns.daysSince ? <TableCell sortDirection={sortBy === "daysSince" ? sortDir : false}><TableSortLabel active={sortBy === "daysSince"} direction={sortBy === "daysSince" ? sortDir : "asc"} onClick={() => requestSort("daysSince")}>Days</TableSortLabel></TableCell> : null}
{columns.jobUrl ? <TableCell>Job URL</TableCell> : null}
<TableCell 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;
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;
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>
<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>
{job.needsFollowUp ? (
<Chip
size="small"
label="Follow up"
title={job.followUpReason ?? undefined}
sx={{ fontWeight: 800 }}
/>
) : null}
{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>
)}
{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">Link</a> : ""}</TableCell> : null}
<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 sx={{ display: "flex", justifyContent: "flex-end", gap: 0.5 }}>
<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={() => void restore(job.id)}><RestoreFromTrashOutlinedIcon fontSize="small" /></IconButton></Tooltip> : <Tooltip title="Soft delete"><IconButton size="small" onClick={() => void 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>
)}
<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">Location</Typography><Typography>{job.location ?? "-"}</Typography></Box>
<Box><Typography variant="overline">Salary</Typography><Typography>{job.salary ?? "-"}</Typography></Box>
<Box><Typography variant="overline">Job URL</Typography><Typography>{job.jobUrl ? <a href={job.jobUrl} target="_blank" rel="noreferrer">Open listing</a> : "-"}</Typography></Box>
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">Skills</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" }}>No tags</Typography>}</Box></Box>
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">Overview</Typography><Typography sx={{ whiteSpace: "pre-wrap" }}>{generateOverview(job) || "No summary yet."}</Typography></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>
)}
{jobs.length === 0 ? <TableRow><TableCell colSpan={9}><Typography sx={{ py: 2, textAlign: "center" }}>No jobs found.</Typography></TableCell></TableRow> : null}
</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]}
/>
<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}
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>
))}
<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", "Interview", "Offer", "Rejected", "Ghosted"].map((s) => <MenuItem key={s} onClick={() => { if (statusJobId) void setStatusQuick(statusJobId, s); setStatusAnchor(null); setStatusJobId(null); }}>Set {s}</MenuItem>)}
</Menu>
</Box>
);