Optimize workspace and daily-loop surfaces
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import {
|
||||
@@ -61,6 +61,26 @@ interface PagedResult<T> {
|
||||
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;
|
||||
@@ -107,6 +127,13 @@ function statusTone(status: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
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)");
|
||||
@@ -210,7 +237,9 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
return jobs.filter((job) => needsWorkflowWork(job));
|
||||
}, [jobs, readinessFilter]);
|
||||
|
||||
const selectedAllOnPage = filteredJobs.length > 0 && filteredJobs.every((job) => selectedIds.includes(job.id));
|
||||
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) : []);
|
||||
@@ -262,7 +291,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
|
||||
const runBulkAction = async (action: "delete" | "restore" | "status", value?: string) => {
|
||||
if (selectedIds.length === 0) return;
|
||||
const selectedJobs = jobs.filter((job) => selectedIds.includes(job.id));
|
||||
const selectedJobs = jobs.filter((job) => selectedIdSet.has(job.id));
|
||||
if (action === "delete" && !(await confirmDelete(selectedJobs))) return;
|
||||
try {
|
||||
await Promise.all(selectedIds.map((id) => {
|
||||
@@ -278,45 +307,38 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
}
|
||||
};
|
||||
|
||||
const generateOverview = (job: JobApplication) => {
|
||||
if (job.fullSummary) return job.fullSummary;
|
||||
if (job.shortSummary) return job.shortSummary;
|
||||
const src = (job.description || job.notes || "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
||||
return src.length > 220 ? `${src.slice(0, 220)}...` : src;
|
||||
};
|
||||
|
||||
const buildWorkflowActionDetail = (job: JobApplication) => getWorkflowAction(job, {
|
||||
packageWork: t("jobTablePackageWork"),
|
||||
followUp: t("jobTableFollowUp"),
|
||||
interviewPrep: t("jobTableInterviewStage"),
|
||||
readiness: t("jobTableReadiness"),
|
||||
});
|
||||
|
||||
const getActionSignals = (job: JobApplication) => {
|
||||
const action = buildWorkflowActionDetail(job);
|
||||
if (!action || job.isDeleted) return [];
|
||||
|
||||
return [{
|
||||
label: action.label,
|
||||
detail: action.detail,
|
||||
onClick: () => navigate(action.path),
|
||||
variant: action.key === "follow-up" ? "contained" as const : "outlined" as const,
|
||||
color: action.key === "follow-up" ? "warning" as const : "primary" as const,
|
||||
}];
|
||||
};
|
||||
|
||||
const getPrimaryAction = (job: JobApplication) => {
|
||||
const action = buildWorkflowActionDetail(job);
|
||||
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" as const : "outlined" as const,
|
||||
color: action.key === "follow-up" ? "warning" as const : "primary" as const,
|
||||
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: 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);
|
||||
@@ -498,11 +520,8 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
</Box>
|
||||
|
||||
{jobsResource.loading ? <Typography sx={{ py: 2, textAlign: "center" }}>{t("loading")}</Typography> : null}
|
||||
{!jobsResource.loading && !jobsResource.error && filteredJobs.map((job) => {
|
||||
const toneName = statusTone(job.status);
|
||||
const primaryAction = getPrimaryAction(job);
|
||||
const actionSignals = getActionSignals(job);
|
||||
const tags = parseTags(job.tags).slice(0, 6);
|
||||
{!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}
|
||||
@@ -517,7 +536,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
<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={selectedIds.includes(job.id)} onChange={(e) => toggleSelected(job.id, e.target.checked)} sx={{ mt: -0.5, ml: -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")}
|
||||
@@ -550,7 +569,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
{columns.dateApplied ? (
|
||||
<Box>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("jobTableDateApplied")}</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>{new Date(job.dateApplied).toLocaleDateString()}</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>{appliedDateLabel}</Typography>
|
||||
</Box>
|
||||
) : null}
|
||||
{columns.daysSince ? (
|
||||
@@ -576,16 +595,16 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{tags.length > 0 ? (
|
||||
{compactTags.length > 0 ? (
|
||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap" }}>
|
||||
{tags.map((tag) => <Chip key={tag} size="small" label={tag} sx={{ borderRadius: 999 }} />)}
|
||||
{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" }}>
|
||||
{generateOverview(job) || t("jobTableNoSummaryYet")}
|
||||
{overview || t("jobTableNoSummaryYet")}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -647,17 +666,14 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{jobsResource.loading ? <TableRow><TableCell colSpan={visibleDesktopColumns}><Typography sx={{ py: 2, textAlign: "center" }}>{t("loading")}</Typography></TableCell></TableRow> : null}
|
||||
{!jobsResource.loading && !jobsResource.error && filteredJobs.map((job) => {
|
||||
const open = expanded.includes(job.id);
|
||||
const toneName = statusTone(job.status);
|
||||
{!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 primaryAction = getPrimaryAction(job);
|
||||
const actionSignals = getActionSignals(job);
|
||||
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={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 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" }}>
|
||||
@@ -679,7 +695,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
</Box>
|
||||
</TableCell>
|
||||
{columns.status ? <TableCell><Chip label={normalizeStatus(job.status)} size="small" color={toneName as any} /></TableCell> : null}
|
||||
{columns.dateApplied ? <TableCell>{new Date(job.dateApplied).toLocaleDateString()}</TableCell> : null}
|
||||
{columns.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">
|
||||
@@ -708,13 +724,13 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell sx={{ py: 0 }} colSpan={visibleDesktopColumns}>
|
||||
<Collapse in={open} timeout="auto" unmountOnExit>
|
||||
<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>{job.salary ?? "-"}</Typography></Box>
|
||||
<Box><Typography variant="overline">{t("settingsColumnJobUrl")}</Typography><Typography>{job.jobUrl ? <a href={job.jobUrl} target="_blank" rel="noreferrer">{t("jobTableOpenListing")}</a> : "-"}</Typography></Box>
|
||||
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">{t("jobTableSkills")}</Typography><Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.5 }}>{parseTags(job.tags).length ? parseTags(job.tags).slice(0, 8).map((tag) => <Chip key={tag} label={tag} size="small" />) : <Typography sx={{ color: "text.secondary" }}>{t("jobTableNoTags")}</Typography>}</Box></Box>
|
||||
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">{t("jobTableOverview")}</Typography><Typography sx={{ whiteSpace: "pre-wrap" }}>{generateOverview(job) || t("jobTableNoSummaryYet")}</Typography></Box>
|
||||
<Box 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>
|
||||
|
||||
Reference in New Issue
Block a user