Polish mobile layout and add collapsible sidebar
This commit is contained in:
@@ -8,16 +8,19 @@ import {
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
IconButton,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Typography,
|
||||
IconButton,
|
||||
} from "@mui/material";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { Company } from "../types";
|
||||
@@ -26,6 +29,7 @@ import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
export default function CompaniesTable() {
|
||||
const isMobile = useMediaQuery("(max-width:767.95px)");
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const location = useLocation();
|
||||
@@ -93,60 +97,101 @@ export default function CompaniesTable() {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper sx={{ mt: 0 }}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t("companiesName")}</TableCell>
|
||||
<TableCell>{t("companiesLocation")}</TableCell>
|
||||
<TableCell>{t("companiesSource")}</TableCell>
|
||||
<TableCell>{t("companiesPipeline")}</TableCell>
|
||||
<TableCell>{t("companiesRecruiter")}</TableCell>
|
||||
<TableCell>{t("companiesNextContact")}</TableCell>
|
||||
<TableCell width={1} align="right" />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{companies.map((c) => (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell>{c.name}</TableCell>
|
||||
<TableCell>{c.location ?? ""}</TableCell>
|
||||
<TableCell>{c.source ?? ""}</TableCell>
|
||||
<TableCell>{c.pipelineStage ?? ""}</TableCell>
|
||||
<TableCell>
|
||||
{c.recruiterName ?? ""}
|
||||
{c.recruiterEmail ? ` (${c.recruiterEmail})` : ""}
|
||||
</TableCell>
|
||||
<TableCell>{c.nextContactAt ? new Date(c.nextContactAt).toLocaleDateString() : ""}</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton size="small" onClick={() => openEdit(c)}>
|
||||
<EditOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{companies.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7}>
|
||||
<Typography sx={{ py: 2, textAlign: "center" }}>
|
||||
{t("companiesEmpty")}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
const renderCompanyMeta = (label: string, value?: string | null) => (
|
||||
<Box>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>{label}</Typography>
|
||||
<Typography variant="body2" sx={{ overflowWrap: "anywhere" }}>{value || "—"}</Typography>
|
||||
</Box>
|
||||
);
|
||||
|
||||
<Dialog open={editOpen} onClose={() => setEditOpen(false)} fullWidth maxWidth="sm">
|
||||
return (
|
||||
<Paper sx={{ mt: 0, p: { xs: 1.5, sm: 0 } }}>
|
||||
{isMobile ? (
|
||||
<Stack spacing={1.5}>
|
||||
{companies.map((c) => (
|
||||
<Paper key={c.id} sx={{ p: 1.5, borderRadius: 3 }}>
|
||||
<Stack spacing={1.25}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 1 }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 900, overflowWrap: "anywhere" }}>{c.name}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>{c.location || t("companiesLocation")}</Typography>
|
||||
</Box>
|
||||
<IconButton size="small" onClick={() => openEdit(c)}>
|
||||
<EditOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1.25 }}>
|
||||
{renderCompanyMeta(t("companiesSource"), c.source)}
|
||||
{renderCompanyMeta(t("companiesPipeline"), c.pipelineStage)}
|
||||
{renderCompanyMeta(t("companiesRecruiter"), [c.recruiterName, c.recruiterEmail].filter(Boolean).join(" · "))}
|
||||
{renderCompanyMeta(t("companiesNextContact"), c.nextContactAt ? new Date(c.nextContactAt).toLocaleDateString() : null)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
{companies.length === 0 ? (
|
||||
<Typography sx={{ py: 2, textAlign: "center" }}>
|
||||
{t("companiesEmpty")}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Stack>
|
||||
) : (
|
||||
<TableContainer sx={{ borderRadius: 3, border: "1px solid", borderColor: "divider" }}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t("companiesName")}</TableCell>
|
||||
<TableCell>{t("companiesLocation")}</TableCell>
|
||||
<TableCell>{t("companiesSource")}</TableCell>
|
||||
<TableCell>{t("companiesPipeline")}</TableCell>
|
||||
<TableCell>{t("companiesRecruiter")}</TableCell>
|
||||
<TableCell>{t("companiesNextContact")}</TableCell>
|
||||
<TableCell width={1} align="right" />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{companies.map((c) => (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell>{c.name}</TableCell>
|
||||
<TableCell>{c.location ?? ""}</TableCell>
|
||||
<TableCell>{c.source ?? ""}</TableCell>
|
||||
<TableCell>{c.pipelineStage ?? ""}</TableCell>
|
||||
<TableCell>
|
||||
{c.recruiterName ?? ""}
|
||||
{c.recruiterEmail ? ` (${c.recruiterEmail})` : ""}
|
||||
</TableCell>
|
||||
<TableCell>{c.nextContactAt ? new Date(c.nextContactAt).toLocaleDateString() : ""}</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton size="small" onClick={() => openEdit(c)}>
|
||||
<EditOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{companies.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7}>
|
||||
<Typography sx={{ py: 2, textAlign: "center" }}>
|
||||
{t("companiesEmpty")}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
<Dialog open={editOpen} onClose={() => setEditOpen(false)} fullWidth fullScreen={isMobile} maxWidth="sm">
|
||||
<DialogTitle>{t("companiesEdit")}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 2, mt: 1 }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2, mt: 1 }}>
|
||||
<TextField
|
||||
label={t("companiesName")}
|
||||
value={editing?.name ?? ""}
|
||||
onChange={(e) => setEditing((p) => (p ? { ...p, name: e.target.value } : p))}
|
||||
sx={{ gridColumn: "1 / -1" }}
|
||||
sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }}
|
||||
/>
|
||||
<TextField
|
||||
label={t("companiesLocation")}
|
||||
@@ -178,7 +223,7 @@ export default function CompaniesTable() {
|
||||
label={t("companiesRecruiterLinkedIn")}
|
||||
value={recruiterLinkedIn}
|
||||
onChange={(e) => setRecruiterLinkedIn(e.target.value)}
|
||||
sx={{ gridColumn: "1 / -1" }}
|
||||
sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
@@ -197,9 +242,9 @@ export default function CompaniesTable() {
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setEditOpen(false)}>{t("cancel")}</Button>
|
||||
<Button variant="contained" onClick={save} disabled={!canSave}>
|
||||
<DialogActions sx={{ px: 3, pb: 3, flexDirection: { xs: "column-reverse", sm: "row" }, gap: 1 }}>
|
||||
<Button onClick={() => setEditOpen(false)} fullWidth={isMobile}>{t("cancel")}</Button>
|
||||
<Button variant="contained" onClick={save} disabled={!canSave} fullWidth={isMobile}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Stack,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import { alpha, useTheme } from "@mui/material/styles";
|
||||
import TuneIcon from "@mui/icons-material/Tune";
|
||||
import TrendingUpIcon from "@mui/icons-material/TrendingUp";
|
||||
@@ -110,7 +111,7 @@ function SectionCard({ children, sx = {} }: { children: React.ReactNode; sx?: an
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2.25,
|
||||
p: { xs: 1.5, sm: 2.25 },
|
||||
borderRadius: 4,
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
@@ -126,6 +127,7 @@ function SectionCard({ children, sx = {} }: { children: React.ReactNode; sx?: an
|
||||
|
||||
export default function DashboardView() {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery("(max-width:767.95px)");
|
||||
const navigate = useNavigate();
|
||||
const { t } = useI18n();
|
||||
const [stats, setStats] = useState<JobStats | null>(null);
|
||||
@@ -153,8 +155,8 @@ export default function DashboardView() {
|
||||
|
||||
const appliedValues = analytics.map((x) => x.applied);
|
||||
const responseValues = analytics.map((x) => x.responses);
|
||||
const chartWidth = 860;
|
||||
const chartHeight = 250;
|
||||
const chartWidth = isMobile ? Math.max(420, analytics.length * 70) : 860;
|
||||
const chartHeight = isMobile ? 210 : 250;
|
||||
const appliedPath = buildLinePath(appliedValues, chartWidth, chartHeight);
|
||||
const responsePath = buildLinePath(responseValues, chartWidth, chartHeight);
|
||||
const tagColors = [theme.palette.primary.main, theme.palette.success.main, theme.palette.warning.main, theme.palette.info.main, theme.palette.error.main];
|
||||
@@ -245,7 +247,7 @@ export default function DashboardView() {
|
||||
<Typography variant="overline" sx={{ color: theme.palette.primary.main, fontWeight: 800 }}>
|
||||
{t("dashboardHeroLabel")}
|
||||
</Typography>
|
||||
<Typography variant="h4" sx={{ fontWeight: 950, mt: 0.5, letterSpacing: -0.6, color: "text.primary" }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 950, mt: 0.5, letterSpacing: -0.6, color: "text.primary", overflowWrap: "anywhere" }}>
|
||||
{t("dashboardOverviewTitle")}
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ color: "text.secondary", mt: 1.25, maxWidth: 680 }}>
|
||||
@@ -259,7 +261,18 @@ export default function DashboardView() {
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
gap: 1,
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
width: { xs: "100%", sm: "auto" },
|
||||
'& .MuiButton-root': {
|
||||
flex: { xs: '1 1 calc(50% - 8px)', sm: '0 0 auto' },
|
||||
},
|
||||
}}
|
||||
>
|
||||
{([6, 12, 24] as const).map((m) => (
|
||||
<Button key={m} size="small" variant={months === m ? "contained" : "outlined"} onClick={() => setMonths(m)}>
|
||||
{t("dashboardMonthsShort", { count: m })}
|
||||
@@ -300,7 +313,7 @@ export default function DashboardView() {
|
||||
{card.icon}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
<Box sx={{ mt: 1.5, overflowX: "auto" }}>
|
||||
<MiniSpark values={card.spark.length ? card.spark : [0, 0, 0]} color={alpha(card.tone, 0.95)} />
|
||||
</Box>
|
||||
</SectionCard>
|
||||
@@ -322,7 +335,7 @@ export default function DashboardView() {
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 2, overflowX: "auto" }}>
|
||||
<Box sx={{ mt: 2, overflowX: "auto", mx: isMobile ? -0.5 : 0, px: isMobile ? 0.5 : 0 }}>
|
||||
<Box sx={{ minWidth: chartWidth }}>
|
||||
<svg width={chartWidth} height={chartHeight} viewBox={`0 0 ${chartWidth} ${chartHeight}`}>
|
||||
{[0.2, 0.4, 0.6, 0.8].map((tick) => (
|
||||
@@ -359,7 +372,7 @@ export default function DashboardView() {
|
||||
const width = funnelMax ? clamp((item.count / funnelMax) * 100, 0, 100) : 0;
|
||||
return (
|
||||
<Box key={item.label}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 0.5 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 0.5, gap: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{item.label}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{item.count}</Typography>
|
||||
</Box>
|
||||
@@ -402,16 +415,17 @@ export default function DashboardView() {
|
||||
{priorityJobs.map((job) => {
|
||||
const action = getReminderAction(job);
|
||||
return (
|
||||
<Box key={job.id} sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: alpha(theme.palette.primary.main, 0.03), display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 900 }}>{job.company?.name ?? t("jobTableCompany")} • {job.jobTitle}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{action?.detail ?? job.workflowSignal?.reason ?? job.followUpReason ?? t("remindersFollowUpLabel")}</Typography>
|
||||
<Box key={job.id} sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: alpha(theme.palette.primary.main, 0.03), display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography sx={{ fontWeight: 900, overflowWrap: "anywhere" }}>{job.company?.name ?? t("jobTableCompany")} • {job.jobTitle}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>{action?.detail ?? job.workflowSignal?.reason ?? job.followUpReason ?? t("remindersFollowUpLabel")}</Typography>
|
||||
</Box>
|
||||
<Button variant="outlined" onClick={() => openReminderJob(job)} sx={{ width: { xs: "100%", sm: "auto" } }}>
|
||||
{action?.label ?? t("remindersOpen")}
|
||||
</Button>
|
||||
</Box>
|
||||
<Button variant="outlined" onClick={() => openReminderJob(job)}>
|
||||
{action?.label ?? t("remindersOpen")}
|
||||
</Button>
|
||||
</Box>
|
||||
)})}
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
@@ -426,9 +440,9 @@ export default function DashboardView() {
|
||||
{(overview?.topCompanies ?? []).map((item, index) => (
|
||||
<Box key={item.companyId} sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: alpha(theme.palette.primary.main, index === 0 ? 0.05 : 0.02) }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 900 }}>{item.company}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("dashboardCompanyJobsResponses", { jobs: item.count, responses: item.responses })}</Typography>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography sx={{ fontWeight: 900, overflowWrap: "anywhere" }}>{item.company}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>{t("dashboardCompanyJobsResponses", { jobs: item.count, responses: item.responses })}</Typography>
|
||||
</Box>
|
||||
<Chip label={`${item.responseRate}%`} color={item.responseRate >= 50 ? "success" : item.responseRate >= 25 ? "warning" : "default"} variant="outlined" />
|
||||
</Box>
|
||||
@@ -451,7 +465,7 @@ export default function DashboardView() {
|
||||
return (
|
||||
<Box key={tag.tag}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, mb: 0.5 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 800 }}>{tag.tag}</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 800, overflowWrap: "anywhere" }}>{tag.tag}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{tag.count}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ height: 10, borderRadius: 999, bgcolor: alpha(theme.palette.text.primary, 0.08), overflow: "hidden" }}>
|
||||
@@ -470,8 +484,8 @@ export default function DashboardView() {
|
||||
<Stack spacing={1.1}>
|
||||
{tagTrends.series.map((series, index) => (
|
||||
<Box key={series.tag}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 0.5 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 800 }}>{series.tag}</Typography>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 0.5, gap: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 800, overflowWrap: "anywhere" }}>{series.tag}</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>{series.counts.reduce((sum, value) => sum + value, 0)} total</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: `repeat(${series.counts.length}, 1fr)`, gap: 0.5 }}>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
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";
|
||||
@@ -105,6 +107,7 @@ function statusTone(status: string): string {
|
||||
|
||||
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();
|
||||
@@ -217,7 +220,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
try {
|
||||
await api.delete(`/jobapplications/${job.id}`);
|
||||
toast(t("jobTableMovedToTrash"), "success", { label: "Undo", onClick: () => { void restore(job.id); } });
|
||||
setReloadToken((t) => t + 1);
|
||||
setReloadToken((token) => token + 1);
|
||||
} catch {
|
||||
toast(t("jobTableDeleteFailed"), "error");
|
||||
}
|
||||
@@ -227,7 +230,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
try {
|
||||
await api.post(`/jobapplications/${id}/restore`);
|
||||
toast(t("jobTableRestored"), "success");
|
||||
setReloadToken((t) => t + 1);
|
||||
setReloadToken((token) => token + 1);
|
||||
} catch {
|
||||
toast(t("jobTableRestoreFailed"), "error");
|
||||
}
|
||||
@@ -237,7 +240,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
try {
|
||||
await api.patch(`/jobapplications/${id}/status`, { status });
|
||||
toast(t("jobTableStatusSet", { status }), "success");
|
||||
setReloadToken((t) => t + 1);
|
||||
setReloadToken((token) => token + 1);
|
||||
} catch {
|
||||
toast(t("jobTableStatusUpdateFailed"), "error");
|
||||
}
|
||||
@@ -254,7 +257,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
return api.patch(`/jobapplications/${id}/status`, { status: value });
|
||||
}));
|
||||
toast(t("jobTableUpdatedJobs", { count: selectedIds.length }), "success");
|
||||
setReloadToken((t) => t + 1);
|
||||
setReloadToken((token) => token + 1);
|
||||
setSelectedIds([]);
|
||||
} catch {
|
||||
toast(t("jobTableBulkActionFailed"), "error");
|
||||
@@ -301,52 +304,149 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
};
|
||||
};
|
||||
|
||||
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>
|
||||
<Box sx={{ display: "flex", gap: 2, alignItems: "center", justifyContent: "space-between", mt: 2, flexWrap: "wrap" }}>
|
||||
<TextField label={t("jobTableSearch")} value={search} onChange={(e) => { setSearch(e.target.value); setPage(0); }} placeholder={t("jobTableSearchPlaceholder")} size="small" InputProps={{ startAdornment: <InputAdornment position="start"><SearchIcon fontSize="small" /></InputAdornment> }} sx={{ minWidth: 320, flex: "1 1 320px" }} />
|
||||
{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
|
||||
/>
|
||||
|
||||
<FormControl sx={{ minWidth: 160 }} size="small">
|
||||
<InputLabel>{t("jobTableStatus")}</InputLabel>
|
||||
<Select value={statusFilter} label={t("jobTableStatus")} onChange={(e) => { setStatusFilter(e.target.value); setPage(0); }}>
|
||||
{[t("jobTableAll"), t("statusApplied"), t("statusWaiting"), t("statusInterview"), t("statusOffer"), t("statusRejected"), t("statusGhosted")].map((s) => <MenuItem key={s} value={s === t("jobTableAll") ? "All" : s === t("statusApplied") ? "Applied" : s === t("statusWaiting") ? "Waiting" : s === t("statusInterview") ? "Interview" : s === t("statusOffer") ? "Offer" : s === t("statusRejected") ? "Rejected" : "Ghosted"}>{s}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<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 sx={{ minWidth: 220 }} size="small">
|
||||
<InputLabel>{t("jobTableCompany")}</InputLabel>
|
||||
<Select value={companyFilterId} label={t("jobTableCompany")} onChange={(e) => { setCompanyFilterId(e.target.value as any); setPage(0); }}>
|
||||
<MenuItem value="All">{t("jobTableAll")}</MenuItem>
|
||||
{companies.map((c) => <MenuItem key={c.id} value={c.id}>{c.name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<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>
|
||||
|
||||
<TextField label={t("jobTableLocation")} value={locationFilter} onChange={(e) => { setLocationFilter(e.target.value); setPage(0); }} sx={{ minWidth: 200, flex: "1 1 200px" }} />
|
||||
<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
|
||||
/>
|
||||
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||
{mode === "jobs" ? <FormControlLabel control={<Checkbox checked={needsFollowUpOnly} onChange={(e) => { setNeedsFollowUpOnly(e.target.checked); setPage(0); }} />} label={t("jobTableNeedsFollowUp")} /> : null}
|
||||
{mode === "jobs" ? (
|
||||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||
<InputLabel>{t("jobTableReadiness")}</InputLabel>
|
||||
<Select value={readinessFilter} label={t("jobTableReadiness")} onChange={(e) => setReadinessFilter(e.target.value as any)}>
|
||||
<MenuItem value="all">{t("jobTableAllReadiness")}</MenuItem>
|
||||
<MenuItem value="needs-work">{t("jobTableNeedsWork")}</MenuItem>
|
||||
<MenuItem value="interview">{t("jobTableInterviewStage")}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
) : null}
|
||||
{mode === "jobs" ? <FormControlLabel control={<Checkbox checked={includeDeleted} onChange={(e) => { setIncludeDeleted(e.target.checked); setPage(0); }} />} label={t("jobTableShowDeleted")} /> : null}
|
||||
<SavedViewsMenu current={{ q: search.trim() || undefined, status: statusFilter !== "All" ? statusFilter : undefined, companyId: companyFilterId === "All" ? undefined : (companyFilterId as number), location: locationFilter.trim() || undefined, needsFollowUp: needsFollowUpOnly ? true : undefined }} onApply={(p: SavedViewParams) => { setSearch(p.q ?? ""); setStatusFilter(p.status ?? "All"); setCompanyFilterId(p.companyId ?? "All"); setLocationFilter(p.location ?? ""); setNeedsFollowUpOnly(Boolean(p.needsFollowUp)); setPage(0); }} />
|
||||
<Tooltip title={t("jobTableColumns")}><IconButton onClick={(e) => setColumnsAnchor(e.currentTarget)}><ViewColumnIcon /></IconButton></Tooltip>
|
||||
{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>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{selectedIds.length > 0 ? (
|
||||
<Paper sx={{ mt: 2, p: 1.5, display: "flex", alignItems: "center", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
|
||||
<Typography sx={{ fontWeight: 800 }}>{t("jobTableSelected", { count: selectedIds.length })}</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
{mode === "trash" ? <Button variant="outlined" onClick={() => void runBulkAction("restore")}>{t("jobTableRestoreSelected")}</Button> : <Button variant="outlined" color="error" onClick={() => void runBulkAction("delete")}>{t("jobTableDeleteSelected")}</Button>}
|
||||
{mode === "jobs" ? ["Waiting", "Interview", "Rejected", "Ghosted", "Offer"].map((s) => <Button key={s} variant="outlined" onClick={() => void runBulkAction("status", s)}>{s}</Button>) : null}
|
||||
<Box 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}
|
||||
@@ -360,107 +460,248 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
))}
|
||||
</Menu>
|
||||
|
||||
<Paper sx={{ mt: 2 }}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell padding="checkbox"><Checkbox checked={selectedAllOnPage} indeterminate={selectedIds.length > 0 && !selectedAllOnPage} onChange={(e) => toggleSelectAll(e.target.checked)} /></TableCell>
|
||||
<TableCell width={1} />
|
||||
<TableCell sortDirection={sortBy === "company" ? sortDir : false}><TableSortLabel active={sortBy === "company"} direction={sortBy === "company" ? sortDir : "asc"} onClick={() => requestSort("company")}>{t("jobTableCompany")}</TableSortLabel></TableCell>
|
||||
<TableCell sortDirection={sortBy === "jobTitle" ? sortDir : false}><TableSortLabel active={sortBy === "jobTitle"} direction={sortBy === "jobTitle" ? sortDir : "asc"} onClick={() => requestSort("jobTitle")}>{t("jobTableRole")}</TableSortLabel></TableCell>
|
||||
{columns.status ? <TableCell sortDirection={sortBy === "status" ? sortDir : false}><TableSortLabel active={sortBy === "status"} direction={sortBy === "status" ? sortDir : "asc"} onClick={() => requestSort("status")}>{t("jobTableStatus")}</TableSortLabel></TableCell> : null}
|
||||
{columns.dateApplied ? <TableCell sortDirection={sortBy === "dateApplied" ? sortDir : false}><TableSortLabel active={sortBy === "dateApplied"} direction={sortBy === "dateApplied" ? sortDir : "asc"} onClick={() => requestSort("dateApplied")}>{t("jobTableDateApplied")}</TableSortLabel></TableCell> : null}
|
||||
{columns.daysSince ? <TableCell sortDirection={sortBy === "daysSince" ? sortDir : false}><TableSortLabel active={sortBy === "daysSince"} direction={sortBy === "daysSince" ? sortDir : "asc"} onClick={() => requestSort("daysSince")}>{t("jobTableDays")}</TableSortLabel></TableCell> : null}
|
||||
{columns.jobUrl ? <TableCell>{t("settingsColumnJobUrl")}</TableCell> : null}
|
||||
<TableCell align="right">{t("jobTableActions")}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
<Paper sx={{ mt: 2, overflow: "hidden" }}>
|
||||
{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>
|
||||
|
||||
{filteredJobs.map((job) => {
|
||||
const open = expanded.includes(job.id);
|
||||
const toneName = statusTone(job.status);
|
||||
const tone = toneName === "error" ? theme.palette.error.main : toneName === "warning" ? theme.palette.warning.main : toneName === "success" ? theme.palette.success.main : toneName === "info" ? theme.palette.info.main : theme.palette.primary.main;
|
||||
const primaryAction = getPrimaryAction(job);
|
||||
const actionSignals = getActionSignals(job);
|
||||
const tags = parseTags(job.tags).slice(0, 6);
|
||||
return (
|
||||
<React.Fragment key={job.id}>
|
||||
<TableRow sx={{ backgroundColor: alpha(tone, theme.palette.mode === "dark" ? 0.1 : 0.06) }}>
|
||||
<TableCell padding="checkbox"><Checkbox checked={selectedIds.includes(job.id)} onChange={(e) => toggleSelected(job.id, e.target.checked)} /></TableCell>
|
||||
<TableCell><IconButton size="small" onClick={() => toggleExpanded(job.id)}>{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}</IconButton></TableCell>
|
||||
<TableCell>{job.company?.name ?? ""}</TableCell>
|
||||
<TableCell>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||
<span>{job.jobTitle}</span>
|
||||
{actionSignals.map((signal) => (
|
||||
<Chip
|
||||
key={`${job.id}-${signal.label}-${signal.detail}`}
|
||||
size="small"
|
||||
label={signal.label}
|
||||
color={signal.color}
|
||||
variant={signal.variant === "contained" ? "filled" : "outlined"}
|
||||
title={signal.detail}
|
||||
sx={{ fontWeight: 800, cursor: "pointer" }}
|
||||
clickable
|
||||
onClick={signal.onClick}
|
||||
aria-label={`${job.jobTitle} — ${signal.label} signal`}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</TableCell>
|
||||
{columns.status ? <TableCell><Chip label={normalizeStatus(job.status)} size="small" color={toneName as any} /></TableCell> : null}
|
||||
{columns.dateApplied ? <TableCell>{new Date(job.dateApplied).toLocaleDateString()}</TableCell> : null}
|
||||
{columns.daysSince ? <TableCell>{job.daysSince}</TableCell> : null}
|
||||
{columns.jobUrl ? <TableCell>{job.jobUrl ? <a href={job.jobUrl} target="_blank" rel="noreferrer">{t("jobTableLink")}</a> : ""}</TableCell> : null}
|
||||
<TableCell align="right">
|
||||
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 0.75 }}>
|
||||
{primaryAction ? (
|
||||
<>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 700 }}>
|
||||
{t("editJobNextAction")}
|
||||
</Typography>
|
||||
<Button size="small" variant={primaryAction.variant} color={primaryAction.color} onClick={primaryAction.onClick} aria-label={`${t("editJobNextAction")}: ${job.jobTitle} — ${primaryAction.label}`}>
|
||||
{primaryAction.label}
|
||||
</Button>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", maxWidth: 220, textAlign: "right" }}>
|
||||
{primaryAction.detail}
|
||||
</Typography>
|
||||
</>
|
||||
) : null}
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 0.5 }}>
|
||||
<Tooltip title={t("jobTableEdit")}><IconButton size="small" onClick={() => setEditJobId(job.id)}><EditOutlinedIcon fontSize="small" /></IconButton></Tooltip>
|
||||
<Tooltip title={t("jobTableQuickStatus")}><IconButton size="small" onClick={(e) => { setStatusJobId(job.id); setStatusAnchor(e.currentTarget); }}><MoreHorizIcon fontSize="small" /></IconButton></Tooltip>
|
||||
<Tooltip title={t("jobTableOpen")}><IconButton size="small" onClick={() => setDetailsJobId(job.id)}><LaunchIcon fontSize="small" /></IconButton></Tooltip>
|
||||
{(mode === "trash" || (includeDeleted && job.isDeleted)) ? <Tooltip title={t("jobTableRestore")}><IconButton size="small" onClick={() => void restore(job.id)}><RestoreFromTrashOutlinedIcon fontSize="small" /></IconButton></Tooltip> : <Tooltip title={t("jobTableSoftDelete")}><IconButton size="small" onClick={() => void softDelete(job)}><DeleteOutlineIcon fontSize="small" /></IconButton></Tooltip>}
|
||||
<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={selectedIds.includes(job.id)} 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>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell sx={{ py: 0 }} colSpan={columns.status && columns.dateApplied && columns.daysSince && columns.jobUrl ? 9 : 8}>
|
||||
<Collapse in={open} timeout="auto" unmountOnExit>
|
||||
<Box sx={{ p: 2, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr" }, gap: 2 }}>
|
||||
<Box><Typography variant="overline">{t("jobTableLocation")}</Typography><Typography>{job.location ?? "-"}</Typography></Box>
|
||||
<Box><Typography variant="overline">{t("addJobModalSalary")}</Typography><Typography>{job.salary ?? "-"}</Typography></Box>
|
||||
<Box><Typography variant="overline">{t("settingsColumnJobUrl")}</Typography><Typography>{job.jobUrl ? <a href={job.jobUrl} target="_blank" rel="noreferrer">{t("jobTableOpenListing")}</a> : "-"}</Typography></Box>
|
||||
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">{t("jobTableSkills")}</Typography><Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.5 }}>{parseTags(job.tags).length ? parseTags(job.tags).slice(0, 8).map((tag) => <Chip key={tag} label={tag} size="small" />) : <Typography sx={{ color: "text.secondary" }}>{t("jobTableNoTags")}</Typography>}</Box></Box>
|
||||
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">{t("jobTableOverview")}</Typography><Typography sx={{ whiteSpace: "pre-wrap" }}>{generateOverview(job) || t("jobTableNoSummaryYet")}</Typography></Box>
|
||||
{columns.status ? <Chip label={normalizeStatus(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" }}>{new Date(job.dateApplied).toLocaleDateString()}</Typography>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</React.Fragment>
|
||||
) : 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" }}>{job.salary ?? "-"}</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}
|
||||
|
||||
{tags.length > 0 ? (
|
||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap" }}>
|
||||
{tags.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")}
|
||||
</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 ? <TableRow><TableCell colSpan={9}><Typography sx={{ py: 2, textAlign: "center" }}>{t("jobTableNoJobsFound")}</Typography></TableCell></TableRow> : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{filteredJobs.length === 0 ? <Typography sx={{ py: 2, textAlign: "center" }}>{t("jobTableNoJobsFound")}</Typography> : 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>
|
||||
{filteredJobs.map((job) => {
|
||||
const open = expanded.includes(job.id);
|
||||
const toneName = statusTone(job.status);
|
||||
const tone = toneName === "error" ? theme.palette.error.main : toneName === "warning" ? theme.palette.warning.main : toneName === "success" ? theme.palette.success.main : toneName === "info" ? theme.palette.info.main : theme.palette.primary.main;
|
||||
const primaryAction = getPrimaryAction(job);
|
||||
const actionSignals = getActionSignals(job);
|
||||
return (
|
||||
<React.Fragment key={job.id}>
|
||||
<TableRow sx={{ backgroundColor: alpha(tone, theme.palette.mode === "dark" ? 0.1 : 0.06) }}>
|
||||
<TableCell padding="checkbox"><Checkbox checked={selectedIds.includes(job.id)} onChange={(e) => toggleSelected(job.id, e.target.checked)} /></TableCell>
|
||||
<TableCell><IconButton size="small" onClick={() => toggleExpanded(job.id)}>{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}</IconButton></TableCell>
|
||||
<TableCell>{job.company?.name ?? ""}</TableCell>
|
||||
<TableCell>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||
<span>{job.jobTitle}</span>
|
||||
{actionSignals.map((signal) => (
|
||||
<Chip
|
||||
key={`${job.id}-${signal.label}-${signal.detail}`}
|
||||
size="small"
|
||||
label={signal.label}
|
||||
color={signal.color}
|
||||
variant={signal.variant === "contained" ? "filled" : "outlined"}
|
||||
title={signal.detail}
|
||||
sx={{ fontWeight: 800, cursor: "pointer" }}
|
||||
clickable
|
||||
onClick={signal.onClick}
|
||||
aria-label={`${job.jobTitle} — ${signal.label} signal`}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</TableCell>
|
||||
{columns.status ? <TableCell><Chip label={normalizeStatus(job.status)} size="small" color={toneName as any} /></TableCell> : null}
|
||||
{columns.dateApplied ? <TableCell>{new Date(job.dateApplied).toLocaleDateString()}</TableCell> : null}
|
||||
{columns.daysSince ? <TableCell>{job.daysSince}</TableCell> : null}
|
||||
{columns.jobUrl ? <TableCell>{job.jobUrl ? <a href={job.jobUrl} target="_blank" rel="noreferrer">{t("jobTableLink")}</a> : ""}</TableCell> : null}
|
||||
<TableCell align="right">
|
||||
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 0.75 }}>
|
||||
{primaryAction ? (
|
||||
<>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 700 }}>
|
||||
{t("editJobNextAction")}
|
||||
</Typography>
|
||||
<Button size="small" variant={primaryAction.variant} color={primaryAction.color} onClick={primaryAction.onClick} aria-label={`${t("editJobNextAction")}: ${job.jobTitle} — ${primaryAction.label}`}>
|
||||
{primaryAction.label}
|
||||
</Button>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", maxWidth: 220, textAlign: "right" }}>
|
||||
{primaryAction.detail}
|
||||
</Typography>
|
||||
</>
|
||||
) : null}
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 0.5 }}>
|
||||
<Tooltip title={t("jobTableEdit")}><IconButton size="small" onClick={() => setEditJobId(job.id)}><EditOutlinedIcon fontSize="small" /></IconButton></Tooltip>
|
||||
<Tooltip title={t("jobTableQuickStatus")}><IconButton size="small" onClick={(e) => { setStatusJobId(job.id); setStatusAnchor(e.currentTarget); }}><MoreHorizIcon fontSize="small" /></IconButton></Tooltip>
|
||||
<Tooltip title={t("jobTableOpen")}><IconButton size="small" onClick={() => setDetailsJobId(job.id)}><LaunchIcon fontSize="small" /></IconButton></Tooltip>
|
||||
{(mode === "trash" || (includeDeleted && job.isDeleted)) ? <Tooltip title={t("jobTableRestore")}><IconButton size="small" onClick={() => void restore(job.id)}><RestoreFromTrashOutlinedIcon fontSize="small" /></IconButton></Tooltip> : <Tooltip title={t("jobTableSoftDelete")}><IconButton size="small" onClick={() => void softDelete(job)}><DeleteOutlineIcon fontSize="small" /></IconButton></Tooltip>}
|
||||
</Box>
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell sx={{ py: 0 }} colSpan={visibleDesktopColumns}>
|
||||
<Collapse in={open} timeout="auto" unmountOnExit>
|
||||
<Box sx={{ p: 2, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr" }, gap: 2 }}>
|
||||
<Box><Typography variant="overline">{t("jobTableLocation")}</Typography><Typography>{job.location ?? "-"}</Typography></Box>
|
||||
<Box><Typography variant="overline">{t("addJobModalSalary")}</Typography><Typography>{job.salary ?? "-"}</Typography></Box>
|
||||
<Box><Typography variant="overline">{t("settingsColumnJobUrl")}</Typography><Typography>{job.jobUrl ? <a href={job.jobUrl} target="_blank" rel="noreferrer">{t("jobTableOpenListing")}</a> : "-"}</Typography></Box>
|
||||
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">{t("jobTableSkills")}</Typography><Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.5 }}>{parseTags(job.tags).length ? parseTags(job.tags).slice(0, 8).map((tag) => <Chip key={tag} label={tag} size="small" />) : <Typography sx={{ color: "text.secondary" }}>{t("jobTableNoTags")}</Typography>}</Box></Box>
|
||||
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">{t("jobTableOverview")}</Typography><Typography sx={{ whiteSpace: "pre-wrap" }}>{generateOverview(job) || t("jobTableNoSummaryYet")}</Typography></Box>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{filteredJobs.length === 0 ? <TableRow><TableCell colSpan={visibleDesktopColumns}><Typography sx={{ py: 2, textAlign: "center" }}>{t("jobTableNoJobsFound")}</Typography></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((t) => t + 1)} />
|
||||
<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); }}>
|
||||
{(["Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const).map((s) => <MenuItem key={s} onClick={() => { if (statusJobId) void setStatusQuick(statusJobId, s); setStatusAnchor(null); setStatusJobId(null); }}>{t("jobTableSetStatus", { status: s })}</MenuItem>)}
|
||||
{statusOptions.map((status) => <MenuItem key={status} onClick={() => { if (statusJobId) void setStatusQuick(statusJobId, status); setStatusAnchor(null); setStatusJobId(null); }}>{t("jobTableSetStatus", { status })}</MenuItem>)}
|
||||
</Menu>
|
||||
</Box>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user