Polish mobile layout and add collapsible sidebar

This commit is contained in:
2026-03-29 14:24:43 +02:00
parent 4253d33dfd
commit 99fc94bc18
7 changed files with 833 additions and 321 deletions
+31 -4
View File
@@ -1,6 +1,6 @@
import React, { Suspense, lazy, useEffect, useMemo, useState } from "react"; 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 useMediaQuery from "@mui/material/useMediaQuery";
import { CssVarsProvider } from "@mui/material/styles"; import { CssVarsProvider } from "@mui/material/styles";
@@ -99,6 +99,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useI18n(); const { t } = useI18n();
const compactHeaderActions = useMediaQuery("(max-width:767.95px)");
const [addOpen, setAddOpen] = useState(false); const [addOpen, setAddOpen] = useState(false);
const [quickOpen, setQuickOpen] = useState(false); const [quickOpen, setQuickOpen] = useState(false);
@@ -165,9 +166,35 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
]; ];
const rightActions = ( const rightActions = (
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}> <Box
<Button variant="outlined" startIcon={<SearchIcon />} onClick={() => setQuickOpen(true)}>{t("quickSearch")}</Button> sx={{
{isJobs ? <Button variant="contained" onClick={() => setAddOpen(true)}>{t("addJob")}</Button> : null} 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> </Box>
); );
@@ -8,16 +8,19 @@ import {
DialogActions, DialogActions,
DialogContent, DialogContent,
DialogTitle, DialogTitle,
IconButton,
Paper, Paper,
Stack,
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
TableContainer,
TableHead, TableHead,
TableRow, TableRow,
TextField, TextField,
Typography, Typography,
IconButton,
} from "@mui/material"; } from "@mui/material";
import useMediaQuery from "@mui/material/useMediaQuery";
import { api, getApiErrorMessage } from "../api"; import { api, getApiErrorMessage } from "../api";
import { Company } from "../types"; import { Company } from "../types";
@@ -26,6 +29,7 @@ import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider"; import { useI18n } from "../i18n/I18nProvider";
export default function CompaniesTable() { export default function CompaniesTable() {
const isMobile = useMediaQuery("(max-width:767.95px)");
const { toast } = useToast(); const { toast } = useToast();
const { t } = useI18n(); const { t } = useI18n();
const location = useLocation(); const location = useLocation();
@@ -93,60 +97,101 @@ export default function CompaniesTable() {
} }
}; };
return ( const renderCompanyMeta = (label: string, value?: string | null) => (
<Paper sx={{ mt: 0 }}> <Box>
<Table> <Typography variant="overline" sx={{ color: "text.secondary" }}>{label}</Typography>
<TableHead> <Typography variant="body2" sx={{ overflowWrap: "anywhere" }}>{value || "—"}</Typography>
<TableRow> </Box>
<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>
<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> <DialogTitle>{t("companiesEdit")}</DialogTitle>
<DialogContent> <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 <TextField
label={t("companiesName")} label={t("companiesName")}
value={editing?.name ?? ""} value={editing?.name ?? ""}
onChange={(e) => setEditing((p) => (p ? { ...p, name: e.target.value } : p))} onChange={(e) => setEditing((p) => (p ? { ...p, name: e.target.value } : p))}
sx={{ gridColumn: "1 / -1" }} sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }}
/> />
<TextField <TextField
label={t("companiesLocation")} label={t("companiesLocation")}
@@ -178,7 +223,7 @@ export default function CompaniesTable() {
label={t("companiesRecruiterLinkedIn")} label={t("companiesRecruiterLinkedIn")}
value={recruiterLinkedIn} value={recruiterLinkedIn}
onChange={(e) => setRecruiterLinkedIn(e.target.value)} onChange={(e) => setRecruiterLinkedIn(e.target.value)}
sx={{ gridColumn: "1 / -1" }} sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }}
/> />
<TextField <TextField
@@ -197,9 +242,9 @@ export default function CompaniesTable() {
/> />
</Box> </Box>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions sx={{ px: 3, pb: 3, flexDirection: { xs: "column-reverse", sm: "row" }, gap: 1 }}>
<Button onClick={() => setEditOpen(false)}>{t("cancel")}</Button> <Button onClick={() => setEditOpen(false)} fullWidth={isMobile}>{t("cancel")}</Button>
<Button variant="contained" onClick={save} disabled={!canSave}> <Button variant="contained" onClick={save} disabled={!canSave} fullWidth={isMobile}>
{t("save")} {t("save")}
</Button> </Button>
</DialogActions> </DialogActions>
+37 -23
View File
@@ -13,6 +13,7 @@ import {
Stack, Stack,
Typography, Typography,
} from "@mui/material"; } from "@mui/material";
import useMediaQuery from "@mui/material/useMediaQuery";
import { alpha, useTheme } from "@mui/material/styles"; import { alpha, useTheme } from "@mui/material/styles";
import TuneIcon from "@mui/icons-material/Tune"; import TuneIcon from "@mui/icons-material/Tune";
import TrendingUpIcon from "@mui/icons-material/TrendingUp"; import TrendingUpIcon from "@mui/icons-material/TrendingUp";
@@ -110,7 +111,7 @@ function SectionCard({ children, sx = {} }: { children: React.ReactNode; sx?: an
return ( return (
<Paper <Paper
sx={{ sx={{
p: 2.25, p: { xs: 1.5, sm: 2.25 },
borderRadius: 4, borderRadius: 4,
border: "1px solid", border: "1px solid",
borderColor: "divider", borderColor: "divider",
@@ -126,6 +127,7 @@ function SectionCard({ children, sx = {} }: { children: React.ReactNode; sx?: an
export default function DashboardView() { export default function DashboardView() {
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery("(max-width:767.95px)");
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useI18n(); const { t } = useI18n();
const [stats, setStats] = useState<JobStats | null>(null); const [stats, setStats] = useState<JobStats | null>(null);
@@ -153,8 +155,8 @@ export default function DashboardView() {
const appliedValues = analytics.map((x) => x.applied); const appliedValues = analytics.map((x) => x.applied);
const responseValues = analytics.map((x) => x.responses); const responseValues = analytics.map((x) => x.responses);
const chartWidth = 860; const chartWidth = isMobile ? Math.max(420, analytics.length * 70) : 860;
const chartHeight = 250; const chartHeight = isMobile ? 210 : 250;
const appliedPath = buildLinePath(appliedValues, chartWidth, chartHeight); const appliedPath = buildLinePath(appliedValues, chartWidth, chartHeight);
const responsePath = buildLinePath(responseValues, 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]; 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 }}> <Typography variant="overline" sx={{ color: theme.palette.primary.main, fontWeight: 800 }}>
{t("dashboardHeroLabel")} {t("dashboardHeroLabel")}
</Typography> </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")} {t("dashboardOverviewTitle")}
</Typography> </Typography>
<Typography variant="body1" sx={{ color: "text.secondary", mt: 1.25, maxWidth: 680 }}> <Typography variant="body1" sx={{ color: "text.secondary", mt: 1.25, maxWidth: 680 }}>
@@ -259,7 +261,18 @@ export default function DashboardView() {
</Stack> </Stack>
</Box> </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) => ( {([6, 12, 24] as const).map((m) => (
<Button key={m} size="small" variant={months === m ? "contained" : "outlined"} onClick={() => setMonths(m)}> <Button key={m} size="small" variant={months === m ? "contained" : "outlined"} onClick={() => setMonths(m)}>
{t("dashboardMonthsShort", { count: m })} {t("dashboardMonthsShort", { count: m })}
@@ -300,7 +313,7 @@ export default function DashboardView() {
{card.icon} {card.icon}
</Box> </Box>
</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)} /> <MiniSpark values={card.spark.length ? card.spark : [0, 0, 0]} color={alpha(card.tone, 0.95)} />
</Box> </Box>
</SectionCard> </SectionCard>
@@ -322,7 +335,7 @@ export default function DashboardView() {
</Stack> </Stack>
</Box> </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 }}> <Box sx={{ minWidth: chartWidth }}>
<svg width={chartWidth} height={chartHeight} viewBox={`0 0 ${chartWidth} ${chartHeight}`}> <svg width={chartWidth} height={chartHeight} viewBox={`0 0 ${chartWidth} ${chartHeight}`}>
{[0.2, 0.4, 0.6, 0.8].map((tick) => ( {[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; const width = funnelMax ? clamp((item.count / funnelMax) * 100, 0, 100) : 0;
return ( return (
<Box key={item.label}> <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={{ fontWeight: 700 }}>{item.label}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{item.count}</Typography> <Typography variant="body2" sx={{ color: "text.secondary" }}>{item.count}</Typography>
</Box> </Box>
@@ -402,16 +415,17 @@ export default function DashboardView() {
{priorityJobs.map((job) => { {priorityJobs.map((job) => {
const action = getReminderAction(job); const action = getReminderAction(job);
return ( 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 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> <Box sx={{ minWidth: 0 }}>
<Typography sx={{ fontWeight: 900 }}>{job.company?.name ?? t("jobTableCompany")} {job.jobTitle}</Typography> <Typography sx={{ fontWeight: 900, overflowWrap: "anywhere" }}>{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> <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> </Box>
<Button variant="outlined" onClick={() => openReminderJob(job)}> );
{action?.label ?? t("remindersOpen")} })}
</Button>
</Box>
)})}
</Stack> </Stack>
)} )}
<Box sx={{ mt: 1.5 }}> <Box sx={{ mt: 1.5 }}>
@@ -426,9 +440,9 @@ export default function DashboardView() {
{(overview?.topCompanies ?? []).map((item, index) => ( {(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 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 sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
<Box> <Box sx={{ minWidth: 0 }}>
<Typography sx={{ fontWeight: 900 }}>{item.company}</Typography> <Typography sx={{ fontWeight: 900, overflowWrap: "anywhere" }}>{item.company}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("dashboardCompanyJobsResponses", { jobs: item.count, responses: item.responses })}</Typography> <Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>{t("dashboardCompanyJobsResponses", { jobs: item.count, responses: item.responses })}</Typography>
</Box> </Box>
<Chip label={`${item.responseRate}%`} color={item.responseRate >= 50 ? "success" : item.responseRate >= 25 ? "warning" : "default"} variant="outlined" /> <Chip label={`${item.responseRate}%`} color={item.responseRate >= 50 ? "success" : item.responseRate >= 25 ? "warning" : "default"} variant="outlined" />
</Box> </Box>
@@ -451,7 +465,7 @@ export default function DashboardView() {
return ( return (
<Box key={tag.tag}> <Box key={tag.tag}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, mb: 0.5 }}> <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> <Typography variant="body2" sx={{ color: "text.secondary" }}>{tag.count}</Typography>
</Box> </Box>
<Box sx={{ height: 10, borderRadius: 999, bgcolor: alpha(theme.palette.text.primary, 0.08), overflow: "hidden" }}> <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}> <Stack spacing={1.1}>
{tagTrends.series.map((series, index) => ( {tagTrends.series.map((series, index) => (
<Box key={series.tag}> <Box key={series.tag}>
<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: 800 }}>{series.tag}</Typography> <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> <Typography variant="caption" sx={{ color: "text.secondary" }}>{series.counts.reduce((sum, value) => sum + value, 0)} total</Typography>
</Box> </Box>
<Box sx={{ display: "grid", gridTemplateColumns: `repeat(${series.counts.length}, 1fr)`, gap: 0.5 }}> <Box sx={{ display: "grid", gridTemplateColumns: `repeat(${series.counts.length}, 1fr)`, gap: 0.5 }}>
+366 -125
View File
@@ -16,6 +16,7 @@ import {
MenuItem, MenuItem,
Paper, Paper,
Select, Select,
Stack,
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
@@ -27,6 +28,7 @@ import {
Tooltip, Tooltip,
Typography, Typography,
} from "@mui/material"; } from "@mui/material";
import useMediaQuery from "@mui/material/useMediaQuery";
import { alpha, useTheme } from "@mui/material/styles"; import { alpha, useTheme } from "@mui/material/styles";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; 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) { export default function JobTable({ refreshToken, pageSize, onPageSizeChange, columns, onColumnsChange, mode = "jobs" }: Props) {
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery("(max-width:767.95px)");
const { toast } = useToast(); const { toast } = useToast();
const { t } = useI18n(); const { t } = useI18n();
const { confirmAction } = useDialogActions(); const { confirmAction } = useDialogActions();
@@ -217,7 +220,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
try { try {
await api.delete(`/jobapplications/${job.id}`); await api.delete(`/jobapplications/${job.id}`);
toast(t("jobTableMovedToTrash"), "success", { label: "Undo", onClick: () => { void restore(job.id); } }); toast(t("jobTableMovedToTrash"), "success", { label: "Undo", onClick: () => { void restore(job.id); } });
setReloadToken((t) => t + 1); setReloadToken((token) => token + 1);
} catch { } catch {
toast(t("jobTableDeleteFailed"), "error"); toast(t("jobTableDeleteFailed"), "error");
} }
@@ -227,7 +230,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
try { try {
await api.post(`/jobapplications/${id}/restore`); await api.post(`/jobapplications/${id}/restore`);
toast(t("jobTableRestored"), "success"); toast(t("jobTableRestored"), "success");
setReloadToken((t) => t + 1); setReloadToken((token) => token + 1);
} catch { } catch {
toast(t("jobTableRestoreFailed"), "error"); toast(t("jobTableRestoreFailed"), "error");
} }
@@ -237,7 +240,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
try { try {
await api.patch(`/jobapplications/${id}/status`, { status }); await api.patch(`/jobapplications/${id}/status`, { status });
toast(t("jobTableStatusSet", { status }), "success"); toast(t("jobTableStatusSet", { status }), "success");
setReloadToken((t) => t + 1); setReloadToken((token) => token + 1);
} catch { } catch {
toast(t("jobTableStatusUpdateFailed"), "error"); toast(t("jobTableStatusUpdateFailed"), "error");
} }
@@ -254,7 +257,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
return api.patch(`/jobapplications/${id}/status`, { status: value }); return api.patch(`/jobapplications/${id}/status`, { status: value });
})); }));
toast(t("jobTableUpdatedJobs", { count: selectedIds.length }), "success"); toast(t("jobTableUpdatedJobs", { count: selectedIds.length }), "success");
setReloadToken((t) => t + 1); setReloadToken((token) => token + 1);
setSelectedIds([]); setSelectedIds([]);
} catch { } catch {
toast(t("jobTableBulkActionFailed"), "error"); 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 ( return (
<Box> <Box>
<Box sx={{ display: "flex", gap: 2, alignItems: "center", justifyContent: "space-between", mt: 2, flexWrap: "wrap" }}> {isMobile ? (
<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" }} /> <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"> <Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
<InputLabel>{t("jobTableStatus")}</InputLabel> <FormControl fullWidth size="small">
<Select value={statusFilter} label={t("jobTableStatus")} onChange={(e) => { setStatusFilter(e.target.value); setPage(0); }}> <InputLabel>{t("jobTableStatus")}</InputLabel>
{[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 value={statusFilter} label={t("jobTableStatus")} onChange={(e) => { setStatusFilter(e.target.value); setPage(0); }}>
</Select> {[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>)}
</FormControl> </Select>
</FormControl>
<FormControl sx={{ minWidth: 220 }} size="small"> <FormControl fullWidth size="small">
<InputLabel>{t("jobTableCompany")}</InputLabel> <InputLabel>{t("jobTableCompany")}</InputLabel>
<Select value={companyFilterId} label={t("jobTableCompany")} onChange={(e) => { setCompanyFilterId(e.target.value as any); setPage(0); }}> <Select value={companyFilterId} label={t("jobTableCompany")} onChange={(e) => { setCompanyFilterId(e.target.value as any); setPage(0); }}>
<MenuItem value="All">{t("jobTableAll")}</MenuItem> <MenuItem value="All">{t("jobTableAll")}</MenuItem>
{companies.map((c) => <MenuItem key={c.id} value={c.id}>{c.name}</MenuItem>)} {companies.map((c) => <MenuItem key={c.id} value={c.id}>{c.name}</MenuItem>)}
</Select> </Select>
</FormControl> </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" ? (
{mode === "jobs" ? <FormControlLabel control={<Checkbox checked={needsFollowUpOnly} onChange={(e) => { setNeedsFollowUpOnly(e.target.checked); setPage(0); }} />} label={t("jobTableNeedsFollowUp")} /> : null} <FormControl fullWidth size="small">
{mode === "jobs" ? ( <InputLabel>{t("jobTableReadiness")}</InputLabel>
<FormControl size="small" sx={{ minWidth: 180 }}> <Select value={readinessFilter} label={t("jobTableReadiness")} onChange={(e) => setReadinessFilter(e.target.value as any)}>
<InputLabel>{t("jobTableReadiness")}</InputLabel> <MenuItem value="all">{t("jobTableAllReadiness")}</MenuItem>
<Select value={readinessFilter} label={t("jobTableReadiness")} onChange={(e) => setReadinessFilter(e.target.value as any)}> <MenuItem value="needs-work">{t("jobTableNeedsWork")}</MenuItem>
<MenuItem value="all">{t("jobTableAllReadiness")}</MenuItem> <MenuItem value="interview">{t("jobTableInterviewStage")}</MenuItem>
<MenuItem value="needs-work">{t("jobTableNeedsWork")}</MenuItem> </Select>
<MenuItem value="interview">{t("jobTableInterviewStage")}</MenuItem> </FormControl>
</Select> ) : null}
</FormControl> </Box>
) : null}
{mode === "jobs" ? <FormControlLabel control={<Checkbox checked={includeDeleted} onChange={(e) => { setIncludeDeleted(e.target.checked); setPage(0); }} />} label={t("jobTableShowDeleted")} /> : null} {mode === "jobs" ? (
<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
<Tooltip title={t("jobTableColumns")}><IconButton onClick={(e) => setColumnsAnchor(e.currentTarget)}><ViewColumnIcon /></IconButton></Tooltip> 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>
</Box> )}
{selectedIds.length > 0 ? ( {selectedIds.length > 0 ? (
<Paper sx={{ mt: 2, p: 1.5, display: "flex", alignItems: "center", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}> <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> <Typography sx={{ fontWeight: 800 }}>{t("jobTableSelected", { count: selectedIds.length })}</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}> <Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", width: { xs: "100%", sm: "auto" } }}>
{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 === "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" ? ["Waiting", "Interview", "Rejected", "Ghosted", "Offer"].map((s) => <Button key={s} variant="outlined" onClick={() => void runBulkAction("status", s)}>{s}</Button>) : null} {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> </Box>
</Paper> </Paper>
) : null} ) : null}
@@ -360,107 +460,248 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
))} ))}
</Menu> </Menu>
<Paper sx={{ mt: 2 }}> <Paper sx={{ mt: 2, overflow: "hidden" }}>
<Table size="small"> {isMobile ? (
<TableHead> <Stack spacing={1.25} sx={{ p: 1.25 }}>
<TableRow> <Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1, px: 0.5 }}>
<TableCell padding="checkbox"><Checkbox checked={selectedAllOnPage} indeterminate={selectedIds.length > 0 && !selectedAllOnPage} onChange={(e) => toggleSelectAll(e.target.checked)} /></TableCell> <FormControlLabel control={<Checkbox checked={selectedAllOnPage} indeterminate={selectedIds.length > 0 && !selectedAllOnPage} onChange={(e) => toggleSelectAll(e.target.checked)} />} label={t("jobTableSelectAll")} sx={{ mr: 0 }} />
<TableCell width={1} /> </Box>
<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) => { {filteredJobs.map((job) => {
const open = expanded.includes(job.id);
const toneName = statusTone(job.status); 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 primaryAction = getPrimaryAction(job);
const actionSignals = getActionSignals(job); const actionSignals = getActionSignals(job);
const tags = parseTags(job.tags).slice(0, 6);
return ( return (
<React.Fragment key={job.id}> <Paper
<TableRow sx={{ backgroundColor: alpha(tone, theme.palette.mode === "dark" ? 0.1 : 0.06) }}> key={job.id}
<TableCell padding="checkbox"><Checkbox checked={selectedIds.includes(job.id)} onChange={(e) => toggleSelected(job.id, e.target.checked)} /></TableCell> sx={{
<TableCell><IconButton size="small" onClick={() => toggleExpanded(job.id)}>{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}</IconButton></TableCell> p: 1.5,
<TableCell>{job.company?.name ?? ""}</TableCell> borderRadius: 3.5,
<TableCell> backgroundColor: alpha(theme.palette.primary.main, 0.03),
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}> borderColor: alpha(theme.palette.primary.main, 0.08),
<span>{job.jobTitle}</span> boxShadow: `0 10px 24px ${alpha(theme.palette.common.black, theme.palette.mode === "dark" ? 0.18 : 0.06)}`,
{actionSignals.map((signal) => ( }}
<Chip >
key={`${job.id}-${signal.label}-${signal.detail}`} <Stack spacing={1.25}>
size="small" <Box sx={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 1 }}>
label={signal.label} <Box sx={{ display: "flex", alignItems: "flex-start", gap: 1, minWidth: 0, flex: 1 }}>
color={signal.color} <Checkbox checked={selectedIds.includes(job.id)} onChange={(e) => toggleSelected(job.id, e.target.checked)} sx={{ mt: -0.5, ml: -1 }} />
variant={signal.variant === "contained" ? "filled" : "outlined"} <Box sx={{ minWidth: 0 }}>
title={signal.detail} <Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>
sx={{ fontWeight: 800, cursor: "pointer" }} {job.company?.name ?? t("jobTableCompany")}
clickable </Typography>
onClick={signal.onClick} <Typography sx={{ fontWeight: 900, fontSize: 21, lineHeight: 1.1, letterSpacing: -0.4, textWrap: "balance", overflowWrap: "anywhere" }}>
aria-label={`${job.jobTitle}${signal.label} signal`} {job.jobTitle}
/> </Typography>
))}
</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>
</Box> </Box>
</TableCell> {columns.status ? <Chip label={normalizeStatus(job.status)} size="small" color={toneName as any} sx={{ fontWeight: 800 }} /> : null}
</TableRow> </Box>
<TableRow>
<TableCell sx={{ py: 0 }} colSpan={columns.status && columns.dateApplied && columns.daysSince && columns.jobUrl ? 9 : 8}> <Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap" }}>
<Collapse in={open} timeout="auto" unmountOnExit> {actionSignals.map((signal) => (
<Box sx={{ p: 2, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr" }, gap: 2 }}> <Chip
<Box><Typography variant="overline">{t("jobTableLocation")}</Typography><Typography>{job.location ?? "-"}</Typography></Box> key={`${job.id}-${signal.label}-${signal.detail}`}
<Box><Typography variant="overline">{t("addJobModalSalary")}</Typography><Typography>{job.salary ?? "-"}</Typography></Box> size="small"
<Box><Typography variant="overline">{t("settingsColumnJobUrl")}</Typography><Typography>{job.jobUrl ? <a href={job.jobUrl} target="_blank" rel="noreferrer">{t("jobTableOpenListing")}</a> : "-"}</Typography></Box> label={signal.label}
<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> color={signal.color}
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">{t("jobTableOverview")}</Typography><Typography sx={{ whiteSpace: "pre-wrap" }}>{generateOverview(job) || t("jobTableNoSummaryYet")}</Typography></Box> 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> </Box>
</Collapse> ) : null}
</TableCell> {columns.daysSince ? (
</TableRow> <Box>
</React.Fragment> <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} {filteredJobs.length === 0 ? <Typography sx={{ py: 2, textAlign: "center" }}>{t("jobTableNoJobsFound")}</Typography> : null}
</TableBody> </Stack>
</Table> ) : (
<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]} /> <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> </Paper>
<JobDetailsDialog open={detailsJobId !== null} jobId={detailsJobId} initialTab={detailsInitialTab} initialFollowUpMode={detailsFollowUpMode} onClose={() => { setDetailsJobId(null); setDetailsInitialTab(0); setDetailsFollowUpMode(undefined); }} /> <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); }}> <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> </Menu>
</Box> </Box>
); );
+8
View File
@@ -200,6 +200,9 @@ export const translations = {
profileCvUploadFailed: "Failed to upload CV.", profileCvUploadFailed: "Failed to upload CV.",
profileCvTextLabel: "Profile CV / master resume text", 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.", 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.", profileCvPreferredUploads: "Supported uploads: PDF, DOCX, TXT, MD, PNG, JPG, JPEG, WEBP.",
profileCvSectionTools: "Section rewrite tools", profileCvSectionTools: "Section rewrite tools",
profileCvStructureOverview: "CV structure overview", profileCvStructureOverview: "CV structure overview",
@@ -675,6 +678,7 @@ export const translations = {
jobTableInterviewStage: "Interview stage", jobTableInterviewStage: "Interview stage",
jobTableShowDeleted: "Show deleted", jobTableShowDeleted: "Show deleted",
jobTableColumns: "Columns", jobTableColumns: "Columns",
jobTableSelectAll: "Select all",
jobTableSelected: "{count} selected", jobTableSelected: "{count} selected",
jobTableRestoreSelected: "Restore selected", jobTableRestoreSelected: "Restore selected",
jobTableDeleteSelected: "Delete selected", jobTableDeleteSelected: "Delete selected",
@@ -1101,6 +1105,9 @@ export const translations = {
profileCvUploadFailed: "Kunne ikke laste opp CV.", profileCvUploadFailed: "Kunne ikke laste opp CV.",
profileCvTextLabel: "Profil-CV / hovedtekst for 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.", 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.", profileCvPreferredUploads: "Støttede opplastinger: PDF, DOCX, TXT, MD, PNG, JPG, JPEG, WEBP.",
profileCvSectionTools: "Verktøy for CV-seksjoner", profileCvSectionTools: "Verktøy for CV-seksjoner",
profileCvStructureOverview: "Oversikt over CV-struktur", profileCvStructureOverview: "Oversikt over CV-struktur",
@@ -1576,6 +1583,7 @@ export const translations = {
jobTableInterviewStage: "Intervjustadium", jobTableInterviewStage: "Intervjustadium",
jobTableShowDeleted: "Vis slettede", jobTableShowDeleted: "Vis slettede",
jobTableColumns: "Kolonner", jobTableColumns: "Kolonner",
jobTableSelectAll: "Velg alle",
jobTableSelected: "{count} valgt", jobTableSelected: "{count} valgt",
jobTableRestoreSelected: "Gjenopprett valgte", jobTableRestoreSelected: "Gjenopprett valgte",
jobTableDeleteSelected: "Slett valgte", jobTableDeleteSelected: "Slett valgte",
+213 -85
View File
@@ -1,4 +1,4 @@
import React, { useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { import {
AppBar, AppBar,
@@ -18,8 +18,10 @@ import {
Toolbar, Toolbar,
Typography, Typography,
} from "@mui/material"; } from "@mui/material";
import useMediaQuery from "@mui/material/useMediaQuery";
import MenuIcon from "@mui/icons-material/Menu"; import MenuIcon from "@mui/icons-material/Menu";
import MenuOpenIcon from "@mui/icons-material/MenuOpen";
import NotificationsNoneIcon from "@mui/icons-material/NotificationsNone"; import NotificationsNoneIcon from "@mui/icons-material/NotificationsNone";
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined"; import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
@@ -43,6 +45,8 @@ function initialsFrom(s?: string) {
return (parts[0][0] + parts[1][0]).toUpperCase(); return (parts[0][0] + parts[1][0]).toUpperCase();
} }
const DESKTOP_SIDEBAR_KEY = "appShellDesktopSidebarCollapsed";
export default function AppShell({ export default function AppShell({
pageTitle, pageTitle,
breadcrumbs, breadcrumbs,
@@ -79,7 +83,23 @@ export default function AppShell({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const { t } = useI18n(); 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 grouped = useMemo(() => {
const groupItems = (items: NavItem[]) => { const groupItems = (items: NavItem[]) => {
@@ -98,15 +118,15 @@ export default function AppShell({
}, [nav, navBottom]); }, [nav, navBottom]);
const renderNavList = (groups: Array<[string, NavItem[]]>) => ( 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]) => ( {groups.map(([section, rows]) => (
<Box key={section || "_"} sx={{ mb: 1.25 }}> <Box key={section || "_"} sx={{ mb: desktopNavCollapsed ? 1 : 1.25 }}>
{section ? ( {section && !desktopNavCollapsed ? (
<Typography variant="caption" sx={{ px: 1.25, color: "text.secondary", fontWeight: 600, textTransform: "uppercase" }}> <Typography variant="caption" sx={{ px: 1.25, color: "text.secondary", fontWeight: 600, textTransform: "uppercase" }}>
{section} {section}
</Typography> </Typography>
) : null} ) : 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) => { {rows.map((item) => {
const selected = pathname === item.to || pathname.startsWith(item.to + "/"); const selected = pathname === item.to || pathname.startsWith(item.to + "/");
return ( return (
@@ -114,24 +134,28 @@ export default function AppShell({
key={item.to} key={item.to}
selected={selected} selected={selected}
onClick={() => onNavigate(item.to)} onClick={() => onNavigate(item.to)}
sx={(theme: any) => ({ title={desktopNavCollapsed ? item.label : undefined}
sx={(muiTheme: any) => ({
borderRadius: 2, borderRadius: 2,
mb: 0.5, mb: 0.5,
minHeight: 44,
px: desktopNavCollapsed ? 1 : 1.5,
justifyContent: desktopNavCollapsed ? "center" : "flex-start",
border: "1px solid transparent", border: "1px solid transparent",
"&.Mui-selected": { "&.Mui-selected": {
backgroundColor: theme.vars.palette.action.hover, backgroundColor: muiTheme.vars.palette.action.hover,
borderColor: theme.vars.palette.divider, borderColor: muiTheme.vars.palette.divider,
}, },
})} })}
> >
<ListItemIcon sx={{ minWidth: 36 }}> <ListItemIcon sx={{ minWidth: desktopNavCollapsed ? 0 : 36, justifyContent: "center" }}>
{item.badgeCount && item.badgeCount > 0 ? ( {item.badgeCount && item.badgeCount > 0 ? (
<Badge color="error" badgeContent={item.badgeCount > 99 ? "99+" : item.badgeCount}> <Badge color="error" badgeContent={item.badgeCount > 99 ? "99+" : item.badgeCount}>
{item.icon} {item.icon}
</Badge> </Badge>
) : item.icon} ) : item.icon}
</ListItemIcon> </ListItemIcon>
<ListItemText primary={item.label} primaryTypographyProps={{ fontWeight: 600 }} /> {!desktopNavCollapsed ? <ListItemText primary={item.label} primaryTypographyProps={{ fontWeight: 600 }} /> : null}
</ListItemButton> </ListItemButton>
); );
})} })}
@@ -143,16 +167,20 @@ export default function AppShell({
const drawerContent = ( const drawerContent = (
<Box sx={{ height: "100%", display: "flex", flexDirection: "column" }}> <Box sx={{ height: "100%", display: "flex", flexDirection: "column" }}>
<Box sx={{ px: 2.25, py: 2.5 }}> <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 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 1, justifyContent: desktopNavCollapsed ? "center" : "flex-start" }}>
<JobbjaktMark style={{ width: 22, height: 22 }} /> <JobbjaktMark style={{ width: 22, height: 22 }} />
<Typography variant="h6" sx={{ fontWeight: 600 }}> {!desktopNavCollapsed ? (
Jobbjakt <Box>
</Typography> <Typography variant="h6" sx={{ fontWeight: 600 }}>
Jobbjakt
</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{t("appTagline")}
</Typography>
</Box>
) : null}
</Box> </Box>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{t("appTagline")}
</Typography>
</Box> </Box>
<Divider /> <Divider />
@@ -179,66 +207,166 @@ export default function AppShell({
position="fixed" position="fixed"
color="inherit" color="inherit"
elevation={0} elevation={0}
sx={(theme: any) => ({ sx={(muiTheme: any) => ({
borderBottom: `1px solid ${theme.vars.palette.grey[300]}`, borderBottom: `1px solid ${muiTheme.vars.palette.grey[300]}`,
backgroundColor: theme.vars.palette.background.default, backgroundColor: muiTheme.vars.palette.background.default,
backgroundImage: "none", 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 } }}> <Toolbar
<IconButton sx={{
edge="start" gap: 1.25,
size="small" px: { xs: 2, md: 3 },
color="secondary" py: { xs: 1.25, md: 0 },
onClick={() => onToggleDrawer(true)} minHeight: { xs: 68, md: 76 },
sx={{ display: { xs: "inline-flex", md: "none" }, border: "1px solid", borderColor: "divider", borderRadius: 2 }} alignItems: { xs: "stretch", md: "center" },
> flexWrap: { xs: "wrap", md: "nowrap" },
<MenuIcon fontSize="small" /> }}
</IconButton> >
{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 {user ? (
color="secondary" <IconButton
size="small" size="small"
title={t("notifications")} onClick={(e) => setUserMenuAnchor(e.currentTarget)}
onClick={onOpenNotifications} sx={{ borderRadius: 2.5, border: "1px solid", borderColor: "divider", width: 42, height: 42, flex: "0 0 auto" }}
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2 }} >
> <Avatar src={user.avatarImageDataUrl || undefined} sx={{ width: 28, height: 28, fontWeight: 900 }}>{initials}</Avatar>
<Badge color="primary" badgeContent={notificationsCount || 0} max={99}> </IconButton>
<NotificationsNoneIcon fontSize="small" /> ) : <Box sx={{ width: 42, height: 42 }} />}
</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>
</Box> </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 <Menu
anchorEl={userMenuAnchor} anchorEl={userMenuAnchor}
@@ -278,15 +406,15 @@ export default function AppShell({
<Drawer <Drawer
variant="permanent" variant="permanent"
sx={(theme: any) => ({ sx={(muiTheme: any) => ({
display: { xs: "none", md: "block" }, display: { xs: "none", md: "block" },
width: drawerWidth, width: drawerWidth,
flexShrink: 0, flexShrink: 0,
[`& .MuiDrawer-paper`]: { [`& .MuiDrawer-paper`]: {
width: drawerWidth, width: drawerWidth,
boxSizing: "border-box", boxSizing: "border-box",
borderRight: `1px solid ${theme.vars.palette.grey[300]}`, borderRight: `1px solid ${muiTheme.vars.palette.grey[300]}`,
backgroundColor: theme.vars.palette.background.default, backgroundColor: muiTheme.vars.palette.background.default,
backgroundImage: "none", backgroundImage: "none",
boxShadow: "none", boxShadow: "none",
}, },
@@ -302,12 +430,12 @@ export default function AppShell({
open={drawerOpen} open={drawerOpen}
onClose={() => onToggleDrawer(false)} onClose={() => onToggleDrawer(false)}
ModalProps={{ keepMounted: true }} ModalProps={{ keepMounted: true }}
sx={(theme: any) => ({ sx={(muiTheme: any) => ({
display: { xs: "block", md: "none" }, display: { xs: "block", md: "none" },
[`& .MuiDrawer-paper`]: { [`& .MuiDrawer-paper`]: {
width: drawerWidth, width: drawerWidth,
borderRight: `1px solid ${theme.vars.palette.grey[300]}`, borderRight: `1px solid ${muiTheme.vars.palette.grey[300]}`,
backgroundColor: theme.vars.palette.background.default, backgroundColor: muiTheme.vars.palette.background.default,
backgroundImage: "none", backgroundImage: "none",
}, },
})} })}
@@ -324,18 +452,18 @@ export default function AppShell({
minHeight: "100vh", minHeight: "100vh",
}} }}
> >
<Box sx={{ mx: "auto", maxWidth: 1320, width: "100%" }}> <Box sx={{ mx: "auto", maxWidth: 1320, width: "100%", minWidth: 0 }}>
<Toolbar sx={{ minHeight: { xs: 68, md: 76 } }} /> <Toolbar sx={{ minHeight: { xs: isMobile ? 124 : 68, md: 76 } }} />
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 2, mb: 2 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 0.75, mb: 2, minWidth: 0 }}>
<Box sx={{ 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) => ( {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} {c}
</Typography> </Typography>
))} ))}
</Breadcrumbs> </Breadcrumbs>
<Typography variant="h5" sx={{ fontWeight: 600 }} noWrap> <Typography variant="h5" sx={{ fontWeight: 600, overflowWrap: "anywhere" }}>
{pageTitle} {pageTitle}
</Typography> </Typography>
</Box> </Box>
+81 -32
View File
@@ -7,9 +7,11 @@ import {
Chip, Chip,
FormControlLabel, FormControlLabel,
Paper, Paper,
Stack,
TextField, TextField,
Typography, Typography,
} from "@mui/material"; } from "@mui/material";
import useMediaQuery from "@mui/material/useMediaQuery";
import { DataGrid, GridColDef } from "@mui/x-data-grid"; import { DataGrid, GridColDef } from "@mui/x-data-grid";
import { api, getApiErrorMessage } from "../api"; import { api, getApiErrorMessage } from "../api";
@@ -26,6 +28,7 @@ type UserDto = {
}; };
export default function AdminUsersPage() { export default function AdminUsersPage() {
const isMobile = useMediaQuery("(max-width:767.95px)");
const { toast } = useToast(); const { toast } = useToast();
const { confirmAction } = useDialogActions(); const { confirmAction } = useDialogActions();
const { t } = useI18n(); const { t } = useI18n();
@@ -149,23 +152,24 @@ export default function AdminUsersPage() {
], [remove, sendReset, setAdminRole, t]); ], [remove, sendReset, setAdminRole, t]);
return ( return (
<Paper sx={{ p: 2 }}> <Paper sx={{ p: { xs: 1.5, sm: 2 } }}>
<Typography variant="h6" sx={{ fontWeight: 950, mb: 0.5 }}> <Typography variant="h6" sx={{ fontWeight: 950, mb: 0.5 }}>
{t("adminUsersTitle")} {t("adminUsersTitle")}
</Typography> </Typography>
<Typography sx={{ color: "text.secondary", mb: 2 }}>{t("adminUsersSubtitle")}</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> <Typography sx={{ fontWeight: 900, mb: 1 }}>{t("adminUsersCreateUser")}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}> <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("profileEmail")} value={newEmail} onChange={(e) => setNewEmail(e.target.value)} fullWidth />
<TextField label={t("profileNewPassword")} type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} /> <TextField label={t("profileNewPassword")} type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} fullWidth />
</Box> </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")} /> <FormControlLabel control={<Checkbox checked={newIsAdmin} onChange={(e) => setNewIsAdmin(e.target.checked)} />} label={t("adminUsersAdmin")} />
<Button <Button
variant="contained" variant="contained"
disabled={!canCreate || loading} disabled={!canCreate || loading}
sx={{ width: { xs: "100%", sm: "auto" } }}
onClick={async () => { onClick={async () => {
try { try {
await api.post("/users", { email: newEmail, password: newPassword, roles: newIsAdmin ? ["Admin"] : [] }); await api.post("/users", { email: newEmail, password: newPassword, roles: newIsAdmin ? ["Admin"] : [] });
@@ -185,33 +189,78 @@ export default function AdminUsersPage() {
</Box> </Box>
</Paper> </Paper>
<Paper sx={{ borderRadius: 3, border: "1px solid", borderColor: "divider", overflow: "hidden" }}> {isMobile ? (
<DataGrid <Stack spacing={1.5}>
autoHeight {!loading && rows.length === 0 ? (
rows={rows} <Typography sx={{ color: "text.secondary", py: 2, textAlign: "center" }}>{t("adminUsersNoUsers")}</Typography>
columns={columns} ) : null}
disableRowSelectionOnClick {rows.map((row) => {
loading={loading} const user = row.raw as UserDto;
pageSizeOptions={[5, 10, 25]} const isAdmin = user.roles.includes("Admin");
initialState={{ return (
pagination: { paginationModel: { pageSize: 10, page: 0 } }, <Paper key={row.id} sx={{ p: 1.5, borderRadius: 3 }}>
sorting: { sortModel: [{ field: "email", sort: "asc" }] }, <Stack spacing={1.25}>
}} <Box>
sx={{ <Typography sx={{ fontWeight: 900, overflowWrap: "anywhere" }}>
border: 0, {row.userName || row.email || row.id}
'& .MuiDataGrid-columnHeaders': { </Typography>
backgroundColor: 'action.hover', <Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>
fontWeight: 800, {row.email || "—"}
}, </Typography>
'& .MuiDataGrid-cell': { </Box>
alignItems: 'center',
}, <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" />
{!loading && rows.length === 0 ? ( )) : <Chip size="small" label="—" variant="outlined" />}
<Typography sx={{ color: "text.secondary", py: 2, textAlign: "center" }}>{t("adminUsersNoUsers")}</Typography> <Chip size="small" label={row.emailConfirmed ? t("yes") : t("noWord")} color={row.emailConfirmed ? "success" : "default"} variant={row.emailConfirmed ? "filled" : "outlined"} />
) : null} </Box>
</Paper>
<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> </Paper>
); );
} }