Dashboard upgrades, workflows added and assitant emailer

This commit is contained in:
cesnimda
2026-03-21 13:25:13 +01:00
parent 8cc4b0dfce
commit 51a539068f
9 changed files with 1358 additions and 1421 deletions
+64 -214
View File
@@ -1,9 +1,7 @@
import React, { useEffect, useMemo, useState } from "react";
import { Box, Button, CssBaseline, ToggleButton, ToggleButtonGroup, Typography } from "@mui/material";
import useMediaQuery from "@mui/material/useMediaQuery";
import { CssVarsProvider } from "@mui/material/styles";
import DashboardIcon from "@mui/icons-material/Dashboard";
@@ -15,6 +13,8 @@ import SettingsIcon from "@mui/icons-material/Settings";
import AlarmIcon from "@mui/icons-material/Alarm";
import AccountCircleIcon from "@mui/icons-material/AccountCircle";
import ShieldIcon from "@mui/icons-material/Shield";
import SearchIcon from "@mui/icons-material/Search";
import MemoryIcon from "@mui/icons-material/Memory";
import { Navigate, Route, Routes, useLocation, useNavigate, createBrowserRouter, RouterProvider } from "react-router-dom";
@@ -28,33 +28,21 @@ import DashboardView from "./components/DashboardView";
import CompaniesTable from "./components/CompaniesTable";
import SettingsView from "./components/SettingsView";
import RemindersView from "./components/RemindersView";
import QuickCommandDialog from "./components/QuickCommandDialog";
import { I18nProvider, useI18n } from "./i18n/I18nProvider";
import LoginPage from "./pages/LoginPage";
import ProfilePage from "./pages/ProfilePage";
import AdminAuditPage from "./pages/AdminAuditPage";
import AdminUsersPage from "./pages/AdminUsersPage";
import AdminSystemPage from "./pages/AdminSystemPage";
import ResetPasswordPage from "./pages/ResetPasswordPage";
import { api } from "./api";
import { clearAuthToken, getAuthToken } from "./auth";
import AppShell, { NavItem } from "./layout/AppShell";
import {
clearAccentColor,
getAccentColor,
getThemeModePref,
setAccentColor,
setThemeModePref,
ThemeModePref,
} from "./themePrefs";
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; roles?: string[] };
function breadcrumbsFor(path: string, t: (k: any) => string): string[] {
if (path.startsWith("/dashboard")) return ["Home", "Analytics", "Overview"];
@@ -67,6 +55,7 @@ function breadcrumbsFor(path: string, t: (k: any) => string): string[] {
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"];
}
@@ -79,36 +68,19 @@ function titleFor(path: string, t: (k: any) => string): string {
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/audit")) return "Audit log";
if (path.startsWith("/admin/users")) return "Users";
if (path.startsWith("/admin/system")) return "System status";
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;
}) {
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 [addOpen, setAddOpen] = useState(false);
const [quickOpen, setQuickOpen] = useState(false);
const [refreshToken, setRefreshToken] = useState(0);
const [requireAuth, setRequireAuth] = useState<boolean | null>(null);
const [isAdmin, setIsAdmin] = useState(false);
@@ -120,62 +92,38 @@ function Shell({
const isJobs = path.startsWith("/jobs");
useEffect(() => {
api
.get<AuthConfig>("/auth/config")
.then((r) => setRequireAuth(Boolean(r.data?.requireAuth)))
.catch(() => setRequireAuth(false));
api.get<AuthConfig>("/auth/config").then((r) => setRequireAuth(Boolean(r.data?.requireAuth))).catch(() => setRequireAuth(false));
}, []);
useEffect(() => {
api
.get<MeResponse>("/auth/me")
.then((r) => {
setMe(r.data);
setIsAdmin(Boolean(r.data?.roles?.includes("Admin")));
})
.catch(() => {
setMe(null);
setIsAdmin(false);
});
api.get<MeResponse>("/auth/me").then((r) => { setMe(r.data); setIsAdmin(Boolean(r.data?.roles?.includes("Admin"))); }).catch(() => { setMe(null); setIsAdmin(false); });
}, []);
useEffect(() => {
const load = () => {
api
.get<any[]>("/jobapplications/reminders", { params: { upcomingDays: 14 } })
.then((r) => setNotifCount(Array.isArray(r.data) ? r.data.length : 0))
.catch(() => setNotifCount(0));
api.get<any[]>("/jobapplications/reminders", { params: { upcomingDays: 14 } }).then((r) => setNotifCount(Array.isArray(r.data) ? r.data.length : 0)).catch(() => setNotifCount(0));
};
load();
const id = window.setInterval(load, 60000);
return () => window.clearInterval(id);
}, []);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setQuickOpen(true);
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
const token = getAuthToken();
if (requireAuth === null) {
return (
<Box sx={{ p: 4 }}>
<Typography variant="h6">Loading...</Typography>
</Box>
);
}
if (requireAuth && !token) {
return <Navigate to="/login" replace state={{ from: path }} />;
}
if (requireAuth === null) return <Box sx={{ p: 4 }}><Typography variant="h6">Loading...</Typography></Box>;
if (requireAuth && !token) return <Navigate to="/login" replace state={{ from: path }} />;
const pageTitle = titleFor(path, t);
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 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 nav: NavItem[] = [
{ to: "/dashboard", label: t("dashboard"), icon: <DashboardIcon fontSize="small" />, section: "Manage" },
@@ -187,23 +135,21 @@ function Shell({
];
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/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" },
];
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>
{isJobs ? (
<Button variant="contained" onClick={() => setAddOpen(true)}>
{t("addJob")}
</Button>
) : null}
{isJobs ? <Button variant="contained" onClick={() => setAddOpen(true)}>{t("addJob")}</Button> : null}
</Box>
);
@@ -217,172 +163,76 @@ function Shell({
navBottom={navBottom}
drawerOpen={mobileDrawerOpen}
onToggleDrawer={setMobileDrawerOpen}
onNavigate={(to) => {
setMobileDrawerOpen(false);
navigate(to);
}}
user={{
email: me?.email,
userName: me?.userName,
roleLabel: isAdmin ? "Super Admin" : "User",
}}
notificationsCount={notifCount} onOpenNotifications={() => navigate("/reminders")} onOpenSettings={() => navigate("/settings")} onOpenProfile={() => navigate("/profile")} onSignOut={() => { clearAuthToken(); navigate("/login"); }} rightActions={rightActions}
onNavigate={(to) => { setMobileDrawerOpen(false); navigate(to); }}
user={{ email: me?.email, userName: me?.userName, roleLabel: isAdmin ? "Super Admin" : "User" }}
notificationsCount={notifCount}
onOpenNotifications={() => navigate("/reminders")}
onOpenSettings={() => navigate("/settings")}
onOpenProfile={() => navigate("/profile")}
onSignOut={() => { clearAuthToken(); navigate("/login"); }}
rightActions={rightActions}
>
<Routes>
<Route path="/" element={<Navigate to="/jobs" replace />} />
<Route path="/dashboard" element={<DashboardView />} />
<Route
path="/jobs"
element={
<JobTable
refreshToken={refreshToken}
pageSize={jobPageSize}
onPageSizeChange={setAndPersistPageSize}
columns={jobColumns}
onColumnsChange={setAndPersistColumns}
mode="jobs"
/>
}
/>
<Route path="/jobs" element={<JobTable refreshToken={refreshToken} pageSize={jobPageSize} onPageSizeChange={setAndPersistPageSize} columns={jobColumns} onColumnsChange={setAndPersistColumns} mode="jobs" />} />
<Route path="/reminders" element={<RemindersView />} />
<Route path="/kanban" element={<KanbanBoard />} />
<Route path="/companies" element={<CompaniesTable />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/admin/audit" element={<AdminAuditPage />} /> <Route path="/admin/users" element={<AdminUsersPage />} />
<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="/admin/audit" element={<AdminAuditPage />} />
<Route path="/admin/users" element={<AdminUsersPage />} />
<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 />} />
</Routes>
</AppShell>
<AddJobModal
open={addOpen}
onClose={() => setAddOpen(false)}
onCreated={() => {
setRefreshToken((t) => t + 1);
setAddOpen(false);
}}
/>
<AddJobModal open={addOpen} onClose={() => setAddOpen(false)} onCreated={() => { setRefreshToken((t) => t + 1); }} />
<QuickCommandDialog open={quickOpen} onClose={() => setQuickOpen(false)} onNavigate={(to) => navigate(to)} onOpenAddJob={() => setAddOpen(true)} />
</>
);
}
export default function App() {
const systemPrefersDark = useMediaQuery("(prefers-color-scheme: dark)", { defaultMatches: true, noSsr: true });
const [themeMode, setThemeMode] = useState<ThemeModePref>(() => getThemeModePref());
const [accentColor, setAccentColorState] = useState<string>(() => getAccentColor());
const effectiveMode: "light" | "dark" =
themeMode === "light" ? "light" : themeMode === "dark" ? "dark" : systemPrefersDark ? "dark" : "light";
const effectiveMode: "light" | "dark" = themeMode === "light" ? "light" : themeMode === "dark" ? "dark" : systemPrefersDark ? "dark" : "light";
const theme = useMemo(() => getTheme(effectiveMode, accentColor), [effectiveMode, accentColor]);
useEffect(() => {
const sync = () => {
setThemeMode(getThemeModePref());
setAccentColorState(getAccentColor());
};
const onAuthChanged = () => sync();
window.addEventListener("auth-changed", onAuthChanged);
return () => window.removeEventListener("auth-changed", onAuthChanged);
useEffect(() => {
const sync = () => { setThemeMode(getThemeModePref()); setAccentColorState(getAccentColor()); };
window.addEventListener("auth-changed", sync);
return () => window.removeEventListener("auth-changed", sync);
}, []);
const onThemeModeChange = (v: ThemeModePref) => {
setThemeModePref(v);
setThemeMode(v);
};
const onAccentColorChange = (v: string) => {
setAccentColor(v);
setAccentColorState(getAccentColor());
};
const onResetAccentColor = () => {
clearAccentColor();
setAccentColorState(getAccentColor());
};
const onThemeModeChange = (v: ThemeModePref) => { setThemeModePref(v); setThemeMode(v); };
const onAccentColorChange = (v: string) => { setAccentColor(v); setAccentColorState(getAccentColor()); };
const onResetAccentColor = () => { clearAccentColor(); setAccentColorState(getAccentColor()); };
const [jobPageSize, setJobPageSize] = useState<15 | 20 | 25>(() => {
const raw = window.localStorage.getItem("jobPageSize");
const n = raw ? Number(raw) : 15;
return (n === 20 || n === 25 ? n : 15) as 15 | 20 | 25;
});
const [jobColumns, setJobColumns] = useState<JobTableColumns>(() => {
const raw = window.localStorage.getItem("jobColumns");
if (!raw) return { status: true, dateApplied: true, daysSince: true, jobUrl: false };
try {
const p = JSON.parse(raw) as Partial<JobTableColumns>;
return {
status: p.status ?? true,
dateApplied: p.dateApplied ?? true,
daysSince: p.daysSince ?? true,
jobUrl: p.jobUrl ?? false,
};
return { status: p.status ?? true, dateApplied: p.dateApplied ?? true, daysSince: p.daysSince ?? true, jobUrl: p.jobUrl ?? false };
} catch {
return { status: true, dateApplied: true, daysSince: true, jobUrl: false };
}
});
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}
/>
),
},
],
{
future: {
v7_relativeSplatPath: true,
},
},
),
[jobColumns, jobPageSize, themeMode, accentColor],
);
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} /> },
], { future: { v7_relativeSplatPath: true } }), [jobColumns, jobPageSize, themeMode, accentColor]);
return (
<ToastProvider>