refactor, security updates, cv extraction upgrades
This commit is contained in:
@@ -32,7 +32,7 @@ import ForgotPasswordPage from "./pages/ForgotPasswordPage";
|
||||
import ResetPasswordPage from "./pages/ResetPasswordPage";
|
||||
import RouteErrorPage from "./pages/RouteErrorPage";
|
||||
import { api } from "./api";
|
||||
import { clearAuthToken, getAuthToken } from "./auth";
|
||||
import { clearAuthClientState, setAuthUserKey } from "./auth";
|
||||
import AppShell, { NavItem } from "./layout/AppShell";
|
||||
import { clearAccentColor, getAccentColor, getThemeModePref, setAccentColor, setThemeModePref, ThemeModePref } from "./themePrefs";
|
||||
|
||||
@@ -112,6 +112,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
const [quickOpen, setQuickOpen] = useState(false);
|
||||
const [refreshToken, setRefreshToken] = useState(0);
|
||||
const [requireAuth, setRequireAuth] = useState<boolean | null>(null);
|
||||
const [authResolved, setAuthResolved] = useState(false);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [me, setMe] = useState<MeResponse | null>(null);
|
||||
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
|
||||
@@ -124,7 +125,26 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
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); });
|
||||
let active = true;
|
||||
api.get<MeResponse>("/auth/me")
|
||||
.then((r) => {
|
||||
if (!active) return;
|
||||
setMe(r.data);
|
||||
setIsAdmin(Boolean(r.data?.roles?.includes("Admin")));
|
||||
setAuthUserKey(r.data?.id || r.data?.email || r.data?.userName || null, false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!active) return;
|
||||
setMe(null);
|
||||
setIsAdmin(false);
|
||||
clearAuthClientState(false);
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setAuthResolved(true);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const load = () => {
|
||||
@@ -134,6 +154,27 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
const id = window.setInterval(load, 60000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const onAuthChanged = () => {
|
||||
setAuthResolved(false);
|
||||
api.get<MeResponse>("/auth/me")
|
||||
.then((r) => {
|
||||
setMe(r.data);
|
||||
setIsAdmin(Boolean(r.data?.roles?.includes("Admin")));
|
||||
setAuthUserKey(r.data?.id || r.data?.email || r.data?.userName || null, false);
|
||||
})
|
||||
.catch(() => {
|
||||
setMe(null);
|
||||
setIsAdmin(false);
|
||||
clearAuthClientState(false);
|
||||
})
|
||||
.finally(() => setAuthResolved(true));
|
||||
};
|
||||
|
||||
window.addEventListener("auth-changed", onAuthChanged);
|
||||
return () => window.removeEventListener("auth-changed", onAuthChanged);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
|
||||
@@ -145,9 +186,8 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
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 || !authResolved) return <Box sx={{ p: 4 }}><Typography variant="h6">Loading...</Typography></Box>;
|
||||
if (requireAuth && !me) return <Navigate to="/login" replace state={{ from: path }} />;
|
||||
|
||||
const pageTitle = titleFor(path, t);
|
||||
const breadcrumbs = breadcrumbsFor(path, t);
|
||||
@@ -223,7 +263,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
onOpenNotifications={() => navigate("/reminders")}
|
||||
onOpenSettings={() => navigate("/settings")}
|
||||
onOpenProfile={() => navigate("/profile")}
|
||||
onSignOut={() => { clearAuthToken(); navigate("/login"); }}
|
||||
onSignOut={() => { void api.post("/auth/logout").catch(() => undefined).finally(() => { clearAuthClientState(); navigate("/login"); }); }}
|
||||
rightActions={rightActions}
|
||||
>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
|
||||
+12
-10
@@ -1,6 +1,5 @@
|
||||
import axios from "axios";
|
||||
import { getAuthToken } from "./auth";
|
||||
import { clearAuthToken } from "./auth";
|
||||
import { clearAuthClientState, getCsrfToken } from "./auth";
|
||||
|
||||
export function getApiErrorMessage(error: any, fallback = "Request failed.") {
|
||||
const data = error?.response?.data;
|
||||
@@ -33,13 +32,19 @@ const defaultBaseUrl =
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: envBaseUrl && envBaseUrl.trim().length > 0 ? envBaseUrl : defaultBaseUrl,
|
||||
withCredentials: true,
|
||||
xsrfCookieName: "XSRF-TOKEN",
|
||||
xsrfHeaderName: "X-CSRF-TOKEN",
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = getAuthToken();
|
||||
if (token) {
|
||||
config.headers = config.headers ?? {};
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
const method = (config.method ?? "get").toUpperCase();
|
||||
if (["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
|
||||
const csrfToken = getCsrfToken();
|
||||
if (csrfToken) {
|
||||
config.headers = config.headers ?? {};
|
||||
config.headers["X-CSRF-TOKEN"] = csrfToken;
|
||||
}
|
||||
}
|
||||
return config;
|
||||
});
|
||||
@@ -47,12 +52,9 @@ api.interceptors.request.use((config) => {
|
||||
api.interceptors.response.use(
|
||||
(r) => r,
|
||||
(err) => {
|
||||
// If tokens expire (Google ID tokens are short-lived), clear and let the UI prompt the user.
|
||||
const status = err?.response?.status;
|
||||
if (status === 401) {
|
||||
clearAuthToken();
|
||||
// Avoid hard navigation loops; let views handle the missing token state.
|
||||
// We still reject so callers can show a toast if they want.
|
||||
clearAuthClientState();
|
||||
}
|
||||
return Promise.reject(err);
|
||||
},
|
||||
|
||||
+38
-56
@@ -1,13 +1,9 @@
|
||||
export const AUTH_TOKEN_KEY = "authToken";
|
||||
export const AUTH_REMEMBER_ME_KEY = "authRememberMe";
|
||||
const LEGACY_AUTH_TOKEN_KEY = "googleIdToken";
|
||||
const AUTH_TOKEN_PERSISTENCE_KEY = "authTokenPersistence";
|
||||
const AUTH_PERSISTENCE_KEY = "authTokenPersistence";
|
||||
const AUTH_USER_KEY = "authUserKey";
|
||||
const AUTH_CSRF_COOKIE = "XSRF-TOKEN";
|
||||
|
||||
type AuthPersistence = "local" | "session";
|
||||
|
||||
function normalizePersistence(value: string | null | undefined): AuthPersistence {
|
||||
return value === "session" ? "session" : "local";
|
||||
}
|
||||
export type AuthPersistence = "local" | "session";
|
||||
|
||||
function safeGet(storage: Storage, key: string): string | null {
|
||||
try {
|
||||
@@ -33,28 +29,27 @@ function safeRemove(storage: Storage, key: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function emitAuthChanged() {
|
||||
window.dispatchEvent(new Event("auth-changed"));
|
||||
}
|
||||
|
||||
function normalizePersistence(value: string | null | undefined): AuthPersistence {
|
||||
return value === "session" ? "session" : "local";
|
||||
}
|
||||
|
||||
function persistPreference(persistence: AuthPersistence) {
|
||||
safeSet(window.localStorage, AUTH_TOKEN_PERSISTENCE_KEY, persistence);
|
||||
safeSet(window.localStorage, AUTH_PERSISTENCE_KEY, persistence);
|
||||
safeSet(window.localStorage, AUTH_REMEMBER_ME_KEY, persistence === "local" ? "1" : "0");
|
||||
}
|
||||
|
||||
function getStoredPersistence(): AuthPersistence {
|
||||
const explicit = safeGet(window.localStorage, AUTH_TOKEN_PERSISTENCE_KEY);
|
||||
const explicit = safeGet(window.localStorage, AUTH_PERSISTENCE_KEY);
|
||||
if (explicit) return normalizePersistence(explicit);
|
||||
const rememberMe = safeGet(window.localStorage, AUTH_REMEMBER_ME_KEY);
|
||||
if (rememberMe === "0") return "session";
|
||||
return "local";
|
||||
}
|
||||
|
||||
function migrateLegacyToken(): string | null {
|
||||
const legacy = safeGet(window.localStorage, LEGACY_AUTH_TOKEN_KEY) ?? safeGet(window.sessionStorage, LEGACY_AUTH_TOKEN_KEY);
|
||||
if (!legacy) return null;
|
||||
safeRemove(window.localStorage, LEGACY_AUTH_TOKEN_KEY);
|
||||
safeRemove(window.sessionStorage, LEGACY_AUTH_TOKEN_KEY);
|
||||
setAuthToken(legacy, getStoredPersistence());
|
||||
return legacy;
|
||||
}
|
||||
|
||||
export function getRememberMePref(): boolean {
|
||||
return getAuthPersistencePreference() === "local";
|
||||
}
|
||||
@@ -63,53 +58,40 @@ export function setRememberMePref(value: boolean) {
|
||||
persistPreference(value ? "local" : "session");
|
||||
}
|
||||
|
||||
export function getAuthToken(): string | null {
|
||||
const localToken = safeGet(window.localStorage, AUTH_TOKEN_KEY);
|
||||
if (localToken) {
|
||||
persistPreference("local");
|
||||
return localToken;
|
||||
}
|
||||
|
||||
const sessionToken = safeGet(window.sessionStorage, AUTH_TOKEN_KEY);
|
||||
if (sessionToken) {
|
||||
persistPreference("session");
|
||||
return sessionToken;
|
||||
}
|
||||
|
||||
return migrateLegacyToken();
|
||||
}
|
||||
|
||||
export function getAuthPersistencePreference(): AuthPersistence {
|
||||
if (safeGet(window.sessionStorage, AUTH_TOKEN_KEY)) return "session";
|
||||
if (safeGet(window.localStorage, AUTH_TOKEN_KEY)) return "local";
|
||||
return getStoredPersistence();
|
||||
}
|
||||
|
||||
export function setAuthToken(token: string, persistence: AuthPersistence = "local") {
|
||||
safeRemove(window.localStorage, AUTH_TOKEN_KEY);
|
||||
safeRemove(window.sessionStorage, AUTH_TOKEN_KEY);
|
||||
|
||||
if (persistence === "session") {
|
||||
safeSet(window.sessionStorage, AUTH_TOKEN_KEY, token);
|
||||
} else {
|
||||
safeSet(window.localStorage, AUTH_TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
export function setAuthPersistencePreference(persistence: AuthPersistence) {
|
||||
persistPreference(persistence);
|
||||
emitAuthChanged();
|
||||
}
|
||||
|
||||
export function clearAuthToken() {
|
||||
safeRemove(window.localStorage, AUTH_TOKEN_KEY);
|
||||
safeRemove(window.sessionStorage, AUTH_TOKEN_KEY);
|
||||
export function getAuthUserKey(): string {
|
||||
return safeGet(window.localStorage, AUTH_USER_KEY) ?? "anon";
|
||||
}
|
||||
|
||||
export function decodeJwtPayload(token: string): any {
|
||||
export function setAuthUserKey(value: string | null | undefined, emit = true) {
|
||||
const next = typeof value === "string" ? value.trim() : "";
|
||||
if (!next) {
|
||||
safeRemove(window.localStorage, AUTH_USER_KEY);
|
||||
} else {
|
||||
safeSet(window.localStorage, AUTH_USER_KEY, next);
|
||||
}
|
||||
if (emit) emitAuthChanged();
|
||||
}
|
||||
|
||||
export function clearAuthClientState(emit = true) {
|
||||
safeRemove(window.localStorage, AUTH_USER_KEY);
|
||||
if (emit) emitAuthChanged();
|
||||
}
|
||||
|
||||
export function getCsrfToken(): string | null {
|
||||
try {
|
||||
const parts = token.split(".");
|
||||
if (parts.length < 2) return null;
|
||||
const base64 = parts[1].replaceAll("-", "+").replaceAll("_", "/");
|
||||
const json = atob(base64);
|
||||
return JSON.parse(json);
|
||||
const parts = document.cookie.split(";").map((part) => part.trim());
|
||||
const match = parts.find((part) => part.startsWith(`${AUTH_CSRF_COOKIE}=`));
|
||||
if (!match) return null;
|
||||
return decodeURIComponent(match.slice(AUTH_CSRF_COOKIE.length + 1));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Box, Button, Paper, Typography } from "@mui/material";
|
||||
|
||||
import { api } from "../api";
|
||||
import { clearAuthToken, getAuthToken } from "../auth";
|
||||
import { clearAuthClientState } from "../auth";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
@@ -25,19 +25,20 @@ type MeResponse = {
|
||||
export default function AuthStatusCard() {
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const token = getAuthToken();
|
||||
const [me, setMe] = useState<MeResponse | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setMe(null);
|
||||
return;
|
||||
}
|
||||
api
|
||||
.get<MeResponse>("/auth/me")
|
||||
.then((r) => setMe(r.data))
|
||||
.catch(() => setMe(null));
|
||||
}, [token]);
|
||||
const refresh = () => {
|
||||
api
|
||||
.get<MeResponse>("/auth/me")
|
||||
.then((r) => setMe(r.data))
|
||||
.catch(() => setMe(null));
|
||||
};
|
||||
|
||||
refresh();
|
||||
window.addEventListener("auth-changed", refresh);
|
||||
return () => window.removeEventListener("auth-changed", refresh);
|
||||
}, []);
|
||||
|
||||
const label = useMemo(() => me?.userName || me?.displayName || [me?.firstName, me?.lastName].filter(Boolean).join(" ") || me?.email, [me]);
|
||||
|
||||
@@ -47,7 +48,7 @@ export default function AuthStatusCard() {
|
||||
{t("authStatusTitle")}
|
||||
</Typography>
|
||||
|
||||
{!token ? (
|
||||
{!me ? (
|
||||
<Typography sx={{ color: "text.secondary" }}>{t("authStatusNotSignedIn")}</Typography>
|
||||
) : (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
|
||||
@@ -69,9 +70,11 @@ export default function AuthStatusCard() {
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
clearAuthToken();
|
||||
setMe(null);
|
||||
toast(t("signedOut"), "info");
|
||||
void api.post("/auth/logout").catch(() => undefined).finally(() => {
|
||||
setMe(null);
|
||||
clearAuthClientState();
|
||||
toast(t("signedOut"), "info");
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("signOut")}
|
||||
|
||||
@@ -23,10 +23,12 @@ import {
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import ViewStateNotice from "./ViewStateNotice";
|
||||
import { Company } from "../types";
|
||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { useViewResource } from "../hooks/useViewResource";
|
||||
|
||||
export default function CompaniesTable() {
|
||||
const isMobile = useMediaQuery("(max-width:767.95px)");
|
||||
@@ -34,7 +36,6 @@ export default function CompaniesTable() {
|
||||
const { t } = useI18n();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [companies, setCompanies] = useState<Company[]>([]);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Company | null>(null);
|
||||
|
||||
@@ -45,9 +46,19 @@ export default function CompaniesTable() {
|
||||
const [lastContactedAt, setLastContactedAt] = useState("");
|
||||
const [nextContactAt, setNextContactAt] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
api.get<Company[]>("/companies").then((r) => setCompanies(r.data)).catch((error) => toast(getApiErrorMessage(error, t("companiesUpdateFailed")), "error"));
|
||||
}, [t, toast]);
|
||||
const companiesResource = useViewResource(
|
||||
async () => {
|
||||
const response = await api.get<Company[]>("/companies");
|
||||
return response.data;
|
||||
},
|
||||
{
|
||||
initialData: [],
|
||||
errorMessage: t("companiesUpdateFailed"),
|
||||
deps: [t],
|
||||
},
|
||||
);
|
||||
|
||||
const companies = companiesResource.data;
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
@@ -88,7 +99,7 @@ export default function CompaniesTable() {
|
||||
nextContactAt: nextContactAt || null,
|
||||
});
|
||||
|
||||
setCompanies((prev) => prev.map((x) => (x.id === res.data.id ? res.data : x)));
|
||||
companiesResource.setData((prev) => prev.map((x) => (x.id === res.data.id ? res.data : x)));
|
||||
toast(t("companiesUpdated"), "success");
|
||||
setEditOpen(false);
|
||||
setEditing(null);
|
||||
@@ -106,82 +117,92 @@ export default function CompaniesTable() {
|
||||
|
||||
return (
|
||||
<Paper sx={{ mt: 0, p: { xs: 1.5, sm: 0 } }}>
|
||||
{isMobile ? (
|
||||
<Stack spacing={1.5}>
|
||||
{companies.map((c) => (
|
||||
<Paper key={c.id} sx={{ p: 1.5, borderRadius: 3 }}>
|
||||
<Stack spacing={1.25}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 1 }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 900, overflowWrap: "anywhere" }}>{c.name}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>{c.location || t("companiesLocation")}</Typography>
|
||||
</Box>
|
||||
<IconButton size="small" onClick={() => openEdit(c)}>
|
||||
<EditOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<ViewStateNotice
|
||||
loading={companiesResource.loading}
|
||||
error={companiesResource.error}
|
||||
title="Unable to load companies"
|
||||
description="The companies list is unavailable right now. Try again when the API is reachable."
|
||||
onRetry={companiesResource.reload}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1.25 }}>
|
||||
{renderCompanyMeta(t("companiesSource"), c.source)}
|
||||
{renderCompanyMeta(t("companiesPipeline"), c.pipelineStage)}
|
||||
{renderCompanyMeta(t("companiesRecruiter"), [c.recruiterName, c.recruiterEmail].filter(Boolean).join(" · "))}
|
||||
{renderCompanyMeta(t("companiesNextContact"), c.nextContactAt ? new Date(c.nextContactAt).toLocaleDateString() : null)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
{companies.length === 0 ? (
|
||||
<Typography sx={{ py: 2, textAlign: "center" }}>
|
||||
{t("companiesEmpty")}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Stack>
|
||||
) : (
|
||||
<TableContainer sx={{ borderRadius: 3, border: "1px solid", borderColor: "divider" }}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t("companiesName")}</TableCell>
|
||||
<TableCell>{t("companiesLocation")}</TableCell>
|
||||
<TableCell>{t("companiesSource")}</TableCell>
|
||||
<TableCell>{t("companiesPipeline")}</TableCell>
|
||||
<TableCell>{t("companiesRecruiter")}</TableCell>
|
||||
<TableCell>{t("companiesNextContact")}</TableCell>
|
||||
<TableCell width={1} align="right" />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{companies.map((c) => (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell>{c.name}</TableCell>
|
||||
<TableCell>{c.location ?? ""}</TableCell>
|
||||
<TableCell>{c.source ?? ""}</TableCell>
|
||||
<TableCell>{c.pipelineStage ?? ""}</TableCell>
|
||||
<TableCell>
|
||||
{c.recruiterName ?? ""}
|
||||
{c.recruiterEmail ? ` (${c.recruiterEmail})` : ""}
|
||||
</TableCell>
|
||||
<TableCell>{c.nextContactAt ? new Date(c.nextContactAt).toLocaleDateString() : ""}</TableCell>
|
||||
<TableCell align="right">
|
||||
{!companiesResource.loading && !companiesResource.error ? (
|
||||
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>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{companies.length === 0 && (
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1.25 }}>
|
||||
{renderCompanyMeta(t("companiesSource"), c.source)}
|
||||
{renderCompanyMeta(t("companiesPipeline"), c.pipelineStage)}
|
||||
{renderCompanyMeta(t("companiesRecruiter"), [c.recruiterName, c.recruiterEmail].filter(Boolean).join(" · "))}
|
||||
{renderCompanyMeta(t("companiesNextContact"), c.nextContactAt ? new Date(c.nextContactAt).toLocaleDateString() : null)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
{companies.length === 0 ? (
|
||||
<Typography sx={{ py: 2, textAlign: "center" }}>
|
||||
{t("companiesEmpty")}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Stack>
|
||||
) : (
|
||||
<TableContainer sx={{ borderRadius: 3, border: "1px solid", borderColor: "divider" }}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell colSpan={7}>
|
||||
<Typography sx={{ py: 2, textAlign: "center" }}>
|
||||
{t("companiesEmpty")}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{t("companiesName")}</TableCell>
|
||||
<TableCell>{t("companiesLocation")}</TableCell>
|
||||
<TableCell>{t("companiesSource")}</TableCell>
|
||||
<TableCell>{t("companiesPipeline")}</TableCell>
|
||||
<TableCell>{t("companiesRecruiter")}</TableCell>
|
||||
<TableCell>{t("companiesNextContact")}</TableCell>
|
||||
<TableCell width={1} align="right" />
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{companies.map((c) => (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell>{c.name}</TableCell>
|
||||
<TableCell>{c.location ?? ""}</TableCell>
|
||||
<TableCell>{c.source ?? ""}</TableCell>
|
||||
<TableCell>{c.pipelineStage ?? ""}</TableCell>
|
||||
<TableCell>
|
||||
{c.recruiterName ?? ""}
|
||||
{c.recruiterEmail ? ` (${c.recruiterEmail})` : ""}
|
||||
</TableCell>
|
||||
<TableCell>{c.nextContactAt ? new Date(c.nextContactAt).toLocaleDateString() : ""}</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton size="small" onClick={() => openEdit(c)}>
|
||||
<EditOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{companies.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7}>
|
||||
<Typography sx={{ py: 2, textAlign: "center" }}>
|
||||
{t("companiesEmpty")}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)
|
||||
) : null}
|
||||
|
||||
<Dialog open={editOpen} onClose={() => setEditOpen(false)} fullWidth fullScreen={isMobile} maxWidth="sm">
|
||||
<DialogTitle>{t("companiesEdit")}</DialogTitle>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import {
|
||||
@@ -22,10 +22,12 @@ import BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined";
|
||||
import AutoGraphIcon from "@mui/icons-material/AutoGraph";
|
||||
|
||||
import { api } from "../api";
|
||||
import ViewStateNotice from "./ViewStateNotice";
|
||||
import { getUserKeyFromToken } from "../themePrefs";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { buildWorkflowPath, getWorkflowAction } from "../jobWorkflowSignals";
|
||||
import { JobApplication } from "../types";
|
||||
import { useViewResource } from "../hooks/useViewResource";
|
||||
|
||||
interface JobStats {
|
||||
total: number;
|
||||
@@ -130,28 +132,58 @@ export default function DashboardView() {
|
||||
const isMobile = useMediaQuery("(max-width:767.95px)");
|
||||
const navigate = useNavigate();
|
||||
const { t } = useI18n();
|
||||
const [stats, setStats] = useState<JobStats | null>(null);
|
||||
const [overview, setOverview] = useState<OverviewAnalytics | null>(null);
|
||||
const [tagTrends, setTagTrends] = useState<TagTrendResponse | null>(null);
|
||||
const [analytics, setAnalytics] = useState<AnalyticsPoint[]>([]);
|
||||
const [tags, setTags] = useState<TagPoint[]>([]);
|
||||
const [months, setMonths] = useState<6 | 12 | 24>(12);
|
||||
const [reminderJobs, setReminderJobs] = useState<ReminderJob[]>([]);
|
||||
const [prefs, setPrefs] = useState<Prefs>(() => loadPrefs());
|
||||
const [prefsAnchor, setPrefsAnchor] = useState<HTMLElement | null>(null);
|
||||
const summaryResource = useViewResource(
|
||||
async () => {
|
||||
const [statsResponse, overviewResponse, remindersResponse] = await Promise.all([
|
||||
api.get<JobStats>("/jobapplications/stats"),
|
||||
api.get<OverviewAnalytics>("/jobapplications/analytics-overview"),
|
||||
api.get<ReminderJob[]>("/jobapplications/reminders", { params: { upcomingDays: 14 } }),
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<JobStats>("/jobapplications/stats").then((r) => setStats(r.data));
|
||||
api.get<OverviewAnalytics>("/jobapplications/analytics-overview").then((r) => setOverview(r.data)).catch(() => setOverview(null));
|
||||
api.get<ReminderJob[]>("/jobapplications/reminders", { params: { upcomingDays: 14 } }).then((r) => setReminderJobs(Array.isArray(r.data) ? r.data : [])).catch(() => setReminderJobs([]));
|
||||
}, []);
|
||||
return {
|
||||
stats: statsResponse.data,
|
||||
overview: overviewResponse.data,
|
||||
reminderJobs: Array.isArray(remindersResponse.data) ? remindersResponse.data : [],
|
||||
};
|
||||
},
|
||||
{
|
||||
initialData: { stats: null as JobStats | null, overview: null as OverviewAnalytics | null, reminderJobs: [] as ReminderJob[] },
|
||||
errorMessage: "Unable to load dashboard summary data right now.",
|
||||
deps: [],
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const params = { months };
|
||||
api.get<AnalyticsPoint[]>("/jobapplications/analytics", { params }).then((r) => setAnalytics(r.data ?? [])).catch(() => setAnalytics([]));
|
||||
api.get<TagPoint[]>("/jobapplications/tags", { params: { limit: 10, ...params } }).then((r) => setTags(r.data ?? [])).catch(() => setTags([]));
|
||||
api.get<TagTrendResponse>("/jobapplications/tag-trends", { params: { months, limit: 5 } }).then((r) => setTagTrends(r.data)).catch(() => setTagTrends(null));
|
||||
}, [months]);
|
||||
const trendsResource = useViewResource(
|
||||
async () => {
|
||||
const params = { months };
|
||||
const [analyticsResponse, tagsResponse, trendsResponse] = await Promise.all([
|
||||
api.get<AnalyticsPoint[]>("/jobapplications/analytics", { params }),
|
||||
api.get<TagPoint[]>("/jobapplications/tags", { params: { limit: 10, ...params } }),
|
||||
api.get<TagTrendResponse>("/jobapplications/tag-trends", { params: { months, limit: 5 } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
analytics: analyticsResponse.data ?? [],
|
||||
tags: tagsResponse.data ?? [],
|
||||
tagTrends: trendsResponse.data,
|
||||
};
|
||||
},
|
||||
{
|
||||
initialData: { analytics: [] as AnalyticsPoint[], tags: [] as TagPoint[], tagTrends: null as TagTrendResponse | null },
|
||||
errorMessage: "Unable to load dashboard trends right now.",
|
||||
deps: [months],
|
||||
},
|
||||
);
|
||||
|
||||
const stats = summaryResource.data.stats;
|
||||
const overview = summaryResource.data.overview;
|
||||
const reminderJobs = summaryResource.data.reminderJobs;
|
||||
const analytics = trendsResource.data.analytics;
|
||||
const tags = trendsResource.data.tags;
|
||||
const tagTrends = trendsResource.data.tagTrends;
|
||||
|
||||
const appliedValues = analytics.map((x) => x.applied);
|
||||
const responseValues = analytics.map((x) => x.responses);
|
||||
@@ -299,7 +331,23 @@ export default function DashboardView() {
|
||||
</Box>
|
||||
</SectionCard>
|
||||
|
||||
{prefs.cards ? (
|
||||
<ViewStateNotice
|
||||
loading={summaryResource.loading}
|
||||
error={summaryResource.error}
|
||||
title="Unable to load dashboard summary"
|
||||
description="The dashboard summary is unavailable right now."
|
||||
onRetry={summaryResource.reload}
|
||||
/>
|
||||
<ViewStateNotice
|
||||
loading={trendsResource.loading}
|
||||
error={trendsResource.error}
|
||||
title="Unable to load dashboard trends"
|
||||
description="Charts and trend panels could not reach the API."
|
||||
onRetry={trendsResource.reload}
|
||||
compact
|
||||
/>
|
||||
|
||||
{!summaryResource.loading && !summaryResource.error && prefs.cards ? (
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(2, 1fr)", xl: "repeat(4, 1fr)" }, gap: 2, mt: 2 }}>
|
||||
{metricCards.map((card) => (
|
||||
<SectionCard key={card.label}>
|
||||
@@ -322,7 +370,7 @@ export default function DashboardView() {
|
||||
) : null}
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "minmax(0, 1.8fr) minmax(320px, 0.9fr)" }, gap: 2, mt: 2 }}>
|
||||
{prefs.activity ? (
|
||||
{!summaryResource.loading && !summaryResource.error && prefs.activity ? (
|
||||
<SectionCard>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "flex-start" }}>
|
||||
<Box>
|
||||
@@ -364,7 +412,8 @@ export default function DashboardView() {
|
||||
</SectionCard>
|
||||
) : null}
|
||||
|
||||
<SectionCard>
|
||||
{!summaryResource.loading && !summaryResource.error ? (
|
||||
<SectionCard>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950 }}>{t("dashboardConversionFunnelTitle")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("dashboardResponseSources")}</Typography>
|
||||
<Stack spacing={1.2}>
|
||||
@@ -402,9 +451,11 @@ export default function DashboardView() {
|
||||
</Typography>
|
||||
</Box>
|
||||
</SectionCard>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "1.15fr 0.85fr" }, gap: 2, mt: 2 }}>
|
||||
{!summaryResource.loading && !summaryResource.error ? (
|
||||
<SectionCard>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>{t("remindersTitle")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("remindersSubtitle")}</Typography>
|
||||
@@ -432,8 +483,9 @@ export default function DashboardView() {
|
||||
<Button variant="text" onClick={() => navigate('/reminders')}>{t("reminders")}</Button>
|
||||
</Box>
|
||||
</SectionCard>
|
||||
) : null}
|
||||
|
||||
{prefs.companies ? (
|
||||
{!summaryResource.loading && !summaryResource.error && prefs.companies ? (
|
||||
<SectionCard>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>{t("dashboardTopCompaniesByActivity")}</Typography>
|
||||
<Stack spacing={1.25}>
|
||||
@@ -452,7 +504,7 @@ export default function DashboardView() {
|
||||
</SectionCard>
|
||||
) : null}
|
||||
|
||||
{prefs.skills ? (
|
||||
{!trendsResource.loading && !trendsResource.error && prefs.skills ? (
|
||||
<SectionCard>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>{t("dashboardTopSkills")}</Typography>
|
||||
{tags.length === 0 ? (
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { Box, Button, Chip, Paper, Typography } from "@mui/material";
|
||||
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { clearAuthToken, decodeJwtPayload, getAuthPersistencePreference, getAuthToken, setAuthToken } from "../auth";
|
||||
import { clearAuthClientState, getAuthPersistencePreference } from "../auth";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
@@ -49,26 +49,19 @@ function loadGoogleScript(): Promise<void> {
|
||||
export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void }) {
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const [token, setToken] = useState<string | null>(() => getAuthToken());
|
||||
const [me, setMe] = useState<MeResponse | null>(null);
|
||||
const [working, setWorking] = useState(false);
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const clientId = (process.env.REACT_APP_GOOGLE_CLIENT_ID || "").trim();
|
||||
const payload = useMemo(() => (token ? decodeJwtPayload(token) : null), [token]);
|
||||
const isRawGoogleToken = payload?.iss === "accounts.google.com" || payload?.iss === "https://accounts.google.com";
|
||||
|
||||
const actionLabel = !token
|
||||
const signedIn = Boolean(me?.provider);
|
||||
const actionLabel = !signedIn
|
||||
? t("continueWithGoogle")
|
||||
: me?.provider === "local" && !me?.googleLink?.linked
|
||||
? t("linkWithGoogle")
|
||||
: t("signInWithGoogle");
|
||||
|
||||
async function refreshMe() {
|
||||
if (!getAuthToken()) {
|
||||
setMe(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await api.get<MeResponse>("/auth/me");
|
||||
setMe(res.data);
|
||||
@@ -79,37 +72,19 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
|
||||
|
||||
useEffect(() => {
|
||||
void refreshMe();
|
||||
}, [token]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !isRawGoogleToken) return;
|
||||
let cancelled = false;
|
||||
const exchange = async () => {
|
||||
try {
|
||||
const res = await api.post<{ accessToken: string }>("/auth/google/exchange", { token });
|
||||
if (cancelled) return;
|
||||
setAuthToken(res.data.accessToken, getAuthPersistencePreference());
|
||||
setToken(res.data.accessToken);
|
||||
toast(t("googleSignedIn"), "success");
|
||||
onSignedIn?.();
|
||||
} catch {
|
||||
if (cancelled) return;
|
||||
clearAuthToken();
|
||||
setToken(null);
|
||||
toast(t("googleNotLinkedYet"), "info");
|
||||
}
|
||||
};
|
||||
void exchange();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [token, isRawGoogleToken, onSignedIn, toast, t]);
|
||||
const onAuthChanged = () => { void refreshMe(); };
|
||||
window.addEventListener("auth-changed", onAuthChanged);
|
||||
return () => window.removeEventListener("auth-changed", onAuthChanged);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!clientId || !host) return;
|
||||
|
||||
const shouldRenderButton = !token || isRawGoogleToken || (me?.provider === "local" && !me?.googleLink?.linked);
|
||||
const shouldRenderButton = !signedIn || (me?.provider === "local" && !me?.googleLink?.linked);
|
||||
host.replaceChildren();
|
||||
if (!shouldRenderButton) return;
|
||||
|
||||
@@ -126,13 +101,12 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
|
||||
setWorking(true);
|
||||
try {
|
||||
if (me?.provider === "local") {
|
||||
const res = await api.post<{ linked: boolean; email?: string | null }>("/auth/google/link", { token: credential });
|
||||
const res = await api.post<{ linked: boolean; email?: string | null }>("/auth/google/link", { token: credential, rememberMe: getAuthPersistencePreference() === "local" });
|
||||
toast(res.data?.email ? t("googleLinkedSuccessWithEmail", { email: res.data.email }) : t("googleLinkedSuccess"), "success");
|
||||
await refreshMe();
|
||||
} else {
|
||||
const res = await api.post<{ accessToken: string }>("/auth/google/exchange", { token: credential });
|
||||
setAuthToken(res.data.accessToken, getAuthPersistencePreference());
|
||||
setToken(res.data.accessToken);
|
||||
await api.post("/auth/google/exchange", { token: credential, rememberMe: getAuthPersistencePreference() === "local" });
|
||||
window.dispatchEvent(new Event("auth-changed"));
|
||||
toast(t("googleSignedIn"), "success");
|
||||
onSignedIn?.();
|
||||
}
|
||||
@@ -157,7 +131,7 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
|
||||
active = false;
|
||||
host.replaceChildren();
|
||||
};
|
||||
}, [clientId, me?.provider, me?.googleLink?.linked, onSignedIn, isRawGoogleToken, token, toast, t]);
|
||||
}, [clientId, me?.provider, me?.googleLink?.linked, onSignedIn, signedIn, toast, t]);
|
||||
|
||||
const signedInName = me?.userName || me?.displayName || [me?.firstName, me?.lastName].filter(Boolean).join(" ") || me?.email || "";
|
||||
|
||||
@@ -180,7 +154,7 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
|
||||
{me?.googleLink?.linkedAt ? <Chip size="small" variant="outlined" label={t("googleLinkedDate", { date: new Date(me.googleLink.linkedAt).toLocaleDateString() })} /> : null}
|
||||
</Box>
|
||||
|
||||
{!token ? (
|
||||
{!signedIn ? (
|
||||
<Typography sx={{ color: "text.secondary" }}>
|
||||
{t("googleSignInHint")}
|
||||
</Typography>
|
||||
@@ -204,14 +178,15 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
|
||||
{token ? (
|
||||
{signedIn ? (
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
clearAuthToken();
|
||||
setToken(null);
|
||||
setMe(null);
|
||||
toast(t("signedOut"), "info");
|
||||
void api.post("/auth/logout").catch(() => undefined).finally(() => {
|
||||
clearAuthClientState();
|
||||
setMe(null);
|
||||
toast(t("signedOut"), "info");
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("signOut")}
|
||||
@@ -239,7 +214,7 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
{token && me?.email ? (
|
||||
{signedIn && me?.email ? (
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
{t("signedInAs", { name: signedInName })}
|
||||
</Typography>
|
||||
|
||||
@@ -41,6 +41,7 @@ import ViewColumnIcon from "@mui/icons-material/ViewColumn";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
|
||||
import { api } from "../api";
|
||||
import ViewStateNotice from "./ViewStateNotice";
|
||||
import { useCompanies } from "../hooks/useCompanies";
|
||||
import { useDebouncedValue } from "../hooks/useDebouncedValue";
|
||||
import JobDetailsDialog from "./JobDetailsDialog";
|
||||
@@ -50,6 +51,7 @@ import SavedViewsMenu, { SavedViewParams } from "./SavedViewsMenu";
|
||||
import { useDialogActions } from "../dialogs";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { JobApplication } from "../types";
|
||||
import { useViewResource } from "../hooks/useViewResource";
|
||||
import { getWorkflowAction, needsInterviewPrep, needsWorkflowWork } from "../jobWorkflowSignals";
|
||||
|
||||
interface PagedResult<T> {
|
||||
@@ -127,7 +129,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
const debouncedLocation = useDebouncedValue(locationFilter, 250);
|
||||
const [needsFollowUpOnly, setNeedsFollowUpOnly] = useState(false);
|
||||
const [readinessFilter, setReadinessFilter] = useState<"all" | "needs-work" | "interview">("all");
|
||||
const { companies } = useCompanies();
|
||||
const { companies, error: companiesError, reload: reloadCompanies } = useCompanies();
|
||||
const [companyFilterId, setCompanyFilterId] = useState<number | "All">("All");
|
||||
const [detailsJobId, setDetailsJobId] = useState<number | null>(null);
|
||||
const [detailsInitialTab, setDetailsInitialTab] = useState(0);
|
||||
@@ -153,13 +155,25 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
needsFollowUp: needsFollowUpOnly ? true : undefined,
|
||||
}), [page, pageSize, debouncedSearch, statusFilter, companyFilterId, debouncedLocation, includeDeleted, mode, sortBy, sortDir, needsFollowUpOnly]);
|
||||
|
||||
const jobsResource = useViewResource(
|
||||
async () => {
|
||||
const r = await api.get<PagedResult<JobApplication>>("/jobapplications", { params });
|
||||
return r.data;
|
||||
},
|
||||
{
|
||||
initialData: { items: [], total: 0, page: 1, pageSize },
|
||||
errorMessage: "Unable to load jobs right now.",
|
||||
deps: [params, refreshToken, reloadToken, pageSize],
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<PagedResult<JobApplication>>("/jobapplications", { params }).then((r) => {
|
||||
setJobs(r.data.items);
|
||||
setTotal(r.data.total);
|
||||
setJobs(jobsResource.data.items);
|
||||
setTotal(jobsResource.data.total);
|
||||
if (!jobsResource.error) {
|
||||
setSelectedIds([]);
|
||||
});
|
||||
}, [params, refreshToken, reloadToken]);
|
||||
}
|
||||
}, [jobsResource.data, jobsResource.error]);
|
||||
|
||||
useEffect(() => {
|
||||
const paramsSearch = new URLSearchParams(location.search);
|
||||
@@ -460,6 +474,22 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
))}
|
||||
</Menu>
|
||||
|
||||
<ViewStateNotice
|
||||
error={jobsResource.error}
|
||||
title={mode === "trash" ? "Unable to load trash" : "Unable to load jobs"}
|
||||
description={mode === "trash" ? "The deleted-jobs view cannot reach the API right now." : "The jobs list cannot reach the API right now."}
|
||||
onRetry={jobsResource.reload}
|
||||
/>
|
||||
{companiesError ? (
|
||||
<ViewStateNotice
|
||||
error={companiesError}
|
||||
title="Unable to load company filters"
|
||||
description="Company filter data is unavailable right now."
|
||||
onRetry={reloadCompanies}
|
||||
compact
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Paper sx={{ mt: 2, overflow: "hidden" }}>
|
||||
{isMobile ? (
|
||||
<Stack spacing={1.25} sx={{ p: 1.25 }}>
|
||||
@@ -467,7 +497,8 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
<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) => {
|
||||
{jobsResource.loading ? <Typography sx={{ py: 2, textAlign: "center" }}>{t("loading")}</Typography> : null}
|
||||
{!jobsResource.loading && !jobsResource.error && filteredJobs.map((job) => {
|
||||
const toneName = statusTone(job.status);
|
||||
const primaryAction = getPrimaryAction(job);
|
||||
const actionSignals = getActionSignals(job);
|
||||
@@ -596,7 +627,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
{filteredJobs.length === 0 ? <Typography sx={{ py: 2, textAlign: "center" }}>{t("jobTableNoJobsFound")}</Typography> : null}
|
||||
{filteredJobs.length === 0 && !jobsResource.loading && !jobsResource.error ? <Typography sx={{ py: 2, textAlign: "center" }}>{t("jobTableNoJobsFound")}</Typography> : null}
|
||||
</Stack>
|
||||
) : (
|
||||
<Box sx={{ overflowX: "auto" }}>
|
||||
@@ -615,7 +646,8 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{filteredJobs.map((job) => {
|
||||
{jobsResource.loading ? <TableRow><TableCell colSpan={visibleDesktopColumns}><Typography sx={{ py: 2, textAlign: "center" }}>{t("loading")}</Typography></TableCell></TableRow> : null}
|
||||
{!jobsResource.loading && !jobsResource.error && 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;
|
||||
@@ -690,7 +722,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{filteredJobs.length === 0 ? <TableRow><TableCell colSpan={visibleDesktopColumns}><Typography sx={{ py: 2, textAlign: "center" }}>{t("jobTableNoJobsFound")}</Typography></TableCell></TableRow> : null}
|
||||
{filteredJobs.length === 0 && !jobsResource.loading && !jobsResource.error ? <TableRow><TableCell colSpan={visibleDesktopColumns}><Typography sx={{ py: 2, textAlign: "center" }}>{t("jobTableNoJobsFound")}</Typography></TableCell></TableRow> : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
Box,
|
||||
@@ -15,8 +15,10 @@ import { alpha, useTheme } from "@mui/material/styles";
|
||||
import MoreHorizIcon from "@mui/icons-material/MoreHoriz";
|
||||
|
||||
import { api } from "../api";
|
||||
import ViewStateNotice from "./ViewStateNotice";
|
||||
import { JobApplication } from "../types";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { useViewResource } from "../hooks/useViewResource";
|
||||
|
||||
const STATUSES = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const;
|
||||
type Status = (typeof STATUSES)[number];
|
||||
@@ -57,14 +59,23 @@ function statusLabel(t: (key: any, params?: any) => string, status: Status): str
|
||||
export default function KanbanBoard() {
|
||||
const theme = useTheme();
|
||||
const { t } = useI18n();
|
||||
const [jobs, setJobs] = useState<JobApplication[]>([]);
|
||||
const [dragJobId, setDragJobId] = useState<number | null>(null);
|
||||
const [menuAnchor, setMenuAnchor] = useState<null | HTMLElement>(null);
|
||||
const [menuJobId, setMenuJobId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<JobApplication[]>("/jobapplications/board").then((r) => setJobs(r.data));
|
||||
}, []);
|
||||
const jobsResource = useViewResource(
|
||||
async () => {
|
||||
const response = await api.get<JobApplication[]>("/jobapplications/board");
|
||||
return response.data;
|
||||
},
|
||||
{
|
||||
initialData: [],
|
||||
errorMessage: "Unable to load the board right now.",
|
||||
deps: [],
|
||||
},
|
||||
);
|
||||
|
||||
const jobs = jobsResource.data;
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const map = new Map<string, JobApplication[]>();
|
||||
@@ -85,12 +96,12 @@ export default function KanbanBoard() {
|
||||
if (!dragJobId) return;
|
||||
setDragJobId(null);
|
||||
await api.patch(`/jobapplications/${dragJobId}/status`, { status });
|
||||
setJobs((prev) => prev.map((j) => (j.id === dragJobId ? { ...j, status } : j)));
|
||||
jobsResource.setData((prev) => prev.map((j) => (j.id === dragJobId ? { ...j, status } : j)));
|
||||
};
|
||||
|
||||
const setStatus = async (id: number, status: Status) => {
|
||||
await api.patch(`/jobapplications/${id}/status`, { status });
|
||||
setJobs((prev) => prev.map((j) => (j.id === id ? { ...j, status } : j)));
|
||||
jobsResource.setData((prev) => prev.map((j) => (j.id === id ? { ...j, status } : j)));
|
||||
};
|
||||
|
||||
const currentMenuStatus = menuJobId == null ? null : normalizeStatus(jobs.find((j) => j.id === menuJobId)?.status ?? "");
|
||||
@@ -101,92 +112,102 @@ export default function KanbanBoard() {
|
||||
{t("kanbanHint")}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)", xl: "repeat(6, 1fr)" }, gap: 2, alignItems: "start" }}>
|
||||
{STATUSES.map((status) => {
|
||||
const c = toneColor(theme, status);
|
||||
const list = groups.get(status) ?? [];
|
||||
return (
|
||||
<Paper
|
||||
key={status}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={() => void onDropTo(status)}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 3,
|
||||
minHeight: 220,
|
||||
border: `1px solid ${alpha(c, theme.palette.mode === "dark" ? 0.25 : 0.18)}`,
|
||||
background: alpha(c, theme.palette.mode === "dark" ? 0.10 : 0.06),
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: theme.palette.mode === "dark" ? "#f8fafc" : "inherit" }}>
|
||||
{statusLabel(t, status)}
|
||||
</Typography>
|
||||
<Chip
|
||||
size="small"
|
||||
label={list.length}
|
||||
sx={{
|
||||
fontWeight: 800,
|
||||
color: alpha(c, theme.palette.mode === "dark" ? 0.95 : 0.9),
|
||||
backgroundColor: alpha(c, theme.palette.mode === "dark" ? 0.18 : 0.12),
|
||||
border: `1px solid ${alpha(c, theme.palette.mode === "dark" ? 0.35 : 0.22)}`,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<ViewStateNotice
|
||||
loading={jobsResource.loading}
|
||||
error={jobsResource.error}
|
||||
title="Unable to load the kanban board"
|
||||
description="The board could not reach the API."
|
||||
onRetry={jobsResource.reload}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
{list.map((j) => (
|
||||
<Card
|
||||
key={j.id}
|
||||
draggable
|
||||
onDragStart={() => setDragJobId(j.id)}
|
||||
onDragEnd={() => setDragJobId(null)}
|
||||
sx={{
|
||||
cursor: "grab",
|
||||
borderRadius: 3,
|
||||
border: `1px solid ${alpha(c, theme.palette.mode === "dark" ? 0.22 : 0.14)}`,
|
||||
background: theme.palette.mode === "dark" ? "rgba(15,23,42,0.82)" : "rgba(255,255,255,0.96)",
|
||||
backdropFilter: "blur(8px)",
|
||||
color: theme.palette.mode === "dark" ? "#e5eefc" : "#0f172a",
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 1.25, "&:last-child": { pb: 1.25 } }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}>
|
||||
<Typography sx={{ fontWeight: 800, lineHeight: 1.25, color: theme.palette.mode === "dark" ? "#f8fafc" : "#0f172a" }}>
|
||||
{j.company?.name ?? ""}
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
sx={{ color: theme.palette.mode === "dark" ? "#e2e8f0" : "#0f172a" }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMenuJobId(j.id);
|
||||
setMenuAnchor(e.currentTarget);
|
||||
}}
|
||||
>
|
||||
<MoreHorizIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: theme.palette.mode === "dark" ? "#cbd5e1" : "#475569" }}>
|
||||
{j.jobTitle}
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, mt: 1, flexWrap: "wrap" }}>
|
||||
<Chip size="small" label={`${j.daysSince}d`} sx={{ color: theme.palette.mode === "dark" ? "#e2e8f0" : "#0f172a", backgroundColor: theme.palette.mode === "dark" ? "rgba(148,163,184,0.18)" : "rgba(148,163,184,0.18)" }} />
|
||||
{j.location ? <Chip size="small" label={j.location} sx={{ color: theme.palette.mode === "dark" ? "#e2e8f0" : "#0f172a", backgroundColor: theme.palette.mode === "dark" ? "rgba(148,163,184,0.18)" : "rgba(148,163,184,0.18)" }} /> : null}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{list.length === 0 && (
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", py: 1 }}>
|
||||
{t("kanbanDropHere")}
|
||||
{!jobsResource.loading && !jobsResource.error ? (
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)", xl: "repeat(6, 1fr)" }, gap: 2, alignItems: "start" }}>
|
||||
{STATUSES.map((status) => {
|
||||
const c = toneColor(theme, status);
|
||||
const list = groups.get(status) ?? [];
|
||||
return (
|
||||
<Paper
|
||||
key={status}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={() => void onDropTo(status)}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 3,
|
||||
minHeight: 220,
|
||||
border: `1px solid ${alpha(c, theme.palette.mode === "dark" ? 0.25 : 0.18)}`,
|
||||
background: alpha(c, theme.palette.mode === "dark" ? 0.10 : 0.06),
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: theme.palette.mode === "dark" ? "#f8fafc" : "inherit" }}>
|
||||
{statusLabel(t, status)}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
<Chip
|
||||
size="small"
|
||||
label={list.length}
|
||||
sx={{
|
||||
fontWeight: 800,
|
||||
color: alpha(c, theme.palette.mode === "dark" ? 0.95 : 0.9),
|
||||
backgroundColor: alpha(c, theme.palette.mode === "dark" ? 0.18 : 0.12),
|
||||
border: `1px solid ${alpha(c, theme.palette.mode === "dark" ? 0.35 : 0.22)}`,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
{list.map((j) => (
|
||||
<Card
|
||||
key={j.id}
|
||||
draggable
|
||||
onDragStart={() => setDragJobId(j.id)}
|
||||
onDragEnd={() => setDragJobId(null)}
|
||||
sx={{
|
||||
cursor: "grab",
|
||||
borderRadius: 3,
|
||||
border: `1px solid ${alpha(c, theme.palette.mode === "dark" ? 0.22 : 0.14)}`,
|
||||
background: theme.palette.mode === "dark" ? "rgba(15,23,42,0.82)" : "rgba(255,255,255,0.96)",
|
||||
backdropFilter: "blur(8px)",
|
||||
color: theme.palette.mode === "dark" ? "#e5eefc" : "#0f172a",
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 1.25, "&:last-child": { pb: 1.25 } }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}>
|
||||
<Typography sx={{ fontWeight: 800, lineHeight: 1.25, color: theme.palette.mode === "dark" ? "#f8fafc" : "#0f172a" }}>
|
||||
{j.company?.name ?? ""}
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
sx={{ color: theme.palette.mode === "dark" ? "#e2e8f0" : "#0f172a" }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMenuJobId(j.id);
|
||||
setMenuAnchor(e.currentTarget);
|
||||
}}
|
||||
>
|
||||
<MoreHorizIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: theme.palette.mode === "dark" ? "#cbd5e1" : "#475569" }}>
|
||||
{j.jobTitle}
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, mt: 1, flexWrap: "wrap" }}>
|
||||
<Chip size="small" label={`${j.daysSince}d`} sx={{ color: theme.palette.mode === "dark" ? "#e2e8f0" : "#0f172a", backgroundColor: theme.palette.mode === "dark" ? "rgba(148,163,184,0.18)" : "rgba(148,163,184,0.18)" }} />
|
||||
{j.location ? <Chip size="small" label={j.location} sx={{ color: theme.palette.mode === "dark" ? "#e2e8f0" : "#0f172a", backgroundColor: theme.palette.mode === "dark" ? "rgba(148,163,184,0.18)" : "rgba(148,163,184,0.18)" }} /> : null}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{list.length === 0 && (
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", py: 1 }}>
|
||||
{t("kanbanDropHere")}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<Menu anchorEl={menuAnchor} open={Boolean(menuAnchor)} onClose={() => { setMenuAnchor(null); setMenuJobId(null); }}>
|
||||
{(["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const)
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Box, Button, Chip, Divider, Paper, Typography } from "@mui/material";
|
||||
|
||||
import { api } from "../api";
|
||||
import ViewStateNotice from "./ViewStateNotice";
|
||||
import { JobApplication } from "../types";
|
||||
import { buildWorkflowPath, getReminderGroup, getWorkflowAction } from "../jobWorkflowSignals";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { useViewResource } from "../hooks/useViewResource";
|
||||
|
||||
type ReminderGroups = {
|
||||
missingCv: JobApplication[];
|
||||
@@ -41,26 +43,27 @@ function ReminderSection({ title, items, onOpen, onSetFollowUp }: { title: strin
|
||||
});
|
||||
|
||||
return (
|
||||
<Paper key={j.id} sx={{ p: 1.5, display: "grid", gridTemplateColumns: "1fr auto", gap: 1, alignItems: "center" }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 900, lineHeight: 1.25 }}>
|
||||
{j.company?.name ?? ""} <span style={{ fontWeight: 700, opacity: 0.7 }}>•</span> {j.jobTitle}
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, mt: 0.5, flexWrap: "wrap" }}>
|
||||
{j.needsFollowUp ? <Chip size="small" color="warning" label={t("remindersFollowUpLabel")} /> : null}
|
||||
{(j.workflowSignal?.reason ?? j.followUpReason) ? <Chip size="small" label={j.workflowSignal?.reason ?? j.followUpReason} variant="outlined" /> : null}
|
||||
{j.followUpAt ? <Chip size="small" label={t("remindersFollowUpDate", { date: new Date(j.followUpAt).toLocaleDateString() })} variant="outlined" /> : null}
|
||||
<Chip size="small" label={j.status} variant="outlined" />
|
||||
<Paper key={j.id} sx={{ p: 1.5, display: "grid", gridTemplateColumns: "1fr auto", gap: 1, alignItems: "center" }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 900, lineHeight: 1.25 }}>
|
||||
{j.company?.name ?? ""} <span style={{ fontWeight: 700, opacity: 0.7 }}>•</span> {j.jobTitle}
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, mt: 0.5, flexWrap: "wrap" }}>
|
||||
{j.needsFollowUp ? <Chip size="small" color="warning" label={t("remindersFollowUpLabel")} /> : null}
|
||||
{(j.workflowSignal?.reason ?? j.followUpReason) ? <Chip size="small" label={j.workflowSignal?.reason ?? j.followUpReason} variant="outlined" /> : null}
|
||||
{j.followUpAt ? <Chip size="small" label={t("remindersFollowUpDate", { date: new Date(j.followUpAt).toLocaleDateString() })} variant="outlined" /> : null}
|
||||
<Chip size="small" label={j.status} variant="outlined" />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", justifyContent: "flex-end" }}>
|
||||
<Button size="small" variant="outlined" onClick={() => onOpen(j)}>{action?.label ?? t("remindersOpen")}</Button>
|
||||
<Button size="small" variant="outlined" onClick={() => onSetFollowUp(j.id, 3)}>+3d</Button>
|
||||
<Button size="small" variant="outlined" onClick={() => onSetFollowUp(j.id, 7)}>+7d</Button>
|
||||
<Button size="small" onClick={() => onSetFollowUp(j.id, null)}>{t("remindersClear")}</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
)})}
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", justifyContent: "flex-end" }}>
|
||||
<Button size="small" variant="outlined" onClick={() => onOpen(j)}>{action?.label ?? t("remindersOpen")}</Button>
|
||||
<Button size="small" variant="outlined" onClick={() => onSetFollowUp(j.id, 3)}>+3d</Button>
|
||||
<Button size="small" variant="outlined" onClick={() => onSetFollowUp(j.id, 7)}>+7d</Button>
|
||||
<Button size="small" onClick={() => onSetFollowUp(j.id, null)}>{t("remindersClear")}</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -69,17 +72,20 @@ export default function RemindersView() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const [items, setItems] = useState<JobApplication[]>([]);
|
||||
|
||||
const load = async () => {
|
||||
const res = await api.get<JobApplication[]>("/jobapplications/reminders", { params: { upcomingDays: 14 } });
|
||||
setItems(res.data);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
const remindersResource = useViewResource(
|
||||
async () => {
|
||||
const res = await api.get<JobApplication[]>("/jobapplications/reminders", { params: { upcomingDays: 14 } });
|
||||
return Array.isArray(res.data) ? res.data : [];
|
||||
},
|
||||
{
|
||||
initialData: [],
|
||||
errorMessage: "Unable to load reminders right now.",
|
||||
deps: [],
|
||||
},
|
||||
);
|
||||
|
||||
const items = remindersResource.data;
|
||||
const grouped = useMemo(() => groupItems(items), [items]);
|
||||
|
||||
const openJob = (job: JobApplication) => {
|
||||
@@ -91,7 +97,7 @@ export default function RemindersView() {
|
||||
const d = daysFromNow === null ? null : new Date(Date.now() + daysFromNow * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
await api.patch(`/jobapplications/${id}/followup`, { followUpAt: d });
|
||||
toast(daysFromNow === null ? t("remindersFollowUpCleared") : t("remindersFollowUpSet"), "success");
|
||||
await load();
|
||||
await remindersResource.reload();
|
||||
} catch {
|
||||
toast(t("remindersFollowUpFailed"), "error");
|
||||
}
|
||||
@@ -104,14 +110,24 @@ export default function RemindersView() {
|
||||
{t("remindersSubtitle")}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<ReminderSection title={t("remindersMissingTailoredCv")} items={grouped.missingCv} onOpen={openJob} onSetFollowUp={setFollowUp} />
|
||||
<ReminderSection title={t("remindersMissingInterviewPrep")} items={grouped.missingInterviewNotes} onOpen={openJob} onSetFollowUp={setFollowUp} />
|
||||
<ReminderSection title={t("remindersFollowUpDue")} items={grouped.overdueFollowUp} onOpen={openJob} onSetFollowUp={setFollowUp} />
|
||||
<ReminderSection title={t("remindersOther")} items={grouped.other} onOpen={openJob} onSetFollowUp={setFollowUp} />
|
||||
<ViewStateNotice
|
||||
loading={remindersResource.loading}
|
||||
error={remindersResource.error}
|
||||
title="Unable to load reminders"
|
||||
description="The reminders view cannot reach the API right now."
|
||||
onRetry={remindersResource.reload}
|
||||
/>
|
||||
|
||||
{items.length === 0 ? <Typography sx={{ color: "text.secondary", textAlign: "center", py: 3 }}>{t("remindersNothing")}</Typography> : null}
|
||||
</Box>
|
||||
{!remindersResource.loading && !remindersResource.error ? (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<ReminderSection title={t("remindersMissingTailoredCv")} items={grouped.missingCv} onOpen={openJob} onSetFollowUp={setFollowUp} />
|
||||
<ReminderSection title={t("remindersMissingInterviewPrep")} items={grouped.missingInterviewNotes} onOpen={openJob} onSetFollowUp={setFollowUp} />
|
||||
<ReminderSection title={t("remindersFollowUpDue")} items={grouped.overdueFollowUp} onOpen={openJob} onSetFollowUp={setFollowUp} />
|
||||
<ReminderSection title={t("remindersOther")} items={grouped.other} onOpen={openJob} onSetFollowUp={setFollowUp} />
|
||||
|
||||
{items.length === 0 ? <Typography sx={{ color: "text.secondary", textAlign: "center", py: 3 }}>{t("remindersNothing")}</Typography> : null}
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { Box, Button, Paper, TextField, Typography } from "@mui/material";
|
||||
|
||||
import { api } from "../api";
|
||||
import { getAuthToken } from "../auth";
|
||||
import { useToast } from "../toast";
|
||||
import { useDialogActions } from "../dialogs";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
@@ -20,7 +19,6 @@ export default function UserManagementCard() {
|
||||
const { toast } = useToast();
|
||||
const { confirmAction } = useDialogActions();
|
||||
const { t } = useI18n();
|
||||
const token = getAuthToken();
|
||||
|
||||
const [supported, setSupported] = useState<boolean | null>(null);
|
||||
const [users, setUsers] = useState<UserDto[]>([]);
|
||||
@@ -30,8 +28,6 @@ export default function UserManagementCard() {
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [newIsAdmin, setNewIsAdmin] = useState(false);
|
||||
|
||||
const canRender = useMemo(() => Boolean(token), [token]);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -52,17 +48,15 @@ export default function UserManagementCard() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!canRender) {
|
||||
setSupported(null);
|
||||
setUsers([]);
|
||||
return;
|
||||
}
|
||||
void load();
|
||||
const onAuthChanged = () => { void load(); };
|
||||
window.addEventListener("auth-changed", onAuthChanged);
|
||||
return () => window.removeEventListener("auth-changed", onAuthChanged);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [canRender]);
|
||||
}, []);
|
||||
|
||||
if (!canRender) return null;
|
||||
if (supported === false) return null;
|
||||
if (supported === null) return null;
|
||||
|
||||
return (
|
||||
<Paper sx={{ mt: 2, p: 2 }}>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from "react";
|
||||
|
||||
import { Alert, Box, Button, CircularProgress, Typography } from "@mui/material";
|
||||
|
||||
import type { ViewResourceError } from "../hooks/useViewResource";
|
||||
|
||||
type Props = {
|
||||
loading?: boolean;
|
||||
error?: ViewResourceError | null;
|
||||
title: string;
|
||||
description?: string;
|
||||
retryLabel?: string;
|
||||
onRetry?: () => void | Promise<void>;
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
export default function ViewStateNotice({ loading = false, error = null, title, description, retryLabel = "Retry", onRetry, compact = false }: Props) {
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ py: compact ? 3 : 6, display: "flex", justifyContent: "center" }}>
|
||||
<CircularProgress size={compact ? 24 : 28} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!error) return null;
|
||||
|
||||
const severity = error.kind === "unauthorized" ? "warning" : error.kind === "unavailable" ? "error" : "error";
|
||||
|
||||
return (
|
||||
<Alert
|
||||
severity={severity}
|
||||
sx={{
|
||||
mb: compact ? 1.5 : 2,
|
||||
alignItems: "flex-start",
|
||||
borderRadius: 3,
|
||||
}}
|
||||
action={error.retryable && onRetry ? <Button color="inherit" size="small" onClick={() => void onRetry()}>{retryLabel}</Button> : undefined}
|
||||
>
|
||||
<Typography sx={{ fontWeight: 800, mb: 0.35 }}>{title}</Typography>
|
||||
{description ? <Typography variant="body2">{description}</Typography> : null}
|
||||
{error.message ? <Typography variant="body2" sx={{ mt: 0.5 }}>{error.message}</Typography> : null}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -209,7 +209,7 @@ test('reminders open action routes tailored-cv gaps into the tailored cv workspa
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('location-indicator')).toHaveTextContent('/jobs');
|
||||
});
|
||||
expect(await screen.findByText(/build the package here, then save the working copy back onto this job/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/saved working material/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('job table urgency signals and next actions route into the shared workspace flow', async () => {
|
||||
@@ -231,6 +231,6 @@ test('job table urgency signals and next actions route into the shared workspace
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('location-indicator')).toHaveTextContent('/jobs');
|
||||
});
|
||||
expect(await screen.findByText(/build the package here, then save the working copy back onto this job/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/saved working material/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/platform work/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { api } from "../api";
|
||||
import { Company } from "../types";
|
||||
import { useViewResource, ViewResourceError } from "./useViewResource";
|
||||
|
||||
let cachedCompanies: Company[] | null = null;
|
||||
let inflight: Promise<Company[]> | null = null;
|
||||
@@ -10,7 +12,7 @@ async function fetchCompanies(): Promise<Company[]> {
|
||||
if (inflight) return inflight;
|
||||
|
||||
inflight = api
|
||||
.get<Company[]>("/companies")
|
||||
.get<Company[]>('/companies')
|
||||
.then((r) => {
|
||||
cachedCompanies = r.data;
|
||||
return r.data;
|
||||
@@ -26,25 +28,34 @@ export function invalidateCompaniesCache() {
|
||||
cachedCompanies = null;
|
||||
}
|
||||
|
||||
export function useCompanies() {
|
||||
const [companies, setCompanies] = useState<Company[]>(cachedCompanies ?? []);
|
||||
const [loading, setLoading] = useState(!cachedCompanies);
|
||||
export function useCompanies(): {
|
||||
companies: Company[];
|
||||
loading: boolean;
|
||||
refreshing: boolean;
|
||||
error: ViewResourceError | null;
|
||||
reload: () => Promise<void>;
|
||||
} {
|
||||
const [cacheBust, setCacheBust] = useState(0);
|
||||
const resource = useViewResource(fetchCompanies, {
|
||||
initialData: cachedCompanies ?? [],
|
||||
errorMessage: 'Unable to load companies right now.',
|
||||
deps: [cacheBust],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
setLoading(!cachedCompanies);
|
||||
fetchCompanies()
|
||||
.then((c) => {
|
||||
if (mounted) setCompanies(c);
|
||||
})
|
||||
.finally(() => {
|
||||
if (mounted) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
if (!resource.error) {
|
||||
cachedCompanies = resource.data;
|
||||
}
|
||||
}, [resource.data, resource.error]);
|
||||
|
||||
return { companies, loading };
|
||||
return {
|
||||
companies: resource.data,
|
||||
loading: resource.loading,
|
||||
refreshing: resource.refreshing,
|
||||
error: resource.error,
|
||||
reload: async () => {
|
||||
invalidateCompaniesCache();
|
||||
setCacheBust((value) => value + 1);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { DependencyList, Dispatch, SetStateAction, useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { getApiErrorMessage } from "../api";
|
||||
|
||||
export type ViewResourceErrorKind = "unauthorized" | "unavailable" | "error";
|
||||
|
||||
export type ViewResourceError = {
|
||||
kind: ViewResourceErrorKind;
|
||||
message: string;
|
||||
retryable: boolean;
|
||||
status?: number;
|
||||
};
|
||||
|
||||
export type ViewResourceState<T> = {
|
||||
data: T;
|
||||
loading: boolean;
|
||||
refreshing: boolean;
|
||||
error: ViewResourceError | null;
|
||||
hasLoaded: boolean;
|
||||
reload: () => Promise<void>;
|
||||
setData: Dispatch<SetStateAction<T>>;
|
||||
};
|
||||
|
||||
function normalizeError(error: any, fallback: string): ViewResourceError {
|
||||
const status = error?.response?.status as number | undefined;
|
||||
if (status === 401 || status === 403) {
|
||||
return {
|
||||
kind: "unauthorized",
|
||||
message: getApiErrorMessage(error, fallback),
|
||||
retryable: false,
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
if (!status || status >= 500) {
|
||||
return {
|
||||
kind: "unavailable",
|
||||
message: getApiErrorMessage(error, fallback),
|
||||
retryable: true,
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "error",
|
||||
message: getApiErrorMessage(error, fallback),
|
||||
retryable: true,
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
export function useViewResource<T>(
|
||||
load: () => Promise<T>,
|
||||
options: {
|
||||
initialData: T;
|
||||
errorMessage: string;
|
||||
deps?: DependencyList;
|
||||
enabled?: boolean;
|
||||
},
|
||||
): ViewResourceState<T> {
|
||||
const { initialData, errorMessage, deps = [], enabled = true } = options;
|
||||
const [data, setData] = useState<T>(initialData);
|
||||
const [loading, setLoading] = useState(enabled);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
const [error, setError] = useState<ViewResourceError | null>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
if (!enabled) return;
|
||||
|
||||
setLoading((current) => !hasLoaded && current);
|
||||
setRefreshing(hasLoaded);
|
||||
try {
|
||||
const next = await load();
|
||||
setData(next);
|
||||
setError(null);
|
||||
setHasLoaded(true);
|
||||
} catch (err: any) {
|
||||
setError(normalizeError(err, errorMessage));
|
||||
setHasLoaded(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [enabled, errorMessage, hasLoaded, load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(!hasLoaded);
|
||||
void reload();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled, reload, ...deps]);
|
||||
|
||||
return useMemo(() => ({ data, loading, refreshing, error, hasLoaded, reload, setData }), [data, error, hasLoaded, loading, refreshing, reload]);
|
||||
}
|
||||
@@ -52,8 +52,9 @@ describe('LoginPage', () => {
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('stores auth token in session storage when remember me is unchecked', async () => {
|
||||
mockedApi.post.mockResolvedValueOnce({ data: { accessToken: 'header.payload.sig', tokenType: 'Bearer' } } as any);
|
||||
it('posts remember-me preference without storing an auth token in browser storage', async () => {
|
||||
mockedApi.post.mockResolvedValueOnce({ data: { authenticated: true, provider: 'local' } } as any);
|
||||
mockedApi.get.mockResolvedValueOnce({ data: { roles: [], email: 'person@example.com', userName: 'person' } } as any);
|
||||
|
||||
renderLoginPage();
|
||||
await screen.findByLabelText('Email');
|
||||
@@ -63,9 +64,10 @@ describe('LoginPage', () => {
|
||||
await userEvent.click(screen.getByLabelText('Remember me'));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Sign in' }));
|
||||
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/auth/login', { email: 'person@example.com', password: 'hunter2' }));
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/auth/login', { email: 'person@example.com', password: 'hunter2', rememberMe: false }));
|
||||
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/auth/me'));
|
||||
|
||||
expect(window.sessionStorage.getItem('authToken')).toBe('header.payload.sig');
|
||||
expect(window.sessionStorage.getItem('authToken')).toBeNull();
|
||||
expect(window.localStorage.getItem('authToken')).toBeNull();
|
||||
expect(window.localStorage.getItem('authTokenPersistence')).toBe('session');
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Box, Button, Checkbox, FormControlLabel, Paper, Tab, Tabs, TextField, T
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { getRememberMePref, setAuthToken, setRememberMePref } from "../auth";
|
||||
import { getRememberMePref, setAuthPersistencePreference } from "../auth";
|
||||
import GoogleAuthCard from "../components/GoogleAuthCard";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
@@ -44,9 +44,9 @@ export default function LoginPage() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const url = mode === "register" ? "/auth/register" : "/auth/login";
|
||||
const res = await api.post<{ accessToken: string; tokenType: string }>(url, { email, password });
|
||||
setRememberMePref(rememberMe);
|
||||
setAuthToken(res.data.accessToken, rememberMe ? "local" : "session");
|
||||
await api.post(url, { email, password, rememberMe });
|
||||
setAuthPersistencePreference(rememberMe ? "local" : "session");
|
||||
await api.get("/auth/me");
|
||||
toast(t("signedIn"), "success");
|
||||
navigate(nextPath, { replace: true });
|
||||
} catch (e: any) {
|
||||
|
||||
@@ -42,6 +42,12 @@ type ExtractionRun = {
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
type QueuedCvRunResponse = {
|
||||
queued: boolean;
|
||||
extractionRunId: number;
|
||||
status: string;
|
||||
};
|
||||
|
||||
type JobListResponse = {
|
||||
items: JobApplication[];
|
||||
total: number;
|
||||
@@ -199,6 +205,7 @@ export default function ProfilePage() {
|
||||
const avatarInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [me, setMe] = useState<MeResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [uploadingCv, setUploadingCv] = useState(false);
|
||||
const [improvingCv, setImprovingCv] = useState(false);
|
||||
const [rebuildingCv, setRebuildingCv] = useState(false);
|
||||
@@ -225,10 +232,12 @@ export default function ProfilePage() {
|
||||
const [reprocessingCv, setReprocessingCv] = useState(false);
|
||||
const [structuredCv, setStructuredCv] = useState<StructuredCvProfile>(emptyStructuredCv());
|
||||
const [extractionRuns, setExtractionRuns] = useState<ExtractionRun[]>([]);
|
||||
const runStatusRef = useRef<Record<number, string>>({});
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
|
||||
const loadProfile = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [profileResponse, runsResponse, jobsResponse] = await Promise.all([
|
||||
api.get<MeResponse>("/auth/me"),
|
||||
@@ -247,10 +256,14 @@ export default function ProfilePage() {
|
||||
setExtractionRuns(runsResponse.data ?? []);
|
||||
setSavedJobs(jobsResponse.data?.items ?? []);
|
||||
setHeadline(window.localStorage.getItem("profileHeadline") ?? "");
|
||||
} catch {
|
||||
setLoadError(null);
|
||||
} catch (error: any) {
|
||||
setMe(null);
|
||||
setExtractionRuns([]);
|
||||
setSavedJobs([]);
|
||||
setLoadError(String(error?.response?.data || error?.message || "Unable to load profile right now."));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -258,6 +271,31 @@ export default function ProfilePage() {
|
||||
void loadProfile();
|
||||
}, [loadProfile]);
|
||||
|
||||
useEffect(() => {
|
||||
const activeRuns = extractionRuns.filter((run) => run.status === "queued" || run.status === "running");
|
||||
if (activeRuns.length === 0) return;
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
void loadProfile();
|
||||
}, 4000);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [extractionRuns, loadProfile]);
|
||||
|
||||
useEffect(() => {
|
||||
const previous = runStatusRef.current;
|
||||
for (const run of extractionRuns) {
|
||||
const prior = previous[run.id];
|
||||
if ((prior === "queued" || prior === "running") && run.status === "applied") {
|
||||
toast(`CV ${run.trigger} completed.`, "success");
|
||||
}
|
||||
if ((prior === "queued" || prior === "running") && run.status === "failed") {
|
||||
toast(run.errorMessage || `CV ${run.trigger} failed.`, "error");
|
||||
}
|
||||
previous[run.id] = run.status;
|
||||
}
|
||||
}, [extractionRuns, toast]);
|
||||
|
||||
const initials = useMemo(() => initialsFrom([me?.displayName, me?.firstName, me?.lastName, me?.userName, me?.email]), [me]);
|
||||
const isLocal = me?.provider === "local";
|
||||
const fullName = [me?.firstName, me?.lastName].filter(Boolean).join(" ");
|
||||
@@ -305,6 +343,13 @@ export default function ProfilePage() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{loadError ? (
|
||||
<Alert severity="error" sx={{ mb: 2, borderRadius: 2.5 }} action={<Button color="inherit" size="small" onClick={() => void loadProfile()}>Retry</Button>}>
|
||||
Unable to load profile.
|
||||
<Typography variant="body2" sx={{ mt: 0.5 }}>{loadError}</Typography>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 1 }}>
|
||||
@@ -413,7 +458,7 @@ export default function ProfilePage() {
|
||||
formData.append("file", file);
|
||||
setUploadingCv(true);
|
||||
try {
|
||||
await api.post("/profile-cv/upload", formData, { headers: { "Content-Type": "multipart/form-data" } });
|
||||
await api.post<QueuedCvRunResponse>("/profile-cv/upload", formData, { headers: { "Content-Type": "multipart/form-data" } });
|
||||
await loadProfile();
|
||||
toast(t("profileCvUploaded"), "success");
|
||||
} catch (e: any) {
|
||||
@@ -432,10 +477,9 @@ export default function ProfilePage() {
|
||||
onClick={async () => {
|
||||
setRebuildingCv(true);
|
||||
try {
|
||||
const res = await api.post<{ text?: string }>("/profile-cv/rebuild");
|
||||
if (res.data?.text) setProfileCvText(res.data.text);
|
||||
const res = await api.post<QueuedCvRunResponse>("/profile-cv/rebuild");
|
||||
await loadProfile();
|
||||
toast(t("profileCvRebuilt"), "success");
|
||||
toast(`Queued CV rebuild (run ${res.data.extractionRunId}).`, "info");
|
||||
} catch (e: any) {
|
||||
toast(String(e?.response?.data || e?.message || t("profileCvRebuildFailed")), "error");
|
||||
} finally {
|
||||
@@ -451,10 +495,9 @@ export default function ProfilePage() {
|
||||
onClick={async () => {
|
||||
setImprovingCv(true);
|
||||
try {
|
||||
const res = await api.post<{ text?: string }>("/profile-cv/improve");
|
||||
if (res.data?.text) setProfileCvText(res.data.text);
|
||||
const res = await api.post<QueuedCvRunResponse>("/profile-cv/improve");
|
||||
await loadProfile();
|
||||
toast(t("profileCvImproved"), "success");
|
||||
toast(`Queued CV improve run (run ${res.data.extractionRunId}).`, "info");
|
||||
} catch (e: any) {
|
||||
toast(String(e?.response?.data || e?.message || t("profileCvImproveFailed")), "error");
|
||||
} finally {
|
||||
@@ -470,9 +513,9 @@ export default function ProfilePage() {
|
||||
onClick={async () => {
|
||||
setReprocessingCv(true);
|
||||
try {
|
||||
await api.post("/profile-cv/reprocess");
|
||||
const res = await api.post<QueuedCvRunResponse>("/profile-cv/reprocess");
|
||||
await loadProfile();
|
||||
toast(t("profileCvReprocessed"), "success");
|
||||
toast(`Queued CV reprocess run (run ${res.data.extractionRunId}).`, "info");
|
||||
} catch (e: any) {
|
||||
toast(String(e?.response?.data || e?.message || t("profileCvReprocessFailed")), "error");
|
||||
} finally {
|
||||
|
||||
@@ -1,26 +1,9 @@
|
||||
import { decodeJwtPayload, getAuthToken } from "./auth";
|
||||
import { getAuthUserKey } from "./auth";
|
||||
|
||||
export type ThemeModePref = "system" | "light" | "dark";
|
||||
|
||||
export function getUserKeyFromToken(): string {
|
||||
const token = getAuthToken();
|
||||
if (!token) return "anon";
|
||||
|
||||
const payload = decodeJwtPayload(token) ?? {};
|
||||
|
||||
const candidates = [
|
||||
payload.sub,
|
||||
payload.nameid,
|
||||
payload["nameid"],
|
||||
payload["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier"],
|
||||
payload["http://schemas.microsoft.com/ws/2008/06/identity/claims/primarysid"],
|
||||
];
|
||||
|
||||
for (const c of candidates) {
|
||||
if (typeof c === "string" && c.trim().length > 0) return c.trim();
|
||||
}
|
||||
|
||||
return "anon";
|
||||
return getAuthUserKey();
|
||||
}
|
||||
|
||||
function k(base: string) {
|
||||
|
||||
Reference in New Issue
Block a user