Polish UI, harden company creation, and add error pages

This commit is contained in:
cesnimda
2026-03-23 19:34:29 +01:00
parent 8f5eab2fe4
commit fcafda6f52
38 changed files with 2293 additions and 1269 deletions
+49 -40
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useState } from "react";
import { Box, Button, CssBaseline, ToggleButton, ToggleButtonGroup, Typography } from "@mui/material";
import { Box, Button, CssBaseline, Typography } from "@mui/material";
import useMediaQuery from "@mui/material/useMediaQuery";
import { CssVarsProvider } from "@mui/material/styles";
@@ -38,27 +38,39 @@ import AdminAuditPage from "./pages/AdminAuditPage";
import AdminUsersPage from "./pages/AdminUsersPage";
import AdminSystemPage from "./pages/AdminSystemPage";
import ResetPasswordPage from "./pages/ResetPasswordPage";
import NotFoundPage from "./pages/NotFoundPage";
import RouteErrorPage from "./pages/RouteErrorPage";
import { api } from "./api";
import { clearAuthToken, getAuthToken } from "./auth";
import AppShell, { NavItem } from "./layout/AppShell";
import { clearAccentColor, getAccentColor, getThemeModePref, setAccentColor, setThemeModePref, ThemeModePref } from "./themePrefs";
type AuthConfig = { requireAuth: boolean };
type MeResponse = { provider?: "local" | "google" | "external"; id?: string; email?: string; userName?: string; roles?: string[] };
type MeResponse = {
provider?: "local" | "google" | "external";
id?: string;
email?: string;
userName?: string;
firstName?: string;
lastName?: string;
displayName?: string;
avatarImageDataUrl?: string;
roles?: string[];
};
function breadcrumbsFor(path: string, t: (k: any) => string): string[] {
if (path.startsWith("/dashboard")) return ["Home", "Analytics", "Overview"];
if (path.startsWith("/jobs")) return ["Home", t("jobApplications")];
if (path.startsWith("/reminders")) return ["Home", t("reminders")];
if (path.startsWith("/kanban")) return ["Home", t("kanbanBoard")];
if (path.startsWith("/companies")) return ["Home", t("companies")];
if (path.startsWith("/trash")) return ["Home", t("trash")];
if (path.startsWith("/settings")) return ["Home", t("settings")];
if (path.startsWith("/profile")) return ["Home", "Account", "Profile"];
if (path.startsWith("/admin/audit")) return ["Home", "Admin", "Audit"];
if (path.startsWith("/admin/users")) return ["Home", "Admin", "Users"];
if (path.startsWith("/admin/system")) return ["Home", "Admin", "System"];
return ["Home"];
if (path.startsWith("/dashboard")) return [t("home"), t("analytics"), t("overview")];
if (path.startsWith("/jobs")) return [t("home"), t("jobApplications")];
if (path.startsWith("/reminders")) return [t("home"), t("reminders")];
if (path.startsWith("/kanban")) return [t("home"), t("kanbanBoard")];
if (path.startsWith("/companies")) return [t("home"), t("companies")];
if (path.startsWith("/trash")) return [t("home"), t("trash")];
if (path.startsWith("/settings")) return [t("home"), t("settings")];
if (path.startsWith("/profile")) return [t("home"), t("account"), t("profile")];
if (path.startsWith("/admin/audit")) return [t("home"), t("admin"), t("auditLog")];
if (path.startsWith("/admin/users")) return [t("home"), t("admin"), t("users")];
if (path.startsWith("/admin/system")) return [t("home"), t("admin"), t("system")];
return [t("home")];
}
function titleFor(path: string, t: (k: any) => string): string {
@@ -69,17 +81,17 @@ function titleFor(path: string, t: (k: any) => string): string {
if (path.startsWith("/companies")) return t("companies");
if (path.startsWith("/trash")) return t("trash");
if (path.startsWith("/settings")) return t("settings");
if (path.startsWith("/profile")) return "Profile";
if (path.startsWith("/admin/audit")) return "Audit log";
if (path.startsWith("/admin/users")) return "Users";
if (path.startsWith("/admin/system")) return "System status";
if (path.startsWith("/profile")) return t("profile");
if (path.startsWith("/admin/audit")) return t("auditLog");
if (path.startsWith("/admin/users")) return t("users");
if (path.startsWith("/admin/system")) return t("systemStatus");
return t("appTitle");
}
function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMode, onThemeModeChange, accentColor, onAccentColorChange, onResetAccentColor }: { jobPageSize: 15 | 20 | 25; setJobPageSize: (n: 15 | 20 | 25) => void; jobColumns: JobTableColumns; setJobColumns: (c: JobTableColumns) => void; themeMode: ThemeModePref; onThemeModeChange: (v: ThemeModePref) => void; accentColor: string; onAccentColorChange: (v: string) => void; onResetAccentColor: () => void; }) {
const location = useLocation();
const navigate = useNavigate();
const { language, setLanguage, t } = useI18n();
const { t } = useI18n();
const [addOpen, setAddOpen] = useState(false);
const [quickOpen, setQuickOpen] = useState(false);
@@ -126,31 +138,28 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
const breadcrumbs = breadcrumbsFor(path, t);
const setAndPersistPageSize = (n: 15 | 20 | 25) => { setJobPageSize(n); window.localStorage.setItem("jobPageSize", String(n)); };
const setAndPersistColumns = (next: JobTableColumns) => { setJobColumns(next); window.localStorage.setItem("jobColumns", JSON.stringify(next)); };
const fullName = [me?.firstName, me?.lastName].filter(Boolean).join(" ");
const nav: NavItem[] = [
{ to: "/dashboard", label: t("dashboard"), icon: <DashboardIcon fontSize="small" />, section: "Manage" },
{ to: "/jobs", label: t("jobApplications"), icon: <WorkOutlineIcon fontSize="small" />, section: "Manage" },
{ to: "/reminders", label: t("reminders"), icon: <AlarmIcon fontSize="small" />, badgeCount: notifCount, section: "Manage" },
{ to: "/kanban", label: t("kanbanBoard"), icon: <ViewKanbanIcon fontSize="small" />, section: "Manage" },
{ to: "/companies", label: t("companies"), icon: <BusinessIcon fontSize="small" />, section: "Manage" },
{ to: "/trash", label: t("trash"), icon: <DeleteOutlineIcon fontSize="small" />, section: "Manage" },
{ to: "/dashboard", label: t("dashboard"), icon: <DashboardIcon fontSize="small" />, section: t("manage") },
{ to: "/jobs", label: t("jobApplications"), icon: <WorkOutlineIcon fontSize="small" />, section: t("manage") },
{ to: "/reminders", label: t("reminders"), icon: <AlarmIcon fontSize="small" />, badgeCount: notifCount, section: t("manage") },
{ to: "/kanban", label: t("kanbanBoard"), icon: <ViewKanbanIcon fontSize="small" />, section: t("manage") },
{ to: "/companies", label: t("companies"), icon: <BusinessIcon fontSize="small" />, section: t("manage") },
{ to: "/trash", label: t("trash"), icon: <DeleteOutlineIcon fontSize="small" />, section: t("manage") },
];
const navBottom: NavItem[] = [
{ to: "/admin/audit", label: "Audit log", icon: <ShieldIcon fontSize="small" />, hidden: !isAdmin, section: "Admin" },
{ to: "/admin/users", label: "Users", icon: <AccountCircleIcon fontSize="small" />, hidden: !isAdmin, section: "Admin" },
{ to: "/admin/system", label: "System", icon: <MemoryIcon fontSize="small" />, hidden: !isAdmin, section: "Admin" },
{ to: "/profile", label: "Profile", icon: <AccountCircleIcon fontSize="small" />, section: "Account" },
{ to: "/settings", label: t("settings"), icon: <SettingsIcon fontSize="small" />, section: "Account" },
{ to: "/admin/audit", label: t("auditLog"), icon: <ShieldIcon fontSize="small" />, hidden: !isAdmin, section: t("admin") },
{ to: "/admin/users", label: t("users"), icon: <AccountCircleIcon fontSize="small" />, hidden: !isAdmin, section: t("admin") },
{ to: "/admin/system", label: t("system"), icon: <MemoryIcon fontSize="small" />, hidden: !isAdmin, section: t("admin") },
{ to: "/profile", label: t("profile"), icon: <AccountCircleIcon fontSize="small" />, section: t("account") },
{ to: "/settings", label: t("settings"), icon: <SettingsIcon fontSize="small" />, section: t("account") },
];
const rightActions = (
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
<Button variant="outlined" startIcon={<SearchIcon />} onClick={() => setQuickOpen(true)}>Quick Search</Button>
<ToggleButtonGroup size="small" exclusive value={language} onChange={(_, v) => v && setLanguage(v)}>
<ToggleButton value="en">EN</ToggleButton>
<ToggleButton value="no">NO</ToggleButton>
</ToggleButtonGroup>
<Button variant="outlined" startIcon={<SearchIcon />} onClick={() => setQuickOpen(true)}>{t("quickSearch")}</Button>
{isJobs ? <Button variant="contained" onClick={() => setAddOpen(true)}>{t("addJob")}</Button> : null}
</Box>
);
@@ -166,7 +175,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
drawerOpen={mobileDrawerOpen}
onToggleDrawer={setMobileDrawerOpen}
onNavigate={(to) => { setMobileDrawerOpen(false); navigate(to); }}
user={{ email: me?.email, userName: me?.userName, roleLabel: isAdmin ? "Super Admin" : "User" }}
user={{ email: me?.email, userName: me?.userName, displayName: me?.displayName || fullName || undefined, avatarImageDataUrl: me?.avatarImageDataUrl, roleLabel: isAdmin ? t("superAdmin") : t("user") }}
notificationsCount={notifCount}
onOpenNotifications={() => navigate("/reminders")}
onOpenSettings={() => navigate("/settings")}
@@ -187,7 +196,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
<Route path="/admin/system" element={<AdminSystemPage />} />
<Route path="/trash" element={<JobTable refreshToken={refreshToken} pageSize={jobPageSize} onPageSizeChange={setAndPersistPageSize} columns={jobColumns} onColumnsChange={setAndPersistColumns} mode="trash" />} />
<Route path="/settings" element={<SettingsView pageSize={jobPageSize} onPageSizeChange={setAndPersistPageSize} columns={jobColumns} onColumnsChange={setAndPersistColumns} themeMode={themeMode} onThemeModeChange={onThemeModeChange} accentColor={accentColor} onAccentColorChange={onAccentColorChange} onResetAccentColor={onResetAccentColor} />} />
<Route path="*" element={<Navigate to="/jobs" replace />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</AppShell>
@@ -231,9 +240,9 @@ export default function App() {
});
const router = useMemo(() => createBrowserRouter([
{ path: "/login", element: <LoginPage /> },
{ path: "/reset-password", element: <ResetPasswordPage /> },
{ path: "/*", element: <Shell jobPageSize={jobPageSize} setJobPageSize={setJobPageSize} jobColumns={jobColumns} setJobColumns={setJobColumns} themeMode={themeMode} onThemeModeChange={onThemeModeChange} accentColor={accentColor} onAccentColorChange={onAccentColorChange} onResetAccentColor={onResetAccentColor} /> },
{ path: "/login", element: <LoginPage />, errorElement: <RouteErrorPage /> },
{ path: "/reset-password", element: <ResetPasswordPage />, errorElement: <RouteErrorPage /> },
{ path: "/*", element: <Shell jobPageSize={jobPageSize} setJobPageSize={setJobPageSize} jobColumns={jobColumns} setJobColumns={setJobColumns} themeMode={themeMode} onThemeModeChange={onThemeModeChange} accentColor={accentColor} onAccentColorChange={onAccentColorChange} onResetAccentColor={onResetAccentColor} />, errorElement: <RouteErrorPage /> },
], { future: { v7_relativeSplatPath: true } }), [jobColumns, jobPageSize, themeMode, accentColor]);
return (