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 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
|
||||||
|
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>
|
<Button variant="outlined" startIcon={<SearchIcon />} onClick={() => setQuickOpen(true)}>{t("quickSearch")}</Button>
|
||||||
{isJobs ? <Button variant="contained" onClick={() => setAddOpen(true)}>{t("addJob")}</Button> : null}
|
)}
|
||||||
|
{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,8 +97,47 @@ export default function CompaniesTable() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper sx={{ mt: 0 }}>
|
<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>
|
<Table>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -137,16 +180,18 @@ export default function CompaniesTable() {
|
|||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
|
||||||
<Dialog open={editOpen} onClose={() => setEditOpen(false)} fullWidth maxWidth="sm">
|
<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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -403,15 +416,16 @@ export default function DashboardView() {
|
|||||||
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>
|
</Box>
|
||||||
<Button variant="outlined" onClick={() => openReminderJob(job)}>
|
<Button variant="outlined" onClick={() => openReminderJob(job)} sx={{ width: { xs: "100%", sm: "auto" } }}>
|
||||||
{action?.label ?? t("remindersOpen")}
|
{action?.label ?? t("remindersOpen")}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</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 }}>
|
||||||
|
|||||||
@@ -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,32 +304,51 @@ 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 }}>
|
||||||
|
<FormControl fullWidth size="small">
|
||||||
<InputLabel>{t("jobTableStatus")}</InputLabel>
|
<InputLabel>{t("jobTableStatus")}</InputLabel>
|
||||||
<Select value={statusFilter} label={t("jobTableStatus")} onChange={(e) => { setStatusFilter(e.target.value); setPage(0); }}>
|
<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>)}
|
{[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>
|
</Select>
|
||||||
</FormControl>
|
</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" ? <FormControlLabel control={<Checkbox checked={needsFollowUpOnly} onChange={(e) => { setNeedsFollowUpOnly(e.target.checked); setPage(0); }} />} label={t("jobTableNeedsFollowUp")} /> : null}
|
|
||||||
{mode === "jobs" ? (
|
{mode === "jobs" ? (
|
||||||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
<FormControl fullWidth size="small">
|
||||||
<InputLabel>{t("jobTableReadiness")}</InputLabel>
|
<InputLabel>{t("jobTableReadiness")}</InputLabel>
|
||||||
<Select value={readinessFilter} label={t("jobTableReadiness")} onChange={(e) => setReadinessFilter(e.target.value as any)}>
|
<Select value={readinessFilter} label={t("jobTableReadiness")} onChange={(e) => setReadinessFilter(e.target.value as any)}>
|
||||||
<MenuItem value="all">{t("jobTableAllReadiness")}</MenuItem>
|
<MenuItem value="all">{t("jobTableAllReadiness")}</MenuItem>
|
||||||
@@ -335,18 +357,96 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
|||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
) : null}
|
) : null}
|
||||||
{mode === "jobs" ? <FormControlLabel control={<Checkbox checked={includeDeleted} onChange={(e) => { setIncludeDeleted(e.target.checked); setPage(0); }} />} label={t("jobTableShowDeleted")} /> : 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); }} />
|
<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>
|
</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,8 +460,147 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
|||||||
))}
|
))}
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|
||||||
<Paper sx={{ mt: 2 }}>
|
<Paper sx={{ mt: 2, overflow: "hidden" }}>
|
||||||
<Table size="small">
|
{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 toneName = statusTone(job.status);
|
||||||
|
const primaryAction = getPrimaryAction(job);
|
||||||
|
const actionSignals = getActionSignals(job);
|
||||||
|
const tags = parseTags(job.tags).slice(0, 6);
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
{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>
|
||||||
|
) : 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 ? <Typography sx={{ py: 2, textAlign: "center" }}>{t("jobTableNoJobsFound")}</Typography> : null}
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ overflowX: "auto" }}>
|
||||||
|
<Table size="small" sx={{ minWidth: 980 }}>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell padding="checkbox"><Checkbox checked={selectedAllOnPage} indeterminate={selectedIds.length > 0 && !selectedAllOnPage} onChange={(e) => toggleSelectAll(e.target.checked)} /></TableCell>
|
<TableCell padding="checkbox"><Checkbox checked={selectedAllOnPage} indeterminate={selectedIds.length > 0 && !selectedAllOnPage} onChange={(e) => toggleSelectAll(e.target.checked)} /></TableCell>
|
||||||
@@ -436,7 +675,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell sx={{ py: 0 }} colSpan={columns.status && columns.dateApplied && columns.daysSince && columns.jobUrl ? 9 : 8}>
|
<TableCell sx={{ py: 0 }} colSpan={visibleDesktopColumns}>
|
||||||
<Collapse in={open} timeout="auto" unmountOnExit>
|
<Collapse in={open} timeout="auto" unmountOnExit>
|
||||||
<Box sx={{ p: 2, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr" }, gap: 2 }}>
|
<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("jobTableLocation")}</Typography><Typography>{job.location ?? "-"}</Typography></Box>
|
||||||
@@ -451,16 +690,18 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
|||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{filteredJobs.length === 0 ? <TableRow><TableCell colSpan={9}><Typography sx={{ py: 2, textAlign: "center" }}>{t("jobTableNoJobsFound")}</Typography></TableCell></TableRow> : null}
|
{filteredJobs.length === 0 ? <TableRow><TableCell colSpan={visibleDesktopColumns}><Typography sx={{ py: 2, textAlign: "center" }}>{t("jobTableNoJobsFound")}</Typography></TableCell></TableRow> : null}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,17 +167,21 @@ 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 }} />
|
||||||
|
{!desktopNavCollapsed ? (
|
||||||
|
<Box>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||||
Jobbjakt
|
Jobbjakt
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
|
||||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||||
{t("appTagline")}
|
{t("appTagline")}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
@@ -179,26 +207,119 @@ 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
|
||||||
|
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
|
<IconButton
|
||||||
edge="start"
|
edge="start"
|
||||||
size="small"
|
size="small"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
onClick={() => onToggleDrawer(true)}
|
onClick={() => onToggleDrawer(true)}
|
||||||
sx={{ display: { xs: "inline-flex", md: "none" }, border: "1px solid", borderColor: "divider", borderRadius: 2 }}
|
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42 }}
|
||||||
>
|
>
|
||||||
<MenuIcon fontSize="small" />
|
<MenuIcon fontSize="small" />
|
||||||
</IconButton>
|
</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>
|
||||||
|
|
||||||
|
{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 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
|
<IconButton
|
||||||
color="secondary"
|
color="secondary"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -220,10 +341,12 @@ export default function AppShell({
|
|||||||
<SettingsOutlinedIcon fontSize="small" />
|
<SettingsOutlinedIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap", flex: { xs: "1 1 auto", md: "0 1 auto" }, justifyContent: "flex-end" }}>
|
||||||
{rightActions}
|
{rightActions}
|
||||||
|
</Box>
|
||||||
|
|
||||||
{user ? (
|
{user ? (
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1.25, pl: 1 }}>
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1.25, pl: { xs: 0, sm: 1 } }}>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
onClick={(e) => setUserMenuAnchor(e.currentTarget)}
|
onClick={(e) => setUserMenuAnchor(e.currentTarget)}
|
||||||
@@ -231,14 +354,19 @@ export default function AppShell({
|
|||||||
>
|
>
|
||||||
<Avatar src={user.avatarImageDataUrl || undefined} sx={{ width: 30, height: 30, fontWeight: 900 }}>{initials}</Avatar>
|
<Avatar src={user.avatarImageDataUrl || undefined} sx={{ width: 30, height: 30, fontWeight: 900 }}>{initials}</Avatar>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Box sx={{ display: { xs: "none", sm: "block" } }}>
|
<Box sx={{ display: { xs: "none", sm: "block" }, minWidth: 0 }}>
|
||||||
<Typography sx={{ fontWeight: 600, lineHeight: 1.2 }}>{user.userName || user.displayName || user.email || t("user")}</Typography>
|
<Typography sx={{ fontWeight: 600, lineHeight: 1.2 }} noWrap>
|
||||||
|
{user.userName || user.displayName || user.email || t("user")}
|
||||||
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||||
{user.roleLabel || ""}
|
{user.roleLabel || ""}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
) : null}
|
) : 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>
|
||||||
|
|||||||
@@ -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,6 +189,50 @@ export default function AdminUsersPage() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</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" }}>
|
<Paper sx={{ borderRadius: 3, border: "1px solid", borderColor: "divider", overflow: "hidden" }}>
|
||||||
<DataGrid
|
<DataGrid
|
||||||
autoHeight
|
autoHeight
|
||||||
@@ -212,6 +260,7 @@ export default function AdminUsersPage() {
|
|||||||
<Typography sx={{ color: "text.secondary", py: 2, textAlign: "center" }}>{t("adminUsersNoUsers")}</Typography>
|
<Typography sx={{ color: "text.secondary", py: 2, textAlign: "center" }}>{t("adminUsersNoUsers")}</Typography>
|
||||||
) : null}
|
) : null}
|
||||||
</Paper>
|
</Paper>
|
||||||
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user