Polish mobile layout and add collapsible sidebar
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import React, { Suspense, lazy, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { Box, Button, CssBaseline, Typography } from "@mui/material";
|
||||
import { Box, Button, CssBaseline, IconButton, Typography } from "@mui/material";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import { CssVarsProvider } from "@mui/material/styles";
|
||||
|
||||
@@ -99,6 +99,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useI18n();
|
||||
const compactHeaderActions = useMediaQuery("(max-width:767.95px)");
|
||||
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [quickOpen, setQuickOpen] = useState(false);
|
||||
@@ -165,9 +166,35 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
];
|
||||
|
||||
const rightActions = (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||
<Button variant="outlined" startIcon={<SearchIcon />} onClick={() => setQuickOpen(true)}>{t("quickSearch")}</Button>
|
||||
{isJobs ? <Button variant="contained" onClick={() => setAddOpen(true)}>{t("addJob")}</Button> : null}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
flexWrap: "nowrap",
|
||||
justifyContent: "flex-end",
|
||||
width: { xs: "100%", sm: "auto" },
|
||||
flex: { xs: 1, sm: "0 0 auto" },
|
||||
}}
|
||||
>
|
||||
{compactHeaderActions ? (
|
||||
<IconButton
|
||||
color="secondary"
|
||||
size="small"
|
||||
title={t("quickSearch")}
|
||||
onClick={() => setQuickOpen(true)}
|
||||
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42, flex: "0 0 auto" }}
|
||||
>
|
||||
<SearchIcon fontSize="small" />
|
||||
</IconButton>
|
||||
) : (
|
||||
<Button variant="outlined" startIcon={<SearchIcon />} onClick={() => setQuickOpen(true)}>{t("quickSearch")}</Button>
|
||||
)}
|
||||
{isJobs ? (
|
||||
<Button variant="contained" onClick={() => setAddOpen(true)} sx={{ flex: { xs: 1, sm: "0 0 auto" }, minHeight: 42 }}>
|
||||
{t("addJob")}
|
||||
</Button>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -200,6 +200,9 @@ export const translations = {
|
||||
profileCvUploadFailed: "Failed to upload CV.",
|
||||
profileCvTextLabel: "Profile CV / master resume text",
|
||||
profileCvTextHelp: "Keep this updated and specific. Include recent roles, tools, achievements, measurable outcomes, and the work you want to be hired for next. If extraction misses something, edit it here manually.",
|
||||
profileCvStructuredDefaultHint: "The structured CV stays front and center. Open the original extraction only when you need to verify or clean up parser output.",
|
||||
profileCvRawPanelTitle: "Original extraction",
|
||||
profileCvRawPanelHelp: "Usually messy, but useful for checking what the parser actually pulled from the uploaded file.",
|
||||
profileCvPreferredUploads: "Supported uploads: PDF, DOCX, TXT, MD, PNG, JPG, JPEG, WEBP.",
|
||||
profileCvSectionTools: "Section rewrite tools",
|
||||
profileCvStructureOverview: "CV structure overview",
|
||||
@@ -675,6 +678,7 @@ export const translations = {
|
||||
jobTableInterviewStage: "Interview stage",
|
||||
jobTableShowDeleted: "Show deleted",
|
||||
jobTableColumns: "Columns",
|
||||
jobTableSelectAll: "Select all",
|
||||
jobTableSelected: "{count} selected",
|
||||
jobTableRestoreSelected: "Restore selected",
|
||||
jobTableDeleteSelected: "Delete selected",
|
||||
@@ -1101,6 +1105,9 @@ export const translations = {
|
||||
profileCvUploadFailed: "Kunne ikke laste opp CV.",
|
||||
profileCvTextLabel: "Profil-CV / hovedtekst for CV",
|
||||
profileCvTextHelp: "Hold denne oppdatert og konkret. Ta med nylige roller, verktøy, prestasjoner, målbare resultater og arbeidet du vil bli ansatt for neste gang. Hvis tekstuttrekket mangler noe, kan du redigere manuelt her.",
|
||||
profileCvStructuredDefaultHint: "Den strukturerte CV-en er hovedvisningen. Åpne originaluttrekket bare når du vil kontrollere eller rydde opp i parserresultatet.",
|
||||
profileCvRawPanelTitle: "Originalt uttrekk",
|
||||
profileCvRawPanelHelp: "Som regel rotete, men nyttig når du vil se nøyaktig hva parseren hentet ut fra den opplastede filen.",
|
||||
profileCvPreferredUploads: "Støttede opplastinger: PDF, DOCX, TXT, MD, PNG, JPG, JPEG, WEBP.",
|
||||
profileCvSectionTools: "Verktøy for CV-seksjoner",
|
||||
profileCvStructureOverview: "Oversikt over CV-struktur",
|
||||
@@ -1576,6 +1583,7 @@ export const translations = {
|
||||
jobTableInterviewStage: "Intervjustadium",
|
||||
jobTableShowDeleted: "Vis slettede",
|
||||
jobTableColumns: "Kolonner",
|
||||
jobTableSelectAll: "Velg alle",
|
||||
jobTableSelected: "{count} valgt",
|
||||
jobTableRestoreSelected: "Gjenopprett valgte",
|
||||
jobTableDeleteSelected: "Slett valgte",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
AppBar,
|
||||
@@ -18,8 +18,10 @@ import {
|
||||
Toolbar,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
|
||||
import MenuIcon from "@mui/icons-material/Menu";
|
||||
import MenuOpenIcon from "@mui/icons-material/MenuOpen";
|
||||
import NotificationsNoneIcon from "@mui/icons-material/NotificationsNone";
|
||||
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
|
||||
|
||||
@@ -43,6 +45,8 @@ function initialsFrom(s?: string) {
|
||||
return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||
}
|
||||
|
||||
const DESKTOP_SIDEBAR_KEY = "appShellDesktopSidebarCollapsed";
|
||||
|
||||
export default function AppShell({
|
||||
pageTitle,
|
||||
breadcrumbs,
|
||||
@@ -79,7 +83,23 @@ export default function AppShell({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const drawerWidth = 254;
|
||||
const isMobile = useMediaQuery("(max-width:767.95px)");
|
||||
const [desktopNavCollapsed, setDesktopNavCollapsed] = useState(() => {
|
||||
try {
|
||||
return window.localStorage.getItem(DESKTOP_SIDEBAR_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const drawerWidth = isMobile ? 254 : desktopNavCollapsed ? 84 : 254;
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(DESKTOP_SIDEBAR_KEY, desktopNavCollapsed ? "1" : "0");
|
||||
} catch {
|
||||
// ignore storage failures
|
||||
}
|
||||
}, [desktopNavCollapsed]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const groupItems = (items: NavItem[]) => {
|
||||
@@ -98,15 +118,15 @@ export default function AppShell({
|
||||
}, [nav, navBottom]);
|
||||
|
||||
const renderNavList = (groups: Array<[string, NavItem[]]>) => (
|
||||
<Box sx={{ px: 1.25, pt: 1 }}>
|
||||
<Box sx={{ px: desktopNavCollapsed ? 0.75 : 1.25, pt: 1 }}>
|
||||
{groups.map(([section, rows]) => (
|
||||
<Box key={section || "_"} sx={{ mb: 1.25 }}>
|
||||
{section ? (
|
||||
<Box key={section || "_"} sx={{ mb: desktopNavCollapsed ? 1 : 1.25 }}>
|
||||
{section && !desktopNavCollapsed ? (
|
||||
<Typography variant="caption" sx={{ px: 1.25, color: "text.secondary", fontWeight: 600, textTransform: "uppercase" }}>
|
||||
{section}
|
||||
</Typography>
|
||||
) : null}
|
||||
<List sx={{ px: 0.75, pt: 0.75 }}>
|
||||
<List sx={{ px: desktopNavCollapsed ? 0.25 : 0.75, pt: desktopNavCollapsed ? 0.25 : 0.75 }}>
|
||||
{rows.map((item) => {
|
||||
const selected = pathname === item.to || pathname.startsWith(item.to + "/");
|
||||
return (
|
||||
@@ -114,24 +134,28 @@ export default function AppShell({
|
||||
key={item.to}
|
||||
selected={selected}
|
||||
onClick={() => onNavigate(item.to)}
|
||||
sx={(theme: any) => ({
|
||||
title={desktopNavCollapsed ? item.label : undefined}
|
||||
sx={(muiTheme: any) => ({
|
||||
borderRadius: 2,
|
||||
mb: 0.5,
|
||||
minHeight: 44,
|
||||
px: desktopNavCollapsed ? 1 : 1.5,
|
||||
justifyContent: desktopNavCollapsed ? "center" : "flex-start",
|
||||
border: "1px solid transparent",
|
||||
"&.Mui-selected": {
|
||||
backgroundColor: theme.vars.palette.action.hover,
|
||||
borderColor: theme.vars.palette.divider,
|
||||
backgroundColor: muiTheme.vars.palette.action.hover,
|
||||
borderColor: muiTheme.vars.palette.divider,
|
||||
},
|
||||
})}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||
<ListItemIcon sx={{ minWidth: desktopNavCollapsed ? 0 : 36, justifyContent: "center" }}>
|
||||
{item.badgeCount && item.badgeCount > 0 ? (
|
||||
<Badge color="error" badgeContent={item.badgeCount > 99 ? "99+" : item.badgeCount}>
|
||||
{item.icon}
|
||||
</Badge>
|
||||
) : item.icon}
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={item.label} primaryTypographyProps={{ fontWeight: 600 }} />
|
||||
{!desktopNavCollapsed ? <ListItemText primary={item.label} primaryTypographyProps={{ fontWeight: 600 }} /> : null}
|
||||
</ListItemButton>
|
||||
);
|
||||
})}
|
||||
@@ -143,16 +167,20 @@ export default function AppShell({
|
||||
|
||||
const drawerContent = (
|
||||
<Box sx={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||
<Box sx={{ px: 2.25, py: 2.5 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<Box sx={{ px: desktopNavCollapsed ? 1.5 : 2.25, py: desktopNavCollapsed ? 2 : 2.5, display: "flex", justifyContent: desktopNavCollapsed ? "center" : "flex-start" }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, justifyContent: desktopNavCollapsed ? "center" : "flex-start" }}>
|
||||
<JobbjaktMark style={{ width: 22, height: 22 }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
Jobbjakt
|
||||
</Typography>
|
||||
{!desktopNavCollapsed ? (
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
Jobbjakt
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{t("appTagline")}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{t("appTagline")}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
@@ -179,66 +207,166 @@ export default function AppShell({
|
||||
position="fixed"
|
||||
color="inherit"
|
||||
elevation={0}
|
||||
sx={(theme: any) => ({
|
||||
borderBottom: `1px solid ${theme.vars.palette.grey[300]}`,
|
||||
backgroundColor: theme.vars.palette.background.default,
|
||||
sx={(muiTheme: any) => ({
|
||||
borderBottom: `1px solid ${muiTheme.vars.palette.grey[300]}`,
|
||||
backgroundColor: muiTheme.vars.palette.background.default,
|
||||
backgroundImage: "none",
|
||||
zIndex: theme.zIndex.drawer + 1,
|
||||
zIndex: muiTheme.zIndex.drawer + 1,
|
||||
})}
|
||||
>
|
||||
<Toolbar sx={{ gap: 1.5, px: { xs: 2, md: 3 }, minHeight: { xs: 68, md: 76 } }}>
|
||||
<IconButton
|
||||
edge="start"
|
||||
size="small"
|
||||
color="secondary"
|
||||
onClick={() => onToggleDrawer(true)}
|
||||
sx={{ display: { xs: "inline-flex", md: "none" }, border: "1px solid", borderColor: "divider", borderRadius: 2 }}
|
||||
>
|
||||
<MenuIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<Toolbar
|
||||
sx={{
|
||||
gap: 1.25,
|
||||
px: { xs: 2, md: 3 },
|
||||
py: { xs: 1.25, md: 0 },
|
||||
minHeight: { xs: 68, md: 76 },
|
||||
alignItems: { xs: "stretch", md: "center" },
|
||||
flexWrap: { xs: "wrap", md: "nowrap" },
|
||||
}}
|
||||
>
|
||||
{isMobile ? (
|
||||
<>
|
||||
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1, width: "100%", minWidth: 0 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, minWidth: 0 }}>
|
||||
<IconButton
|
||||
edge="start"
|
||||
size="small"
|
||||
color="secondary"
|
||||
onClick={() => onToggleDrawer(true)}
|
||||
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42 }}
|
||||
>
|
||||
<MenuIcon fontSize="small" />
|
||||
</IconButton>
|
||||
|
||||
<Box sx={{ flex: 1, display: "flex", alignItems: "center", gap: 1.25 }} />
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 0.9, minWidth: 0, pl: 0.25 }}>
|
||||
<JobbjaktMark style={{ width: 18, height: 18, flex: "0 0 auto" }} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800, letterSpacing: -0.2 }} noWrap>
|
||||
Jobbjakt
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<IconButton
|
||||
color="secondary"
|
||||
size="small"
|
||||
title={t("notifications")}
|
||||
onClick={onOpenNotifications}
|
||||
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2 }}
|
||||
>
|
||||
<Badge color="primary" badgeContent={notificationsCount || 0} max={99}>
|
||||
<NotificationsNoneIcon fontSize="small" />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
color="secondary"
|
||||
size="small"
|
||||
title={t("settings")}
|
||||
onClick={onOpenSettings}
|
||||
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2 }}
|
||||
>
|
||||
<SettingsOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
|
||||
{rightActions}
|
||||
|
||||
{user ? (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1.25, pl: 1 }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => setUserMenuAnchor(e.currentTarget)}
|
||||
sx={{ borderRadius: 2, border: "1px solid", borderColor: "divider" }}
|
||||
>
|
||||
<Avatar src={user.avatarImageDataUrl || undefined} sx={{ width: 30, height: 30, fontWeight: 900 }}>{initials}</Avatar>
|
||||
</IconButton>
|
||||
<Box sx={{ display: { xs: "none", sm: "block" } }}>
|
||||
<Typography sx={{ fontWeight: 600, lineHeight: 1.2 }}>{user.userName || user.displayName || user.email || t("user")}</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{user.roleLabel || ""}
|
||||
</Typography>
|
||||
{user ? (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => setUserMenuAnchor(e.currentTarget)}
|
||||
sx={{ borderRadius: 2.5, border: "1px solid", borderColor: "divider", width: 42, height: 42, flex: "0 0 auto" }}
|
||||
>
|
||||
<Avatar src={user.avatarImageDataUrl || undefined} sx={{ width: 28, height: 28, fontWeight: 900 }}>{initials}</Avatar>
|
||||
</IconButton>
|
||||
) : <Box sx={{ width: 42, height: 42 }} />}
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, width: "100%" }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flex: "0 0 auto" }}>
|
||||
<IconButton
|
||||
color="secondary"
|
||||
size="small"
|
||||
title={t("notifications")}
|
||||
onClick={onOpenNotifications}
|
||||
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42 }}
|
||||
>
|
||||
<Badge color="primary" badgeContent={notificationsCount || 0} max={99}>
|
||||
<NotificationsNoneIcon fontSize="small" />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
color="secondary"
|
||||
size="small"
|
||||
title={t("settings")}
|
||||
onClick={onOpenSettings}
|
||||
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42 }}
|
||||
>
|
||||
<SettingsOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
'& > *': { minWidth: 0 },
|
||||
}}
|
||||
>
|
||||
{rightActions}
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, minWidth: 0, flex: 1 }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="secondary"
|
||||
onClick={() => setDesktopNavCollapsed((value) => !value)}
|
||||
title={desktopNavCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2, width: 40, height: 40 }}
|
||||
>
|
||||
<MenuOpenIcon fontSize="small" sx={{ transform: desktopNavCollapsed ? "scaleX(-1)" : "none" }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
gap: 1,
|
||||
flexWrap: "wrap",
|
||||
width: { xs: "100%", md: "auto" },
|
||||
flex: { xs: "1 1 100%", md: "0 1 auto" },
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
color="secondary"
|
||||
size="small"
|
||||
title={t("notifications")}
|
||||
onClick={onOpenNotifications}
|
||||
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2 }}
|
||||
>
|
||||
<Badge color="primary" badgeContent={notificationsCount || 0} max={99}>
|
||||
<NotificationsNoneIcon fontSize="small" />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
color="secondary"
|
||||
size="small"
|
||||
title={t("settings")}
|
||||
onClick={onOpenSettings}
|
||||
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2 }}
|
||||
>
|
||||
<SettingsOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap", flex: { xs: "1 1 auto", md: "0 1 auto" }, justifyContent: "flex-end" }}>
|
||||
{rightActions}
|
||||
</Box>
|
||||
|
||||
{user ? (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1.25, pl: { xs: 0, sm: 1 } }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => setUserMenuAnchor(e.currentTarget)}
|
||||
sx={{ borderRadius: 2, border: "1px solid", borderColor: "divider" }}
|
||||
>
|
||||
<Avatar src={user.avatarImageDataUrl || undefined} sx={{ width: 30, height: 30, fontWeight: 900 }}>{initials}</Avatar>
|
||||
</IconButton>
|
||||
<Box sx={{ display: { xs: "none", sm: "block" }, minWidth: 0 }}>
|
||||
<Typography sx={{ fontWeight: 600, lineHeight: 1.2 }} noWrap>
|
||||
{user.userName || user.displayName || user.email || t("user")}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{user.roleLabel || ""}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Menu
|
||||
anchorEl={userMenuAnchor}
|
||||
@@ -278,15 +406,15 @@ export default function AppShell({
|
||||
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
sx={(theme: any) => ({
|
||||
sx={(muiTheme: any) => ({
|
||||
display: { xs: "none", md: "block" },
|
||||
width: drawerWidth,
|
||||
flexShrink: 0,
|
||||
[`& .MuiDrawer-paper`]: {
|
||||
width: drawerWidth,
|
||||
boxSizing: "border-box",
|
||||
borderRight: `1px solid ${theme.vars.palette.grey[300]}`,
|
||||
backgroundColor: theme.vars.palette.background.default,
|
||||
borderRight: `1px solid ${muiTheme.vars.palette.grey[300]}`,
|
||||
backgroundColor: muiTheme.vars.palette.background.default,
|
||||
backgroundImage: "none",
|
||||
boxShadow: "none",
|
||||
},
|
||||
@@ -302,12 +430,12 @@ export default function AppShell({
|
||||
open={drawerOpen}
|
||||
onClose={() => onToggleDrawer(false)}
|
||||
ModalProps={{ keepMounted: true }}
|
||||
sx={(theme: any) => ({
|
||||
sx={(muiTheme: any) => ({
|
||||
display: { xs: "block", md: "none" },
|
||||
[`& .MuiDrawer-paper`]: {
|
||||
width: drawerWidth,
|
||||
borderRight: `1px solid ${theme.vars.palette.grey[300]}`,
|
||||
backgroundColor: theme.vars.palette.background.default,
|
||||
borderRight: `1px solid ${muiTheme.vars.palette.grey[300]}`,
|
||||
backgroundColor: muiTheme.vars.palette.background.default,
|
||||
backgroundImage: "none",
|
||||
},
|
||||
})}
|
||||
@@ -324,18 +452,18 @@ export default function AppShell({
|
||||
minHeight: "100vh",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ mx: "auto", maxWidth: 1320, width: "100%" }}>
|
||||
<Toolbar sx={{ minHeight: { xs: 68, md: 76 } }} />
|
||||
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 2, mb: 2 }}>
|
||||
<Box sx={{ mx: "auto", maxWidth: 1320, width: "100%", minWidth: 0 }}>
|
||||
<Toolbar sx={{ minHeight: { xs: isMobile ? 124 : 68, md: 76 } }} />
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75, mb: 2, minWidth: 0 }}>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Breadcrumbs sx={{ color: "text.secondary", mb: 0.5 }}>
|
||||
<Breadcrumbs sx={{ color: "text.secondary", mb: 0.5, '& .MuiBreadcrumbs-ol': { flexWrap: 'wrap' } }}>
|
||||
{breadcrumbs.map((c) => (
|
||||
<Typography key={c} variant="caption" sx={{ color: "text.secondary", fontWeight: 600 }} noWrap>
|
||||
<Typography key={c} variant="caption" sx={{ color: "text.secondary", fontWeight: 600, overflowWrap: "anywhere" }}>
|
||||
{c}
|
||||
</Typography>
|
||||
))}
|
||||
</Breadcrumbs>
|
||||
<Typography variant="h5" sx={{ fontWeight: 600 }} noWrap>
|
||||
<Typography variant="h5" sx={{ fontWeight: 600, overflowWrap: "anywhere" }}>
|
||||
{pageTitle}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
Chip,
|
||||
FormControlLabel,
|
||||
Paper,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import { DataGrid, GridColDef } from "@mui/x-data-grid";
|
||||
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
@@ -26,6 +28,7 @@ type UserDto = {
|
||||
};
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const isMobile = useMediaQuery("(max-width:767.95px)");
|
||||
const { toast } = useToast();
|
||||
const { confirmAction } = useDialogActions();
|
||||
const { t } = useI18n();
|
||||
@@ -149,23 +152,24 @@ export default function AdminUsersPage() {
|
||||
], [remove, sendReset, setAdminRole, t]);
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Paper sx={{ p: { xs: 1.5, sm: 2 } }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 0.5 }}>
|
||||
{t("adminUsersTitle")}
|
||||
</Typography>
|
||||
<Typography sx={{ color: "text.secondary", mb: 2 }}>{t("adminUsersSubtitle")}</Typography>
|
||||
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Paper sx={{ p: { xs: 1.5, sm: 2 }, mb: 2 }}>
|
||||
<Typography sx={{ fontWeight: 900, mb: 1 }}>{t("adminUsersCreateUser")}</Typography>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
|
||||
<TextField label={t("profileEmail")} value={newEmail} onChange={(e) => setNewEmail(e.target.value)} />
|
||||
<TextField label={t("profileNewPassword")} type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
|
||||
<TextField label={t("profileEmail")} value={newEmail} onChange={(e) => setNewEmail(e.target.value)} fullWidth />
|
||||
<TextField label={t("profileNewPassword")} type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} fullWidth />
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 2, mt: 1.5, flexWrap: "wrap" }}>
|
||||
<Box sx={{ display: "flex", alignItems: { xs: "stretch", sm: "center" }, justifyContent: "space-between", gap: 2, mt: 1.5, flexWrap: "wrap" }}>
|
||||
<FormControlLabel control={<Checkbox checked={newIsAdmin} onChange={(e) => setNewIsAdmin(e.target.checked)} />} label={t("adminUsersAdmin")} />
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!canCreate || loading}
|
||||
sx={{ width: { xs: "100%", sm: "auto" } }}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await api.post("/users", { email: newEmail, password: newPassword, roles: newIsAdmin ? ["Admin"] : [] });
|
||||
@@ -185,33 +189,78 @@ export default function AdminUsersPage() {
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ borderRadius: 3, border: "1px solid", borderColor: "divider", overflow: "hidden" }}>
|
||||
<DataGrid
|
||||
autoHeight
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
disableRowSelectionOnClick
|
||||
loading={loading}
|
||||
pageSizeOptions={[5, 10, 25]}
|
||||
initialState={{
|
||||
pagination: { paginationModel: { pageSize: 10, page: 0 } },
|
||||
sorting: { sortModel: [{ field: "email", sort: "asc" }] },
|
||||
}}
|
||||
sx={{
|
||||
border: 0,
|
||||
'& .MuiDataGrid-columnHeaders': {
|
||||
backgroundColor: 'action.hover',
|
||||
fontWeight: 800,
|
||||
},
|
||||
'& .MuiDataGrid-cell': {
|
||||
alignItems: 'center',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{!loading && rows.length === 0 ? (
|
||||
<Typography sx={{ color: "text.secondary", py: 2, textAlign: "center" }}>{t("adminUsersNoUsers")}</Typography>
|
||||
) : null}
|
||||
</Paper>
|
||||
{isMobile ? (
|
||||
<Stack spacing={1.5}>
|
||||
{!loading && rows.length === 0 ? (
|
||||
<Typography sx={{ color: "text.secondary", py: 2, textAlign: "center" }}>{t("adminUsersNoUsers")}</Typography>
|
||||
) : null}
|
||||
{rows.map((row) => {
|
||||
const user = row.raw as UserDto;
|
||||
const isAdmin = user.roles.includes("Admin");
|
||||
return (
|
||||
<Paper key={row.id} sx={{ p: 1.5, borderRadius: 3 }}>
|
||||
<Stack spacing={1.25}>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 900, overflowWrap: "anywhere" }}>
|
||||
{row.userName || row.email || row.id}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>
|
||||
{row.email || "—"}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap" }}>
|
||||
{(row.roles as string[]).length ? (row.roles as string[]).map((role) => (
|
||||
<Chip key={role} size="small" label={role} variant="outlined" />
|
||||
)) : <Chip size="small" label="—" variant="outlined" />}
|
||||
<Chip size="small" label={row.emailConfirmed ? t("yes") : t("noWord")} color={row.emailConfirmed ? "success" : "default"} variant={row.emailConfirmed ? "filled" : "outlined"} />
|
||||
</Box>
|
||||
|
||||
<Stack spacing={1}>
|
||||
<Button variant={isAdmin ? "contained" : "outlined"} onClick={() => void setAdminRole(user, !isAdmin)} fullWidth>
|
||||
{t("adminUsersAdmin")}
|
||||
</Button>
|
||||
<Button variant="outlined" onClick={() => void sendReset(user)} fullWidth>
|
||||
{t("adminUsersSendReset")}
|
||||
</Button>
|
||||
<Button color="error" variant="outlined" onClick={() => void remove(user)} fullWidth>
|
||||
{t("adminUsersDelete")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
) : (
|
||||
<Paper sx={{ borderRadius: 3, border: "1px solid", borderColor: "divider", overflow: "hidden" }}>
|
||||
<DataGrid
|
||||
autoHeight
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
disableRowSelectionOnClick
|
||||
loading={loading}
|
||||
pageSizeOptions={[5, 10, 25]}
|
||||
initialState={{
|
||||
pagination: { paginationModel: { pageSize: 10, page: 0 } },
|
||||
sorting: { sortModel: [{ field: "email", sort: "asc" }] },
|
||||
}}
|
||||
sx={{
|
||||
border: 0,
|
||||
'& .MuiDataGrid-columnHeaders': {
|
||||
backgroundColor: 'action.hover',
|
||||
fontWeight: 800,
|
||||
},
|
||||
'& .MuiDataGrid-cell': {
|
||||
alignItems: 'center',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{!loading && rows.length === 0 ? (
|
||||
<Typography sx={{ color: "text.secondary", py: 2, textAlign: "center" }}>{t("adminUsersNoUsers")}</Typography>
|
||||
) : null}
|
||||
</Paper>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user