refactor, security updates, cv extraction upgrades
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user