From 99fc94bc18a3d9daf6e6f1bc01373637ff4ddc68 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 29 Mar 2026 14:24:43 +0200 Subject: [PATCH] Polish mobile layout and add collapsible sidebar --- job-tracker-ui/src/App.tsx | 35 +- .../src/components/CompaniesTable.tsx | 149 ++++-- .../src/components/DashboardView.tsx | 60 ++- job-tracker-ui/src/components/JobTable.tsx | 491 +++++++++++++----- job-tracker-ui/src/i18n/translations.ts | 8 + job-tracker-ui/src/layout/AppShell.tsx | 298 ++++++++--- job-tracker-ui/src/pages/AdminUsersPage.tsx | 113 ++-- 7 files changed, 833 insertions(+), 321 deletions(-) diff --git a/job-tracker-ui/src/App.tsx b/job-tracker-ui/src/App.tsx index 90962c4..d1c3692 100644 --- a/job-tracker-ui/src/App.tsx +++ b/job-tracker-ui/src/App.tsx @@ -1,6 +1,6 @@ import React, { Suspense, lazy, useEffect, useMemo, useState } from "react"; -import { Box, Button, CssBaseline, Typography } from "@mui/material"; +import { Box, Button, CssBaseline, IconButton, Typography } from "@mui/material"; import useMediaQuery from "@mui/material/useMediaQuery"; import { CssVarsProvider } from "@mui/material/styles"; @@ -99,6 +99,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo const location = useLocation(); const navigate = useNavigate(); const { t } = useI18n(); + const compactHeaderActions = useMediaQuery("(max-width:767.95px)"); const [addOpen, setAddOpen] = useState(false); const [quickOpen, setQuickOpen] = useState(false); @@ -165,9 +166,35 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo ]; const rightActions = ( - - - {isJobs ? : null} + + {compactHeaderActions ? ( + setQuickOpen(true)} + sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42, flex: "0 0 auto" }} + > + + + ) : ( + + )} + {isJobs ? ( + + ) : null} ); diff --git a/job-tracker-ui/src/components/CompaniesTable.tsx b/job-tracker-ui/src/components/CompaniesTable.tsx index b602334..618a70f 100644 --- a/job-tracker-ui/src/components/CompaniesTable.tsx +++ b/job-tracker-ui/src/components/CompaniesTable.tsx @@ -8,16 +8,19 @@ import { DialogActions, DialogContent, DialogTitle, + IconButton, Paper, + Stack, Table, TableBody, TableCell, + TableContainer, TableHead, TableRow, TextField, Typography, - IconButton, } from "@mui/material"; +import useMediaQuery from "@mui/material/useMediaQuery"; import { api, getApiErrorMessage } from "../api"; import { Company } from "../types"; @@ -26,6 +29,7 @@ import { useToast } from "../toast"; import { useI18n } from "../i18n/I18nProvider"; export default function CompaniesTable() { + const isMobile = useMediaQuery("(max-width:767.95px)"); const { toast } = useToast(); const { t } = useI18n(); const location = useLocation(); @@ -93,60 +97,101 @@ export default function CompaniesTable() { } }; - return ( - - - - - {t("companiesName")} - {t("companiesLocation")} - {t("companiesSource")} - {t("companiesPipeline")} - {t("companiesRecruiter")} - {t("companiesNextContact")} - - - - - {companies.map((c) => ( - - {c.name} - {c.location ?? ""} - {c.source ?? ""} - {c.pipelineStage ?? ""} - - {c.recruiterName ?? ""} - {c.recruiterEmail ? ` (${c.recruiterEmail})` : ""} - - {c.nextContactAt ? new Date(c.nextContactAt).toLocaleDateString() : ""} - - openEdit(c)}> - - - - - ))} - {companies.length === 0 && ( - - - - {t("companiesEmpty")} - - - - )} - -
+ const renderCompanyMeta = (label: string, value?: string | null) => ( + + {label} + {value || "—"} + + ); - setEditOpen(false)} fullWidth maxWidth="sm"> + return ( + + {isMobile ? ( + + {companies.map((c) => ( + + + + + {c.name} + {c.location || t("companiesLocation")} + + openEdit(c)}> + + + + + + {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)} + + + + ))} + {companies.length === 0 ? ( + + {t("companiesEmpty")} + + ) : null} + + ) : ( + + + + + {t("companiesName")} + {t("companiesLocation")} + {t("companiesSource")} + {t("companiesPipeline")} + {t("companiesRecruiter")} + {t("companiesNextContact")} + + + + + {companies.map((c) => ( + + {c.name} + {c.location ?? ""} + {c.source ?? ""} + {c.pipelineStage ?? ""} + + {c.recruiterName ?? ""} + {c.recruiterEmail ? ` (${c.recruiterEmail})` : ""} + + {c.nextContactAt ? new Date(c.nextContactAt).toLocaleDateString() : ""} + + openEdit(c)}> + + + + + ))} + {companies.length === 0 && ( + + + + {t("companiesEmpty")} + + + + )} + +
+
+ )} + + setEditOpen(false)} fullWidth fullScreen={isMobile} maxWidth="sm"> {t("companiesEdit")} - + setEditing((p) => (p ? { ...p, name: e.target.value } : p))} - sx={{ gridColumn: "1 / -1" }} + sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} /> setRecruiterLinkedIn(e.target.value)} - sx={{ gridColumn: "1 / -1" }} + sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} /> - - - + diff --git a/job-tracker-ui/src/components/DashboardView.tsx b/job-tracker-ui/src/components/DashboardView.tsx index b9c8777..371dcf9 100644 --- a/job-tracker-ui/src/components/DashboardView.tsx +++ b/job-tracker-ui/src/components/DashboardView.tsx @@ -13,6 +13,7 @@ import { Stack, Typography, } from "@mui/material"; +import useMediaQuery from "@mui/material/useMediaQuery"; import { alpha, useTheme } from "@mui/material/styles"; import TuneIcon from "@mui/icons-material/Tune"; import TrendingUpIcon from "@mui/icons-material/TrendingUp"; @@ -110,7 +111,7 @@ function SectionCard({ children, sx = {} }: { children: React.ReactNode; sx?: an return ( (null); @@ -153,8 +155,8 @@ export default function DashboardView() { const appliedValues = analytics.map((x) => x.applied); const responseValues = analytics.map((x) => x.responses); - const chartWidth = 860; - const chartHeight = 250; + const chartWidth = isMobile ? Math.max(420, analytics.length * 70) : 860; + const chartHeight = isMobile ? 210 : 250; const appliedPath = buildLinePath(appliedValues, chartWidth, chartHeight); const responsePath = buildLinePath(responseValues, chartWidth, chartHeight); const tagColors = [theme.palette.primary.main, theme.palette.success.main, theme.palette.warning.main, theme.palette.info.main, theme.palette.error.main]; @@ -245,7 +247,7 @@ export default function DashboardView() { {t("dashboardHeroLabel")} - + {t("dashboardOverviewTitle")} @@ -259,7 +261,18 @@ export default function DashboardView() {
- + {([6, 12, 24] as const).map((m) => ( + + + + ) : ( + + { setSearch(e.target.value); setPage(0); }} + placeholder={t("jobTableSearchPlaceholder")} + size="small" + InputProps={{ startAdornment: }} + sx={{ width: { xs: "100%", md: "auto" }, minWidth: { xs: 0, md: 320 }, flex: { xs: "1 1 100%", md: "1 1 320px" } }} + /> + + + {t("jobTableStatus")} + + + + + {t("jobTableCompany")} + + + + { setLocationFilter(e.target.value); setPage(0); }} + sx={{ width: { xs: "100%", sm: 220 }, flex: { xs: "1 1 100%", md: "1 1 200px" } }} + /> + + + {mode === "jobs" ? { setNeedsFollowUpOnly(e.target.checked); setPage(0); }} />} label={t("jobTableNeedsFollowUp")} sx={{ mr: 0 }} /> : null} + {mode === "jobs" ? ( + + {t("jobTableReadiness")} + + + ) : null} + {mode === "jobs" ? { setIncludeDeleted(e.target.checked); setPage(0); }} />} label={t("jobTableShowDeleted")} sx={{ mr: 0 }} /> : null} + { setSearch(p.q ?? ""); setStatusFilter(p.status ?? "All"); setCompanyFilterId(p.companyId ?? "All"); setLocationFilter(p.location ?? ""); setNeedsFollowUpOnly(Boolean(p.needsFollowUp)); setPage(0); }} /> + {!isMobile ? setColumnsAnchor(e.currentTarget)}> : null} + - + )} {selectedIds.length > 0 ? ( {t("jobTableSelected", { count: selectedIds.length })} - - {mode === "trash" ? : } - {mode === "jobs" ? ["Waiting", "Interview", "Rejected", "Ghosted", "Offer"].map((s) => ) : null} + + {mode === "trash" ? : } + {mode === "jobs" ? statusOptions.map((status) => ) : null} ) : null} @@ -360,107 +460,248 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col ))} - - - - - 0 && !selectedAllOnPage} onChange={(e) => toggleSelectAll(e.target.checked)} /> - - requestSort("company")}>{t("jobTableCompany")} - requestSort("jobTitle")}>{t("jobTableRole")} - {columns.status ? requestSort("status")}>{t("jobTableStatus")} : null} - {columns.dateApplied ? requestSort("dateApplied")}>{t("jobTableDateApplied")} : null} - {columns.daysSince ? requestSort("daysSince")}>{t("jobTableDays")} : null} - {columns.jobUrl ? {t("settingsColumnJobUrl")} : null} - {t("jobTableActions")} - - - + + {isMobile ? ( + + + 0 && !selectedAllOnPage} onChange={(e) => toggleSelectAll(e.target.checked)} />} label={t("jobTableSelectAll")} sx={{ mr: 0 }} /> + + {filteredJobs.map((job) => { - const open = expanded.includes(job.id); const toneName = statusTone(job.status); - const tone = toneName === "error" ? theme.palette.error.main : toneName === "warning" ? theme.palette.warning.main : toneName === "success" ? theme.palette.success.main : toneName === "info" ? theme.palette.info.main : theme.palette.primary.main; const primaryAction = getPrimaryAction(job); const actionSignals = getActionSignals(job); + const tags = parseTags(job.tags).slice(0, 6); return ( - - - toggleSelected(job.id, e.target.checked)} /> - toggleExpanded(job.id)}>{open ? : } - {job.company?.name ?? ""} - - - {job.jobTitle} - {actionSignals.map((signal) => ( - - ))} - - - {columns.status ? : null} - {columns.dateApplied ? {new Date(job.dateApplied).toLocaleDateString()} : null} - {columns.daysSince ? {job.daysSince} : null} - {columns.jobUrl ? {job.jobUrl ? {t("jobTableLink")} : ""} : null} - - - {primaryAction ? ( - <> - - {t("editJobNextAction")} - - - - {primaryAction.detail} - - - ) : null} - - setEditJobId(job.id)}> - { setStatusJobId(job.id); setStatusAnchor(e.currentTarget); }}> - setDetailsJobId(job.id)}> - {(mode === "trash" || (includeDeleted && job.isDeleted)) ? void restore(job.id)}> : void softDelete(job)}>} + + + + + toggleSelected(job.id, e.target.checked)} sx={{ mt: -0.5, ml: -1 }} /> + + + {job.company?.name ?? t("jobTableCompany")} + + + {job.jobTitle} + - - - - - - - {t("jobTableLocation")}{job.location ?? "-"} - {t("addJobModalSalary")}{job.salary ?? "-"} - {t("settingsColumnJobUrl")}{job.jobUrl ? {t("jobTableOpenListing")} : "-"} - {t("jobTableSkills")}{parseTags(job.tags).length ? parseTags(job.tags).slice(0, 8).map((tag) => ) : {t("jobTableNoTags")}} - {t("jobTableOverview")}{generateOverview(job) || t("jobTableNoSummaryYet")} + {columns.status ? : null} + + + + {actionSignals.map((signal) => ( + + ))} + + + + {columns.dateApplied ? ( + + {t("jobTableDateApplied")} + {new Date(job.dateApplied).toLocaleDateString()} - - - - + ) : null} + {columns.daysSince ? ( + + {t("jobTableDays")} + {job.daysSince} + + ) : null} + + {t("jobTableLocation")} + {job.location ?? "-"} + + + {t("addJobModalSalary")} + {job.salary ?? "-"} + + + + {columns.jobUrl && job.jobUrl ? ( + + {t("settingsColumnJobUrl")} + {t("jobTableOpenListing")} + + ) : null} + + {tags.length > 0 ? ( + + {tags.map((tag) => )} + + ) : null} + + + {t("jobTableOverview")} + + {generateOverview(job) || t("jobTableNoSummaryYet")} + + + + {primaryAction ? ( + + + {t("editJobNextAction")} + + + + {primaryAction.detail} + + + ) : null} + + + + + + {(mode === "trash" || (includeDeleted && job.isDeleted)) ? ( + + ) : ( + + )} + + + ); })} - {filteredJobs.length === 0 ? {t("jobTableNoJobsFound")} : null} - -
+ {filteredJobs.length === 0 ? {t("jobTableNoJobsFound")} : null} + + ) : ( + + + + + 0 && !selectedAllOnPage} onChange={(e) => toggleSelectAll(e.target.checked)} /> + + requestSort("company")}>{t("jobTableCompany")} + requestSort("jobTitle")}>{t("jobTableRole")} + {columns.status ? requestSort("status")}>{t("jobTableStatus")} : null} + {columns.dateApplied ? requestSort("dateApplied")}>{t("jobTableDateApplied")} : null} + {columns.daysSince ? requestSort("daysSince")}>{t("jobTableDays")} : null} + {columns.jobUrl ? {t("settingsColumnJobUrl")} : null} + {t("jobTableActions")} + + + + {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 ( + + + toggleSelected(job.id, e.target.checked)} /> + toggleExpanded(job.id)}>{open ? : } + {job.company?.name ?? ""} + + + {job.jobTitle} + {actionSignals.map((signal) => ( + + ))} + + + {columns.status ? : null} + {columns.dateApplied ? {new Date(job.dateApplied).toLocaleDateString()} : null} + {columns.daysSince ? {job.daysSince} : null} + {columns.jobUrl ? {job.jobUrl ? {t("jobTableLink")} : ""} : null} + + + {primaryAction ? ( + <> + + {t("editJobNextAction")} + + + + {primaryAction.detail} + + + ) : null} + + setEditJobId(job.id)}> + { setStatusJobId(job.id); setStatusAnchor(e.currentTarget); }}> + setDetailsJobId(job.id)}> + {(mode === "trash" || (includeDeleted && job.isDeleted)) ? void restore(job.id)}> : void softDelete(job)}>} + + + + + + + + + {t("jobTableLocation")}{job.location ?? "-"} + {t("addJobModalSalary")}{job.salary ?? "-"} + {t("settingsColumnJobUrl")}{job.jobUrl ? {t("jobTableOpenListing")} : "-"} + {t("jobTableSkills")}{parseTags(job.tags).length ? parseTags(job.tags).slice(0, 8).map((tag) => ) : {t("jobTableNoTags")}} + {t("jobTableOverview")}{generateOverview(job) || t("jobTableNoSummaryYet")} + + + + + + ); + })} + {filteredJobs.length === 0 ? {t("jobTableNoJobsFound")} : null} + +
+
+ )} setPage(next)} rowsPerPage={pageSize} onRowsPerPageChange={(e) => { onPageSizeChange(Number(e.target.value) as 15 | 20 | 25); setPage(0); }} rowsPerPageOptions={[15, 20, 25]} />
{ setDetailsJobId(null); setDetailsInitialTab(0); setDetailsFollowUpMode(undefined); }} /> - setEditJobId(null)} onSaved={() => setReloadToken((t) => t + 1)} /> + setEditJobId(null)} onSaved={() => setReloadToken((token) => token + 1)} /> { setStatusAnchor(null); setStatusJobId(null); }}> - {(["Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const).map((s) => { if (statusJobId) void setStatusQuick(statusJobId, s); setStatusAnchor(null); setStatusJobId(null); }}>{t("jobTableSetStatus", { status: s })})} + {statusOptions.map((status) => { if (statusJobId) void setStatusQuick(statusJobId, status); setStatusAnchor(null); setStatusJobId(null); }}>{t("jobTableSetStatus", { status })})} ); diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 995ef8b..aec47c8 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -200,6 +200,9 @@ export const translations = { profileCvUploadFailed: "Failed to upload CV.", profileCvTextLabel: "Profile CV / master resume text", profileCvTextHelp: "Keep this updated and specific. Include recent roles, tools, achievements, measurable outcomes, and the work you want to be hired for next. If extraction misses something, edit it here manually.", + profileCvStructuredDefaultHint: "The structured CV stays front and center. Open the original extraction only when you need to verify or clean up parser output.", + profileCvRawPanelTitle: "Original extraction", + profileCvRawPanelHelp: "Usually messy, but useful for checking what the parser actually pulled from the uploaded file.", profileCvPreferredUploads: "Supported uploads: PDF, DOCX, TXT, MD, PNG, JPG, JPEG, WEBP.", profileCvSectionTools: "Section rewrite tools", profileCvStructureOverview: "CV structure overview", @@ -675,6 +678,7 @@ export const translations = { jobTableInterviewStage: "Interview stage", jobTableShowDeleted: "Show deleted", jobTableColumns: "Columns", + jobTableSelectAll: "Select all", jobTableSelected: "{count} selected", jobTableRestoreSelected: "Restore selected", jobTableDeleteSelected: "Delete selected", @@ -1101,6 +1105,9 @@ export const translations = { profileCvUploadFailed: "Kunne ikke laste opp CV.", profileCvTextLabel: "Profil-CV / hovedtekst for CV", profileCvTextHelp: "Hold denne oppdatert og konkret. Ta med nylige roller, verktøy, prestasjoner, målbare resultater og arbeidet du vil bli ansatt for neste gang. Hvis tekstuttrekket mangler noe, kan du redigere manuelt her.", + profileCvStructuredDefaultHint: "Den strukturerte CV-en er hovedvisningen. Åpne originaluttrekket bare når du vil kontrollere eller rydde opp i parserresultatet.", + profileCvRawPanelTitle: "Originalt uttrekk", + profileCvRawPanelHelp: "Som regel rotete, men nyttig når du vil se nøyaktig hva parseren hentet ut fra den opplastede filen.", profileCvPreferredUploads: "Støttede opplastinger: PDF, DOCX, TXT, MD, PNG, JPG, JPEG, WEBP.", profileCvSectionTools: "Verktøy for CV-seksjoner", profileCvStructureOverview: "Oversikt over CV-struktur", @@ -1576,6 +1583,7 @@ export const translations = { jobTableInterviewStage: "Intervjustadium", jobTableShowDeleted: "Vis slettede", jobTableColumns: "Kolonner", + jobTableSelectAll: "Velg alle", jobTableSelected: "{count} valgt", jobTableRestoreSelected: "Gjenopprett valgte", jobTableDeleteSelected: "Slett valgte", diff --git a/job-tracker-ui/src/layout/AppShell.tsx b/job-tracker-ui/src/layout/AppShell.tsx index bed598d..e7ffc2f 100644 --- a/job-tracker-ui/src/layout/AppShell.tsx +++ b/job-tracker-ui/src/layout/AppShell.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { AppBar, @@ -18,8 +18,10 @@ import { Toolbar, Typography, } from "@mui/material"; +import useMediaQuery from "@mui/material/useMediaQuery"; import MenuIcon from "@mui/icons-material/Menu"; +import MenuOpenIcon from "@mui/icons-material/MenuOpen"; import NotificationsNoneIcon from "@mui/icons-material/NotificationsNone"; import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined"; @@ -43,6 +45,8 @@ function initialsFrom(s?: string) { return (parts[0][0] + parts[1][0]).toUpperCase(); } +const DESKTOP_SIDEBAR_KEY = "appShellDesktopSidebarCollapsed"; + export default function AppShell({ pageTitle, breadcrumbs, @@ -79,7 +83,23 @@ export default function AppShell({ children: React.ReactNode; }) { const { t } = useI18n(); - const drawerWidth = 254; + const isMobile = useMediaQuery("(max-width:767.95px)"); + const [desktopNavCollapsed, setDesktopNavCollapsed] = useState(() => { + try { + return window.localStorage.getItem(DESKTOP_SIDEBAR_KEY) === "1"; + } catch { + return false; + } + }); + const drawerWidth = isMobile ? 254 : desktopNavCollapsed ? 84 : 254; + + useEffect(() => { + try { + window.localStorage.setItem(DESKTOP_SIDEBAR_KEY, desktopNavCollapsed ? "1" : "0"); + } catch { + // ignore storage failures + } + }, [desktopNavCollapsed]); const grouped = useMemo(() => { const groupItems = (items: NavItem[]) => { @@ -98,15 +118,15 @@ export default function AppShell({ }, [nav, navBottom]); const renderNavList = (groups: Array<[string, NavItem[]]>) => ( - + {groups.map(([section, rows]) => ( - - {section ? ( + + {section && !desktopNavCollapsed ? ( {section} ) : null} - + {rows.map((item) => { const selected = pathname === item.to || pathname.startsWith(item.to + "/"); return ( @@ -114,24 +134,28 @@ export default function AppShell({ key={item.to} selected={selected} onClick={() => onNavigate(item.to)} - sx={(theme: any) => ({ + title={desktopNavCollapsed ? item.label : undefined} + sx={(muiTheme: any) => ({ borderRadius: 2, mb: 0.5, + minHeight: 44, + px: desktopNavCollapsed ? 1 : 1.5, + justifyContent: desktopNavCollapsed ? "center" : "flex-start", border: "1px solid transparent", "&.Mui-selected": { - backgroundColor: theme.vars.palette.action.hover, - borderColor: theme.vars.palette.divider, + backgroundColor: muiTheme.vars.palette.action.hover, + borderColor: muiTheme.vars.palette.divider, }, })} > - + {item.badgeCount && item.badgeCount > 0 ? ( 99 ? "99+" : item.badgeCount}> {item.icon} ) : item.icon} - + {!desktopNavCollapsed ? : null} ); })} @@ -143,16 +167,20 @@ export default function AppShell({ const drawerContent = ( - - + + - - Jobbjakt - + {!desktopNavCollapsed ? ( + + + Jobbjakt + + + {t("appTagline")} + + + ) : null} - - {t("appTagline")} - @@ -179,66 +207,166 @@ export default function AppShell({ position="fixed" color="inherit" elevation={0} - sx={(theme: any) => ({ - borderBottom: `1px solid ${theme.vars.palette.grey[300]}`, - backgroundColor: theme.vars.palette.background.default, + sx={(muiTheme: any) => ({ + borderBottom: `1px solid ${muiTheme.vars.palette.grey[300]}`, + backgroundColor: muiTheme.vars.palette.background.default, backgroundImage: "none", - zIndex: theme.zIndex.drawer + 1, + zIndex: muiTheme.zIndex.drawer + 1, })} > - - onToggleDrawer(true)} - sx={{ display: { xs: "inline-flex", md: "none" }, border: "1px solid", borderColor: "divider", borderRadius: 2 }} - > - - + + {isMobile ? ( + <> + + + onToggleDrawer(true)} + sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42 }} + > + + - + + + + Jobbjakt + + + - - - - - - - - - - {rightActions} - - {user ? ( - - setUserMenuAnchor(e.currentTarget)} - sx={{ borderRadius: 2, border: "1px solid", borderColor: "divider" }} - > - {initials} - - - {user.userName || user.displayName || user.email || t("user")} - - {user.roleLabel || ""} - + {user ? ( + setUserMenuAnchor(e.currentTarget)} + sx={{ borderRadius: 2.5, border: "1px solid", borderColor: "divider", width: 42, height: 42, flex: "0 0 auto" }} + > + {initials} + + ) : } - - ) : null} + + + + + + + + + + + + + + *': { minWidth: 0 }, + }} + > + {rightActions} + + + + ) : ( + <> + + setDesktopNavCollapsed((value) => !value)} + title={desktopNavCollapsed ? "Expand sidebar" : "Collapse sidebar"} + sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2, width: 40, height: 40 }} + > + + + + + + + + + + + + + + + + {rightActions} + + + {user ? ( + + setUserMenuAnchor(e.currentTarget)} + sx={{ borderRadius: 2, border: "1px solid", borderColor: "divider" }} + > + {initials} + + + + {user.userName || user.displayName || user.email || t("user")} + + + {user.roleLabel || ""} + + + + ) : null} + + + )} ({ + sx={(muiTheme: any) => ({ display: { xs: "none", md: "block" }, width: drawerWidth, flexShrink: 0, [`& .MuiDrawer-paper`]: { width: drawerWidth, boxSizing: "border-box", - borderRight: `1px solid ${theme.vars.palette.grey[300]}`, - backgroundColor: theme.vars.palette.background.default, + borderRight: `1px solid ${muiTheme.vars.palette.grey[300]}`, + backgroundColor: muiTheme.vars.palette.background.default, backgroundImage: "none", boxShadow: "none", }, @@ -302,12 +430,12 @@ export default function AppShell({ open={drawerOpen} onClose={() => onToggleDrawer(false)} ModalProps={{ keepMounted: true }} - sx={(theme: any) => ({ + sx={(muiTheme: any) => ({ display: { xs: "block", md: "none" }, [`& .MuiDrawer-paper`]: { width: drawerWidth, - borderRight: `1px solid ${theme.vars.palette.grey[300]}`, - backgroundColor: theme.vars.palette.background.default, + borderRight: `1px solid ${muiTheme.vars.palette.grey[300]}`, + backgroundColor: muiTheme.vars.palette.background.default, backgroundImage: "none", }, })} @@ -324,18 +452,18 @@ export default function AppShell({ minHeight: "100vh", }} > - - - + + + - + {breadcrumbs.map((c) => ( - + {c} ))} - + {pageTitle} diff --git a/job-tracker-ui/src/pages/AdminUsersPage.tsx b/job-tracker-ui/src/pages/AdminUsersPage.tsx index ef85b72..e48db41 100644 --- a/job-tracker-ui/src/pages/AdminUsersPage.tsx +++ b/job-tracker-ui/src/pages/AdminUsersPage.tsx @@ -7,9 +7,11 @@ import { Chip, FormControlLabel, Paper, + Stack, TextField, Typography, } from "@mui/material"; +import useMediaQuery from "@mui/material/useMediaQuery"; import { DataGrid, GridColDef } from "@mui/x-data-grid"; import { api, getApiErrorMessage } from "../api"; @@ -26,6 +28,7 @@ type UserDto = { }; export default function AdminUsersPage() { + const isMobile = useMediaQuery("(max-width:767.95px)"); const { toast } = useToast(); const { confirmAction } = useDialogActions(); const { t } = useI18n(); @@ -149,23 +152,24 @@ export default function AdminUsersPage() { ], [remove, sendReset, setAdminRole, t]); return ( - + {t("adminUsersTitle")} {t("adminUsersSubtitle")} - + {t("adminUsersCreateUser")} - setNewEmail(e.target.value)} /> - setNewPassword(e.target.value)} /> + setNewEmail(e.target.value)} fullWidth /> + setNewPassword(e.target.value)} fullWidth /> - + setNewIsAdmin(e.target.checked)} />} label={t("adminUsersAdmin")} /> + + + + + + ); + })} + + ) : ( + + + {!loading && rows.length === 0 ? ( + {t("adminUsersNoUsers")} + ) : null} + + )} ); }