fix(ui): preserve inbox context and review recovery
This commit is contained in:
@@ -1,11 +1,10 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
Box,
|
||||
Alert,
|
||||
Autocomplete,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
@@ -23,6 +22,9 @@ import { useConfirm } from "../confirm";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import GmailReviewPage from "./GmailReviewPage";
|
||||
import ViewStateNotice from "../components/ViewStateNotice";
|
||||
import { PageSection } from "../layout/PageLayout";
|
||||
import type { ViewResourceError } from "../hooks/useViewResource";
|
||||
|
||||
export type CorrespondenceInboxItem = {
|
||||
id: number;
|
||||
@@ -112,15 +114,23 @@ type EmailSendResult = {
|
||||
failureCategory?: string | null;
|
||||
};
|
||||
|
||||
function readError(error: any): ViewResourceError {
|
||||
const status = error?.response?.status as number | undefined;
|
||||
const unauthorized = status === 401 || status === 403;
|
||||
return { kind: unauthorized ? "unauthorized" : "error", status, retryable: !unauthorized, message: getApiErrorMessage(error, "") };
|
||||
}
|
||||
|
||||
export default function CorrespondenceInboxPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const view = searchParams.get("view") === "review" ? "review" : "inbox";
|
||||
const { toast } = useToast();
|
||||
const { confirm } = useConfirm();
|
||||
const { language, t } = useI18n();
|
||||
const [items, setItems] = useState<CorrespondenceInboxItem[]>([]);
|
||||
const [inboxPage, setInboxPage] = useState(1);
|
||||
const page = Number(searchParams.get("page"));
|
||||
const inboxPage = Number.isInteger(page) && page > 0 && page <= 2147483647 ? page : 1;
|
||||
const [inboxTotal, setInboxTotal] = useState(0);
|
||||
const [inboxTotalPages, setInboxTotalPages] = useState(0);
|
||||
const [providers, setProviders] = useState<EmailProviderStatus[]>([]);
|
||||
@@ -133,16 +143,35 @@ export default function CorrespondenceInboxPage() {
|
||||
const [composeJobId, setComposeJobId] = useState<number | "">("");
|
||||
const [composeProvider, setComposeProvider] = useState("");
|
||||
const [providerStatusError, setProviderStatusError] = useState(false);
|
||||
const [providerError, setProviderError] = useState<ViewResourceError | null>(null);
|
||||
const [providersLoading, setProvidersLoading] = useState(false);
|
||||
const providerPending = useRef(false);
|
||||
const [selectedMessageId, setSelectedMessageId] = useState<number | null>(null);
|
||||
const [messageDetail, setMessageDetail] = useState<EmailMessageDetail | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [detailError, setDetailError] = useState<string | null>(null);
|
||||
const [detailNotice, setDetailNotice] = useState<string | null>(null);
|
||||
const [detailError, setDetailError] = useState<ViewResourceError | null>(null);
|
||||
const [detailNotice, setDetailNotice] = useState(false);
|
||||
const detailRequest = useRef(0);
|
||||
const detailItem = useRef<CorrespondenceInboxItem | null>(null);
|
||||
const detailPending = useRef(false);
|
||||
const detailPanel = useRef<HTMLDivElement>(null);
|
||||
const inboxPanel = useRef<HTMLDivElement>(null);
|
||||
const providerPanel = useRef<HTMLDivElement>(null);
|
||||
const rowId = useId();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [direction, setDirection] = useState<string>("all");
|
||||
const [linkState, setLinkState] = useState<string>("all");
|
||||
const [loadError, setLoadError] = useState<ViewResourceError | null>(null);
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
const listRequest = useRef(0);
|
||||
const pendingList = useRef<string | null>(null);
|
||||
const [loadedListKey, setLoadedListKey] = useState<string | null>(null);
|
||||
const query = searchParams.get("q") ?? "";
|
||||
const direction = ["inbound", "outbound", "internal"].includes(searchParams.get("direction") ?? "") ? searchParams.get("direction")! : "all";
|
||||
const linkState = ["linked", "manual"].includes(searchParams.get("linkState") ?? "") ? searchParams.get("linkState")! : "all";
|
||||
const currentListKey = JSON.stringify([query.trim(), direction, linkState, inboxPage]);
|
||||
const hasCurrentInbox = loadedListKey === currentListKey && loadError?.kind !== "unauthorized";
|
||||
const [reviewVisited, setReviewVisited] = useState(view === "review");
|
||||
const mounted = useRef(true);
|
||||
const [draftsReadError, setDraftsReadError] = useState(false);
|
||||
const [draft, setDraft] = useState<EmailDraft | null>(null);
|
||||
const [storedDrafts, setStoredDrafts] = useState<StoredEmailDraft[]>([]);
|
||||
const [savingDraft, setSavingDraft] = useState(false);
|
||||
@@ -151,9 +180,33 @@ export default function CorrespondenceInboxPage() {
|
||||
const [sendError, setSendError] = useState<string | null>(null);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [unlinkingThreadId, setUnlinkingThreadId] = useState<string | null>(null);
|
||||
const unlinkPending = useRef(false);
|
||||
const [unlinkError, setUnlinkError] = useState<ViewResourceError | null>(null);
|
||||
|
||||
const updateFilters = useCallback((changes: Record<string, string>) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
for (const [key, value] of Object.entries(changes)) {
|
||||
if (!value || (value === "all" && (key === "direction" || key === "linkState")) || (key === "page" && value === "1") || (key === "view" && value === "inbox")) next.delete(key);
|
||||
else next.set(key, value);
|
||||
}
|
||||
navigate({ pathname: location.pathname, search: `?${next}`, hash: location.hash }, { state: location.state, replace: Object.hasOwn(changes, "q") });
|
||||
}, [location.hash, location.pathname, location.state, navigate, searchParams]);
|
||||
const latestFilters = useRef(updateFilters);
|
||||
latestFilters.current = updateFilters;
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => { mounted.current = false; listRequest.current += 1; detailRequest.current += 1; };
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (view !== "inbox") return;
|
||||
const key = JSON.stringify([query.trim(), direction, linkState, inboxPage]);
|
||||
if (pendingList.current === key) return;
|
||||
const request = ++listRequest.current;
|
||||
pendingList.current = key;
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
try {
|
||||
const res = await api.get<CorrespondenceInboxPage>("/correspondence/page", {
|
||||
params: {
|
||||
@@ -164,35 +217,63 @@ export default function CorrespondenceInboxPage() {
|
||||
pageSize: 50,
|
||||
},
|
||||
});
|
||||
if (!mounted.current || request !== listRequest.current) return;
|
||||
setItems(res.data?.items ?? []);
|
||||
setInboxTotal(res.data?.total ?? 0);
|
||||
setInboxTotalPages(res.data?.totalPages ?? 0);
|
||||
if (res.data?.page && res.data.page !== inboxPage) setInboxPage(res.data.page);
|
||||
setLoadedListKey(key);
|
||||
if (res.data?.page && res.data.page !== inboxPage) latestFilters.current({ page: String(res.data.page) });
|
||||
setHasLoaded(true);
|
||||
detailRequest.current += 1;
|
||||
detailItem.current = null;
|
||||
detailPending.current = false;
|
||||
setSelectedMessageId(null);
|
||||
setMessageDetail(null);
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, t("emailInboxLoadFailed")), "error");
|
||||
if (mounted.current && request === listRequest.current) setLoadError(readError(error));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (mounted.current && request === listRequest.current) { pendingList.current = null; setLoading(false); }
|
||||
}
|
||||
}, [direction, inboxPage, linkState, query, t, toast]);
|
||||
}, [direction, inboxPage, linkState, query, view]);
|
||||
const latestLoad = useRef(load);
|
||||
latestLoad.current = load;
|
||||
|
||||
useEffect(() => {
|
||||
if (view === "inbox") void load();
|
||||
else setReviewVisited(true);
|
||||
return () => {
|
||||
listRequest.current += 1;
|
||||
pendingList.current = null;
|
||||
detailRequest.current += 1;
|
||||
detailItem.current = null;
|
||||
detailPending.current = false;
|
||||
setSelectedMessageId(null);
|
||||
setMessageDetail(null);
|
||||
setDetailLoading(false);
|
||||
};
|
||||
}, [load, view]);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<EmailProviderStatus[]>("/email/providers")
|
||||
.then((response) => {
|
||||
setProviders(response.data ?? []);
|
||||
setProviderStatusError(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setProviders([]);
|
||||
setProviderStatusError(true);
|
||||
})
|
||||
.finally(() => setProviderStatusLoaded(true));
|
||||
const loadProviders = useCallback(async () => {
|
||||
if (providerPending.current) return;
|
||||
providerPending.current = true;
|
||||
setProvidersLoading(true);
|
||||
try {
|
||||
const response = await api.get<EmailProviderStatus[]>("/email/providers");
|
||||
if (!mounted.current) return;
|
||||
setProviders(response.data ?? []);
|
||||
setProviderStatusError(false);
|
||||
setProviderError(null);
|
||||
} catch (error) {
|
||||
if (!mounted.current) return;
|
||||
setProviders([]);
|
||||
setProviderStatusError(true);
|
||||
setProviderError(readError(error));
|
||||
} finally {
|
||||
providerPending.current = false;
|
||||
if (mounted.current) { setProviderStatusLoaded(true); setProvidersLoading(false); }
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => { void loadProviders(); }, [loadProviders]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
@@ -211,36 +292,33 @@ export default function CorrespondenceInboxPage() {
|
||||
const loadStoredDrafts = useCallback(async () => {
|
||||
try {
|
||||
const response = await api.get<StoredEmailDraft[]>("/email/drafts");
|
||||
setStoredDrafts(Array.isArray(response.data) ? response.data : []);
|
||||
if (mounted.current) { setStoredDrafts(Array.isArray(response.data) ? response.data : []); setDraftsReadError(false); }
|
||||
} catch {
|
||||
setDraftError(t("emailInboxDraftsLoadFailed"));
|
||||
if (mounted.current) setDraftsReadError(true);
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadStoredDrafts();
|
||||
}, [loadStoredDrafts]);
|
||||
|
||||
const filteredSummary = useMemo(() => {
|
||||
if (!hasCurrentInbox) return { linked: 0, inbound: 0 };
|
||||
const linked = items.filter((item) => item.externalThreadId).length;
|
||||
const inbound = items.filter((item) => item.direction === "inbound").length;
|
||||
return { linked, inbound };
|
||||
}, [items]);
|
||||
}, [hasCurrentInbox, items]);
|
||||
const sendCapableProviders = useMemo(() => providers.filter((provider) => provider.connected && provider.canSend), [providers]);
|
||||
|
||||
const showMessage = async (item: CorrespondenceInboxItem) => {
|
||||
if (selectedMessageId === item.id) {
|
||||
detailRequest.current += 1;
|
||||
setSelectedMessageId(null);
|
||||
setMessageDetail(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadMessage = async (item: CorrespondenceInboxItem) => {
|
||||
if (detailPending.current && detailItem.current?.id === item.id) return;
|
||||
const request = ++detailRequest.current;
|
||||
detailPending.current = true;
|
||||
detailItem.current = item;
|
||||
setSelectedMessageId(item.id);
|
||||
setMessageDetail(null);
|
||||
setDetailError(null);
|
||||
setDetailNotice(null);
|
||||
setDetailNotice(false);
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
if (item.provider && item.provider !== "manual" && item.externalMessageId) {
|
||||
@@ -253,7 +331,7 @@ export default function CorrespondenceInboxPage() {
|
||||
return;
|
||||
} catch {
|
||||
if (request !== detailRequest.current) return;
|
||||
setDetailNotice(t("emailInboxProviderCopyUnavailable"));
|
||||
setDetailNotice(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,12 +340,22 @@ export default function CorrespondenceInboxPage() {
|
||||
setMessageDetail(saved.data);
|
||||
} catch (error) {
|
||||
if (request !== detailRequest.current) return;
|
||||
setDetailError(getApiErrorMessage(error, t("emailInboxMessageLoadFailed")));
|
||||
setDetailError(readError(error));
|
||||
} finally {
|
||||
if (request === detailRequest.current) setDetailLoading(false);
|
||||
if (request === detailRequest.current) { detailPending.current = false; setDetailLoading(false); }
|
||||
}
|
||||
};
|
||||
|
||||
const showMessage = (item: CorrespondenceInboxItem) => {
|
||||
if (selectedMessageId !== item.id) return loadMessage(item);
|
||||
detailRequest.current += 1;
|
||||
detailItem.current = null;
|
||||
detailPending.current = false;
|
||||
setSelectedMessageId(null);
|
||||
setMessageDetail(null);
|
||||
setDetailLoading(false);
|
||||
};
|
||||
|
||||
const startReply = async (item: CorrespondenceInboxItem) => {
|
||||
if (!messageDetail || !item.provider) return;
|
||||
const provider = providers.find((candidate) => candidate.provider === item.provider);
|
||||
@@ -490,33 +578,37 @@ export default function CorrespondenceInboxPage() {
|
||||
};
|
||||
|
||||
const unlinkGmailThread = async (item: CorrespondenceInboxItem) => {
|
||||
if (item.provider !== "gmail" || !item.externalThreadId || unlinkingThreadId) return;
|
||||
if (!(await confirm({
|
||||
title: t("emailInboxUnlinkTitle"),
|
||||
message: t("emailInboxUnlinkMessage", { job: item.companyName || t("emailInboxThisJob") }),
|
||||
confirmLabel: t("emailInboxUnlinkThread"),
|
||||
destructive: true,
|
||||
}))) return;
|
||||
|
||||
setUnlinkingThreadId(item.externalThreadId);
|
||||
if (item.provider !== "gmail" || !item.externalThreadId || unlinkPending.current) return;
|
||||
unlinkPending.current = true;
|
||||
try {
|
||||
if (!(await confirm({
|
||||
title: t("emailInboxUnlinkTitle"),
|
||||
message: t("emailInboxUnlinkMessage", { job: item.companyName || t("emailInboxThisJob") }),
|
||||
confirmLabel: t("emailInboxUnlinkThread"),
|
||||
destructive: true,
|
||||
})) || !mounted.current) return;
|
||||
setUnlinkingThreadId(item.externalThreadId);
|
||||
inboxPanel.current?.focus({ preventScroll: true });
|
||||
setUnlinkError(null);
|
||||
await api.post("/gmail/unlink-thread", {
|
||||
jobApplicationId: item.jobApplicationId,
|
||||
threadId: item.externalThreadId,
|
||||
note: "Unlinked from Job email hub",
|
||||
nextDecision: "review",
|
||||
});
|
||||
if (!mounted.current) return;
|
||||
if (selectedMessageId === item.id) {
|
||||
detailRequest.current += 1;
|
||||
setSelectedMessageId(null);
|
||||
setMessageDetail(null);
|
||||
}
|
||||
await load();
|
||||
await latestLoad.current();
|
||||
toast(t("emailInboxUnlinked"), "success");
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, t("emailInboxUnlinkFailed")), "error");
|
||||
if (mounted.current) setUnlinkError({ ...readError(error), retryable: false });
|
||||
} finally {
|
||||
setUnlinkingThreadId(null);
|
||||
unlinkPending.current = false;
|
||||
if (mounted.current) setUnlinkingThreadId(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -528,33 +620,20 @@ export default function CorrespondenceInboxPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
mt: 0,
|
||||
p: 2,
|
||||
borderRadius: 4,
|
||||
border: "none",
|
||||
boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)",
|
||||
}}
|
||||
>
|
||||
<PageSection surface title={t("correspondenceInbox")} description={t("emailInboxSubtitle")}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap", mb: 2 }}>
|
||||
<Box>
|
||||
<Typography component="h1" variant="h5" sx={{ fontWeight: 900 }}>{t("correspondenceInbox")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
{t("emailInboxSubtitle")}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||
{view === "inbox" ? <Chip icon={<MailOutlineIcon />} label={t("emailInboxItems", { count: inboxTotal })} variant="outlined" /> : null}
|
||||
{view === "inbox" ? <Chip label={t("emailInboxLinked", { count: filteredSummary.linked })} variant="outlined" color={filteredSummary.linked > 0 ? "success" : "default"} /> : null}
|
||||
{view === "inbox" ? <Chip label={t("emailInboxInboundCount", { count: filteredSummary.inbound })} variant="outlined" /> : null}
|
||||
<Button variant={view === "inbox" ? "contained" : "text"} size="small" onClick={() => setSearchParams({})}>{t("emailInboxLinkedMessages")}</Button>
|
||||
<Button variant={view === "review" ? "contained" : "text"} size="small" onClick={() => setSearchParams({ view: "review" })}>{t("emailInboxReviewSuggestions")}</Button>
|
||||
{view === "inbox" && hasCurrentInbox ? <Chip icon={<MailOutlineIcon />} label={t("emailInboxItems", { count: inboxTotal })} variant="outlined" /> : null}
|
||||
{view === "inbox" && hasCurrentInbox ? <Chip label={t("emailInboxLinkedOnPage", { count: filteredSummary.linked })} variant="outlined" color={filteredSummary.linked > 0 ? "success" : "default"} /> : null}
|
||||
{view === "inbox" && hasCurrentInbox ? <Chip label={t("emailInboxInboundOnPage", { count: filteredSummary.inbound })} variant="outlined" /> : null}
|
||||
<Button variant={view === "inbox" ? "outlined" : "text"} aria-pressed={view === "inbox"} disabled={!!unlinkingThreadId} size="small" onClick={() => updateFilters({ view: "inbox" })}>{t("emailInboxLinkedMessages")}</Button>
|
||||
<Button variant={view === "review" ? "outlined" : "text"} aria-pressed={view === "review"} disabled={!!unlinkingThreadId} size="small" onClick={() => updateFilters({ view: "review" })}>{t("emailInboxReviewSuggestions")}</Button>
|
||||
<Button variant="outlined" size="small" onClick={openComposeSetup} disabled={jobs.length === 0 || sendCapableProviders.length === 0}>{t("emailInboxCompose")}</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 2 }} aria-label={t("emailInboxProviderStatus")}>
|
||||
<Box ref={providerPanel} tabIndex={-1} role="group" aria-label={t("emailInboxProviderStatus")} sx={{ minWidth: 0, mb: 2 }}>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", "& .MuiChip-root": { maxWidth: "100%", height: "auto" }, "& .MuiChip-label": { whiteSpace: "normal", overflowWrap: "anywhere" } }}>
|
||||
{providers.map((provider) => (
|
||||
<Chip
|
||||
key={provider.provider}
|
||||
@@ -565,12 +644,12 @@ export default function CorrespondenceInboxPage() {
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
{providerStatusError ? (
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>{t("emailInboxProviderStatusUnavailable")}</Alert>
|
||||
) : null}
|
||||
<ViewStateNotice compact loading={providersLoading} title={t("emailInboxProviderStatusUnavailable")} error={providerError ? { ...providerError, message: providerError.message || t("emailInboxProviderStatusUnavailable") } : null} onRetry={() => { providerPanel.current?.focus({ preventScroll: true }); return loadProviders(); }} />
|
||||
</Box>
|
||||
{jobsLoaded && jobs.length === 0 ? <Alert severity="info" sx={{ mb: 2 }}>{t("emailInboxAddJobFirst")}</Alert> : null}
|
||||
{providerStatusLoaded && !providerStatusError && sendCapableProviders.length === 0 ? <Alert severity="info" sx={{ mb: 2 }}>{t("emailInboxReconnectToCompose")}</Alert> : null}
|
||||
{draftError && !draft ? <Alert severity="warning" sx={{ mb: 2 }}>{draftError}</Alert> : null}
|
||||
{draftsReadError && !draft ? <Alert severity="warning" sx={{ mb: 2 }}>{t("emailInboxDraftsLoadFailed")}</Alert> : null}
|
||||
|
||||
{storedDrafts.length > 0 ? (
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
@@ -615,12 +694,13 @@ export default function CorrespondenceInboxPage() {
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
{view === "review" ? <GmailReviewPage embedded /> : <>
|
||||
{reviewVisited ? <Box hidden={view !== "review"}><GmailReviewPage embedded active={view === "review"} /></Box> : null}
|
||||
<Box ref={inboxPanel} role="group" tabIndex={-1} aria-label={t("emailInboxLinkedMessages")} hidden={view !== "inbox"}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "2fr 1fr 1fr auto" }, gap: 1.25, mb: 2 }}>
|
||||
<TextField label={t("correspondenceSearch")} value={query} onChange={(e) => { setQuery(e.target.value); setInboxPage(1); }} placeholder={t("emailInboxSearchPlaceholder")} />
|
||||
<TextField label={t("correspondenceSearch")} value={query} disabled={!!unlinkingThreadId} onChange={(e) => updateFilters({ q: e.target.value, page: "1" })} placeholder={t("emailInboxSearchPlaceholder")} />
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="email-direction-label">{t("emailInboxDirection")}</InputLabel>
|
||||
<Select labelId="email-direction-label" value={direction} label={t("emailInboxDirection")} onChange={(e) => { setDirection(String(e.target.value)); setInboxPage(1); }}>
|
||||
<Select labelId="email-direction-label" value={direction} disabled={!!unlinkingThreadId} label={t("emailInboxDirection")} onChange={(e) => updateFilters({ direction: String(e.target.value), page: "1" })}>
|
||||
<MenuItem value="all">{t("emailInboxAll")}</MenuItem>
|
||||
<MenuItem value="inbound">{t("emailInboxInbound")}</MenuItem>
|
||||
<MenuItem value="outbound">{t("emailInboxOutbound")}</MenuItem>
|
||||
@@ -629,13 +709,13 @@ export default function CorrespondenceInboxPage() {
|
||||
</FormControl>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="email-link-state-label">{t("emailInboxLinkState")}</InputLabel>
|
||||
<Select labelId="email-link-state-label" value={linkState} label={t("emailInboxLinkState")} onChange={(e) => { setLinkState(String(e.target.value)); setInboxPage(1); }}>
|
||||
<Select labelId="email-link-state-label" value={linkState} disabled={!!unlinkingThreadId} label={t("emailInboxLinkState")} onChange={(e) => updateFilters({ linkState: String(e.target.value), page: "1" })}>
|
||||
<MenuItem value="all">{t("emailInboxAll")}</MenuItem>
|
||||
<MenuItem value="linked">{t("emailInboxLinkedThreads")}</MenuItem>
|
||||
<MenuItem value="manual">{t("emailInboxManualOnly")}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Button variant="contained" onClick={() => void load()} disabled={loading}>{loading ? t("loading") : t("refresh")}</Button>
|
||||
<Button variant="outlined" onClick={() => { inboxPanel.current?.focus({ preventScroll: true }); void load(); }} disabled={loading || !!unlinkingThreadId}>{loading ? t("loading") : t("refresh")}</Button>
|
||||
</Box>
|
||||
|
||||
{draft ? (
|
||||
@@ -676,19 +756,20 @@ export default function CorrespondenceInboxPage() {
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
{loading ? <Box sx={{ py: 6, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : null}
|
||||
<ViewStateNotice compact loading={loading} title={t("emailInboxLoadFailed")} error={loadError ? { ...loadError, message: loadError.message || t("emailInboxLoadFailed") } : null} onRetry={() => { inboxPanel.current?.focus({ preventScroll: true }); return load(); }} />
|
||||
<ViewStateNotice compact loading={!!unlinkingThreadId} title={t("emailInboxUnlinkFailed")} error={unlinkError ? { ...unlinkError, message: unlinkError.message || t("emailInboxUnlinkFailed") } : null} />
|
||||
|
||||
{!loading && items.length === 0 ? (
|
||||
{hasLoaded && hasCurrentInbox && !loadError && !loading && items.length === 0 ? (
|
||||
<Typography sx={{ color: "text.secondary", py: 4, textAlign: "center" }}>{t("emailInboxNoMatches")}</Typography>
|
||||
) : null}
|
||||
|
||||
<Stack spacing={1.25}>
|
||||
{items.map((item) => (
|
||||
<Paper key={item.id} variant="outlined" sx={{ p: 1.5, borderRadius: 3 }}>
|
||||
{(hasCurrentInbox ? items : []).map((item) => (
|
||||
<Paper component="article" aria-labelledby={`${rowId}-${item.id}`} key={item.id} variant="outlined" sx={{ p: "var(--app-card-padding)", borderRadius: 1, minWidth: 0, "& .MuiChip-root": { maxWidth: "100%", height: "auto" }, "& .MuiChip-label": { whiteSpace: "normal", overflowWrap: "anywhere" } }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "flex-start" }}>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography sx={{ fontWeight: 800, overflowWrap: "anywhere" }}>{item.companyName || t("emailInboxUnknownCompany")} • {item.jobTitle || t("emailInboxUnknownRole")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>{item.subject || item.contentPreview}</Typography>
|
||||
<Typography id={`${rowId}-${item.id}`} component="h3" variant="subtitle2" sx={{ overflowWrap: "anywhere" }}>{item.subject || item.contentPreview || t("emailInboxNoSubject")}</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 0.5 }}>
|
||||
{item.externalFrom || item.from} {item.externalTo ? `→ ${item.externalTo}` : ""} · {new Date(item.date).toLocaleString(language === "nb" ? "nb-NO" : "en")}
|
||||
</Typography>
|
||||
@@ -699,20 +780,19 @@ export default function CorrespondenceInboxPage() {
|
||||
{item.externalThreadId ? <Chip size="small" label={t("emailInboxThreadId", { id: item.externalThreadId })} color="success" variant="outlined" /> : <Chip size="small" label={t("emailInboxManualInternal")} variant="outlined" />}
|
||||
{item.labelCount > 0 ? <Chip size="small" label={t("emailInboxLabels", { count: item.labelCount })} variant="outlined" /> : null}
|
||||
{item.attachmentCount > 0 ? <Chip size="small" label={t("emailInboxAttachments", { count: item.attachmentCount })} variant="outlined" /> : null}
|
||||
<Button size="small" variant="text" onClick={() => void showMessage(item)}>{selectedMessageId === item.id ? t("emailInboxHideMessage") : t("emailInboxViewMessage")}</Button>
|
||||
<Button size="small" variant="text" onClick={() => navigate(`/jobs/${item.jobApplicationId}?section=communication`)}>{t("emailInboxOpenJob")}</Button>
|
||||
<Button size="small" variant="text" disabled={!!unlinkingThreadId} aria-expanded={selectedMessageId === item.id} aria-controls={selectedMessageId === item.id ? `${rowId}-${item.id}-message` : undefined} onClick={() => void showMessage(item)}>{selectedMessageId === item.id ? t("emailInboxHideMessage") : t("emailInboxViewMessage")}</Button>
|
||||
<Button size="small" variant="text" disabled={!!unlinkingThreadId} onClick={() => navigate(`/jobs/${item.jobApplicationId}?section=communication`, { state: { from: location.pathname + location.search + location.hash } })}>{t("emailInboxOpenJob")}</Button>
|
||||
{item.provider === "gmail" && item.externalThreadId ? (
|
||||
<Button size="small" color="warning" variant="text" disabled={unlinkingThreadId === item.externalThreadId} onClick={() => void unlinkGmailThread(item)}>
|
||||
<Button size="small" color="warning" variant="text" disabled={!!unlinkingThreadId} onClick={() => void unlinkGmailThread(item)}>
|
||||
{unlinkingThreadId === item.externalThreadId ? t("emailInboxUnlinking") : t("emailInboxUnlinkThread")}
|
||||
</Button>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
{selectedMessageId === item.id ? (
|
||||
<Box sx={{ mt: 1.5, pt: 1.5, borderTop: "1px solid", borderColor: "divider" }}>
|
||||
{detailLoading ? <Box sx={{ display: "flex", gap: 1, alignItems: "center" }}><CircularProgress size={18} /><Typography variant="body2">{t("emailInboxLoadingMessage")}</Typography></Box> : null}
|
||||
{detailNotice ? <Alert severity="warning" sx={{ mb: 1 }}>{detailNotice}</Alert> : null}
|
||||
{detailError ? <Alert severity="error">{detailError}</Alert> : null}
|
||||
<Box ref={detailPanel} tabIndex={-1} role="group" id={`${rowId}-${item.id}-message`} aria-label={t("emailInboxMessage")} sx={{ mt: 1.5, pt: 1.5, borderTop: "1px solid", borderColor: "divider" }}>
|
||||
<ViewStateNotice compact loading={detailLoading} title={t("emailInboxMessageLoadFailed")} error={detailError ? { ...detailError, message: detailError.message || t("emailInboxMessageLoadFailed") } : null} onRetry={() => { detailPanel.current?.focus({ preventScroll: true }); return detailItem.current ? loadMessage(detailItem.current) : undefined; }} />
|
||||
{detailNotice ? <Alert severity="warning" sx={{ mb: 1 }}>{t("emailInboxProviderCopyUnavailable")}</Alert> : null}
|
||||
{messageDetail ? <>
|
||||
<Typography sx={{ fontWeight: 800 }}>{messageDetail.subject || t("emailInboxNoSubject")}</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mb: 1 }}>{messageDetail.from}{messageDetail.to ? ` → ${messageDetail.to}` : ""}</Typography>
|
||||
@@ -732,18 +812,19 @@ export default function CorrespondenceInboxPage() {
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
{inboxTotalPages > 1 ? (
|
||||
{hasCurrentInbox && inboxTotalPages > 1 ? (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", mt: 2 }}>
|
||||
<Pagination
|
||||
page={inboxPage}
|
||||
count={inboxTotalPages}
|
||||
onChange={(_, value) => setInboxPage(value)}
|
||||
onChange={(_, value) => updateFilters({ page: String(value) })}
|
||||
disabled={!!unlinkingThreadId}
|
||||
color="primary"
|
||||
aria-label={t("emailInboxPages")}
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
</>}
|
||||
</Paper>
|
||||
</Box>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,48 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Box, Button, Chip, CircularProgress, Paper, Stack, TextField, Typography } from "@mui/material";
|
||||
import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { Box, Button, Chip, Paper, Stack, TextField, Typography } from "@mui/material";
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { CreatedSuggestedGmailJobResult, GmailManualSyncResult, GmailReviewQueueResponse, GmailSuggestedJobsResponse } from "../types";
|
||||
import { useToast } from "../toast";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import type { TranslationKey } from "../i18n/translations";
|
||||
import type { ViewResourceError } from "../hooks/useViewResource";
|
||||
import ViewStateNotice from "../components/ViewStateNotice";
|
||||
import { PageHeader, PageSection } from "../layout/PageLayout";
|
||||
|
||||
export default function GmailReviewPage({ embedded = false }: { embedded?: boolean }) {
|
||||
type ReviewRequestError = { response?: { status?: number; data?: { code?: string } } };
|
||||
|
||||
function noticeError(cause: unknown, fallback: string): ViewResourceError {
|
||||
const status = (cause as ReviewRequestError)?.response?.status;
|
||||
const unauthorized = status === 401 || status === 403;
|
||||
return { kind: unauthorized ? "unauthorized" : !status || status >= 500 ? "unavailable" : "error", status, retryable: !unauthorized, message: getApiErrorMessage(cause, fallback) };
|
||||
}
|
||||
|
||||
export default function GmailReviewPage({ embedded = false, active = true }: { embedded?: boolean; active?: boolean }) {
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const navigate = useNavigate();
|
||||
const [data, setData] = useState<GmailReviewQueueResponse | null>(null);
|
||||
const [suggestions, setSuggestions] = useState<GmailSuggestedJobsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [savingThreadId, setSavingThreadId] = useState<string | null>(null);
|
||||
const [creatingThreadId, setCreatingThreadId] = useState<string | null>(null);
|
||||
const [readError, setReadError] = useState<unknown | null>(null);
|
||||
const [writeError, setWriteError] = useState<{ cause: unknown; key: TranslationKey } | null>(null);
|
||||
const [operation, setOperation] = useState<{ kind: "decision" | "sync" | "create"; threadId?: string } | null>(null);
|
||||
const [createdJobs, setCreatedJobs] = useState<Record<string, number>>({});
|
||||
const [routingFilter, setRoutingFilter] = useState<"all" | "auto-link" | "review" | "unmatched" | "suggested" | "linked" | "rejected">("all");
|
||||
const [notes, setNotes] = useState<Record<string, string>>({});
|
||||
const sectionId = useId();
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
const mountedRef = useRef(false);
|
||||
const activeRef = useRef(active);
|
||||
const viewSequence = useRef(0);
|
||||
const readSequence = useRef(0);
|
||||
const loadingRef = useRef(false);
|
||||
const mutationRef = useRef(false);
|
||||
const tRef = useRef(t);
|
||||
activeRef.current = active;
|
||||
tRef.current = t;
|
||||
const isCurrentView = useCallback((view: number) => mountedRef.current && activeRef.current && view === viewSequence.current, []);
|
||||
const routeLabel = (value: string) => {
|
||||
if (value === "auto-link") return t("gmailReviewRouteAutoLink");
|
||||
if (value === "review") return t("gmailReviewRouteReview");
|
||||
@@ -29,14 +54,20 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
|
||||
};
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!mountedRef.current || !activeRef.current || loadingRef.current) return false;
|
||||
const view = viewSequence.current;
|
||||
const request = ++readSequence.current;
|
||||
loadingRef.current = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
const [reviewRes, suggestedRes] = await Promise.all([
|
||||
api.get<GmailReviewQueueResponse>("/gmail/review-candidates"),
|
||||
api.get<GmailSuggestedJobsResponse>("/gmail/suggested-jobs"),
|
||||
]);
|
||||
if (!isCurrentView(view) || request !== readSequence.current) return false;
|
||||
setData(reviewRes.data);
|
||||
setSuggestions(suggestedRes.data);
|
||||
setReadError(null);
|
||||
setNotes((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const thread of reviewRes.data.threads) {
|
||||
@@ -44,19 +75,54 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, t("gmailReviewLoadFailed")), "error");
|
||||
if (isCurrentView(view) && request === readSequence.current) setReadError(error);
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (mountedRef.current && request === readSequence.current) {
|
||||
loadingRef.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [t, toast]);
|
||||
}, [isCurrentView]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
readSequence.current += 1;
|
||||
viewSequence.current += 1;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
viewSequence.current += 1;
|
||||
readSequence.current += 1;
|
||||
loadingRef.current = false;
|
||||
setLoading(false);
|
||||
if (active && !mutationRef.current) void load();
|
||||
}, [active, load]);
|
||||
|
||||
const beginOperation = useCallback((kind: "decision" | "sync" | "create", threadId?: string) => {
|
||||
if (!mountedRef.current || !activeRef.current || mutationRef.current || loadingRef.current || readError) return null;
|
||||
bodyRef.current?.focus();
|
||||
mutationRef.current = true;
|
||||
setOperation({ kind, threadId });
|
||||
setWriteError(null);
|
||||
return viewSequence.current;
|
||||
}, [readError]);
|
||||
|
||||
const finishOperation = useCallback((view: number) => {
|
||||
mutationRef.current = false;
|
||||
if (!mountedRef.current) return;
|
||||
setOperation(null);
|
||||
if (activeRef.current && view !== viewSequence.current) void load();
|
||||
}, [load]);
|
||||
|
||||
const saveDecision = useCallback(async (threadId: string, decision: "linked" | "rejected" | "review" | "suggested", jobApplicationId?: number) => {
|
||||
setSavingThreadId(threadId);
|
||||
const view = beginOperation("decision", threadId);
|
||||
if (view === null) return;
|
||||
try {
|
||||
await api.post("/gmail/review-decision", {
|
||||
threadId,
|
||||
@@ -64,26 +130,29 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
|
||||
jobApplicationId: decision === "linked" ? jobApplicationId ?? null : null,
|
||||
note: notes[threadId]?.trim() || null,
|
||||
});
|
||||
if (!isCurrentView(view)) return;
|
||||
await load();
|
||||
if (!isCurrentView(view)) return;
|
||||
toast(
|
||||
decision === "linked"
|
||||
? t("gmailReviewLinked")
|
||||
? tRef.current("gmailReviewLinked")
|
||||
: decision === "rejected"
|
||||
? t("gmailReviewRejected")
|
||||
? tRef.current("gmailReviewRejected")
|
||||
: decision === "suggested"
|
||||
? t("gmailReviewKeptSuggested")
|
||||
: t("gmailReviewReturned"),
|
||||
? tRef.current("gmailReviewKeptSuggested")
|
||||
: tRef.current("gmailReviewReturned"),
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, t("gmailReviewSaveFailed")), "error");
|
||||
if (isCurrentView(view)) setWriteError({ cause: error, key: "gmailReviewSaveFailed" });
|
||||
} finally {
|
||||
setSavingThreadId(null);
|
||||
finishOperation(view);
|
||||
}
|
||||
}, [load, notes, t, toast]);
|
||||
}, [beginOperation, finishOperation, isCurrentView, load, notes, toast]);
|
||||
|
||||
const runManualSync = useCallback(async () => {
|
||||
setSyncing(true);
|
||||
const view = beginOperation("sync");
|
||||
if (view === null) return;
|
||||
try {
|
||||
const res = await api.post<GmailManualSyncResult>("/gmail/manual-sync", {
|
||||
lookbackDays: 365,
|
||||
@@ -91,23 +160,26 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
|
||||
autoImportHighConfidence: true,
|
||||
includeSpamTrash: false,
|
||||
});
|
||||
if (!isCurrentView(view)) return;
|
||||
await load();
|
||||
if (!isCurrentView(view)) return;
|
||||
toast(
|
||||
t("gmailReviewSyncFinished", { linked: res.data.importedThreads, review: res.data.reviewThreadCount, unmatched: res.data.unmatchedThreadCount }),
|
||||
tRef.current("gmailReviewSyncFinished", { linked: res.data.importedThreads, review: res.data.reviewThreadCount, unmatched: res.data.unmatchedThreadCount }),
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, t("gmailReviewSyncFailed")), "error");
|
||||
if (isCurrentView(view)) setWriteError({ cause: error, key: "gmailReviewSyncFailed" });
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
finishOperation(view);
|
||||
}
|
||||
}, [load, t, toast]);
|
||||
}, [beginOperation, finishOperation, isCurrentView, load, toast]);
|
||||
|
||||
const createSuggestedJob = useCallback(async (threadId: string) => {
|
||||
const suggestion = suggestions?.items.find((item) => item.threadId === threadId);
|
||||
if (!suggestion) return;
|
||||
if (!suggestion || createdJobs[threadId]) return;
|
||||
|
||||
setCreatingThreadId(threadId);
|
||||
const view = beginOperation("create", threadId);
|
||||
if (view === null) return;
|
||||
try {
|
||||
const res = await api.post<CreatedSuggestedGmailJobResult>("/gmail/create-suggested-job", {
|
||||
threadId,
|
||||
@@ -118,92 +190,93 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
|
||||
notes: notes[threadId]?.trim() || suggestion.preview || null,
|
||||
status: "Applied",
|
||||
});
|
||||
await load();
|
||||
toast(t(res.data.imported === 1 ? "gmailReviewCreatedImportedMessage" : "gmailReviewCreatedImportedMessages", { count: res.data.imported }), "success");
|
||||
navigate(`/jobs/${res.data.jobApplicationId}?section=communication`);
|
||||
if (!mountedRef.current) return;
|
||||
// Keep a successful creation identity even if the owner changes views before it settles.
|
||||
setCreatedJobs((prev) => ({ ...prev, [threadId]: res.data.jobApplicationId }));
|
||||
if (!isCurrentView(view)) return;
|
||||
const refreshed = await load();
|
||||
if (!isCurrentView(view)) return;
|
||||
toast(tRef.current(res.data.imported === 1 ? "gmailReviewCreatedImportedMessage" : "gmailReviewCreatedImportedMessages", { count: res.data.imported }), "success");
|
||||
if (refreshed) navigate(`/jobs/${res.data.jobApplicationId}?section=communication`);
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, t("gmailReviewCreateFailed")), "error");
|
||||
if (isCurrentView(view)) setWriteError({ cause: error, key: "gmailReviewCreateFailed" });
|
||||
} finally {
|
||||
setCreatingThreadId(null);
|
||||
finishOperation(view);
|
||||
}
|
||||
}, [load, navigate, notes, suggestions?.items, t, toast]);
|
||||
}, [beginOperation, createdJobs, finishOperation, isCurrentView, load, navigate, notes, suggestions?.items, t, toast]);
|
||||
|
||||
const filteredThreads = useMemo(() => {
|
||||
const threads = data?.threads ?? [];
|
||||
return routingFilter === "all" ? threads : threads.filter((thread) => thread.routing === routingFilter);
|
||||
}, [data?.threads, routingFilter]);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
mt: 0,
|
||||
p: 2,
|
||||
borderRadius: 4,
|
||||
border: "none",
|
||||
boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap", mb: 2 }}>
|
||||
<Box>
|
||||
<Typography component={embedded ? "h2" : "h1"} variant="h5" sx={{ fontWeight: 900 }}>{embedded ? t("gmailReviewEmbeddedTitle") : t("gmailReviewTitle")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
{t("gmailReviewSubtitle")}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
<Button variant="contained" onClick={() => void runManualSync()} disabled={syncing}>
|
||||
{syncing ? t("gmailReviewSyncing") : t("gmailReviewRunSync")}
|
||||
</Button>
|
||||
<Button variant="outlined" onClick={() => void load()} disabled={loading || syncing}>
|
||||
{loading ? t("loading") : t("correspondenceRefresh")}
|
||||
</Button>
|
||||
{!embedded ? <Button variant="text" onClick={() => navigate("/correspondence")}>{t("gmailReviewBackToInbox")}</Button> : null}
|
||||
</Box>
|
||||
</Box>
|
||||
const disconnected = (readError as ReviewRequestError)?.response?.data?.code === "gmail_not_connected";
|
||||
const readTitle = t(disconnected ? "gmailReviewNotConnected" : "gmailReviewLoadFailed");
|
||||
const readNotice = readError ? noticeError(readError, readTitle) : null;
|
||||
if (disconnected && readNotice) readNotice.message = readTitle;
|
||||
const unavailable = disconnected || readNotice?.kind === "unauthorized";
|
||||
const controlsDisabled = !active || loading || operation !== null || readError !== null;
|
||||
const chipSx = { maxWidth: "100%", height: "auto", minHeight: 24, "& .MuiChip-label": { whiteSpace: "normal", overflowWrap: "anywhere", py: 0.5 } };
|
||||
const actions = <>
|
||||
<Button variant="contained" onClick={() => void runManualSync()} disabled={controlsDisabled}>
|
||||
{operation?.kind === "sync" ? t("gmailReviewSyncing") : t("gmailReviewRunSync")}
|
||||
</Button>
|
||||
<Button variant="outlined" onClick={() => { bodyRef.current?.focus(); void load(); }} disabled={!active || loading || operation !== null}>
|
||||
{loading ? t("loading") : t("correspondenceRefresh")}
|
||||
</Button>
|
||||
{!embedded ? <Button variant="text" disabled={!active || operation !== null} onClick={() => navigate("/correspondence")}>{t("gmailReviewBackToInbox")}</Button> : null}
|
||||
</>;
|
||||
const body = <Box ref={bodyRef} role="group" aria-label={t(embedded ? "gmailReviewEmbeddedTitle" : "gmailReviewTitle")} tabIndex={-1} sx={{ minWidth: 0 }}>
|
||||
<ViewStateNotice compact loading={loading || operation !== null} error={readNotice} title={readTitle} onRetry={() => { bodyRef.current?.focus(); void load(); }} />
|
||||
{writeError ? <ViewStateNotice compact title={t(writeError.key)} error={{ ...noticeError(writeError.cause, t(writeError.key)), retryable: false }} /> : null}
|
||||
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 2 }}>
|
||||
<Chip label={t("gmailReviewFilter", { value: routeLabel(routingFilter) })} variant="outlined" />
|
||||
<Chip label={t("gmailReviewFilter", { value: routeLabel(routingFilter) })} variant="outlined" sx={chipSx} />
|
||||
{(["all", "auto-link", "review", "unmatched", "suggested", "linked", "rejected"] as const).map((value) => (
|
||||
<Button key={value} size="small" variant={routingFilter === value ? "contained" : "text"} onClick={() => setRoutingFilter(value)}>
|
||||
<Button key={value} size="small" aria-pressed={routingFilter === value} disabled={!active} variant={routingFilter === value ? "contained" : "text"} onClick={() => setRoutingFilter(value)}>
|
||||
{routeLabel(value)}
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{data ? (
|
||||
{!unavailable && data ? (
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 2 }}>
|
||||
<Chip label={t("gmailReviewCandidateThreads", { count: data.candidateThreadCount })} variant="outlined" />
|
||||
<Chip label={t("gmailReviewAutoLinkCount", { count: data.autoLinkThreadCount })} color="success" variant="outlined" />
|
||||
<Chip label={t("gmailReviewReviewCount", { count: data.reviewThreadCount })} color="warning" variant="outlined" />
|
||||
<Chip label={t("gmailReviewUnmatchedCount", { count: data.unmatchedThreadCount })} variant="outlined" />
|
||||
{suggestions?.count ? <Chip label={t("gmailReviewSuggestedJobsCount", { count: suggestions.count })} color="secondary" variant="outlined" /> : null}
|
||||
<Chip label={t("gmailReviewCandidateThreads", { count: data.candidateThreadCount })} variant="outlined" sx={chipSx} />
|
||||
<Chip label={t("gmailReviewAutoLinkCount", { count: data.autoLinkThreadCount })} color="success" variant="outlined" sx={chipSx} />
|
||||
<Chip label={t("gmailReviewReviewCount", { count: data.reviewThreadCount })} color="warning" variant="outlined" sx={chipSx} />
|
||||
<Chip label={t("gmailReviewUnmatchedCount", { count: data.unmatchedThreadCount })} variant="outlined" sx={chipSx} />
|
||||
{suggestions?.count ? <Chip label={t("gmailReviewSuggestedJobsCount", { count: suggestions.count })} color="secondary" variant="outlined" sx={chipSx} /> : null}
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{loading ? <Box sx={{ py: 6, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : null}
|
||||
{!loading && data && filteredThreads.length === 0 ? <Typography sx={{ color: "text.secondary" }}>{t("gmailReviewNoCandidates")}</Typography> : null}
|
||||
{!loading && !readError && data && filteredThreads.length === 0 ? <Typography color="text.secondary">{t("gmailReviewNoCandidates")}</Typography> : null}
|
||||
|
||||
<Stack spacing={1.25}>
|
||||
{filteredThreads.map((thread) => {
|
||||
{!unavailable && filteredThreads.map((thread) => {
|
||||
const suggestion = (suggestions?.items ?? []).find((item) => item.threadId === thread.threadId);
|
||||
const headingId = `${sectionId}-${encodeURIComponent(thread.threadId)}`;
|
||||
return (
|
||||
<Paper key={thread.threadId} variant="outlined" sx={{ p: 1.5, borderRadius: 3 }}>
|
||||
<Paper key={thread.threadId} component="article" aria-labelledby={headingId} variant="outlined" sx={{ p: "var(--app-card-padding)", borderRadius: 1, minWidth: 0 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "flex-start" }}>
|
||||
<Box sx={{ minWidth: 0, flex: "1 1 420px" }}>
|
||||
<Typography sx={{ fontWeight: 800, overflowWrap: "anywhere" }}>{thread.subject}</Typography>
|
||||
<Typography id={headingId} component={embedded ? "h3" : "h2"} variant="subtitle1" sx={{ fontWeight: 700, overflowWrap: "anywhere" }}>{thread.subject}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
{t("gmailReviewThreadSummary", { count: thread.messageCount, route: routeLabel(thread.routing) })}
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.75 }}>
|
||||
{thread.matchedQueries.slice(0, 3).map((query) => (
|
||||
<Chip key={query} size="small" label={query} variant="outlined" />
|
||||
<Chip key={query} size="small" label={query} variant="outlined" sx={chipSx} />
|
||||
))}
|
||||
{thread.hasImportedMessages ? <Chip size="small" label={t("gmailReviewHasImported")} color="success" variant="outlined" /> : null}
|
||||
{thread.hasImportedMessages ? <Chip size="small" label={t("gmailReviewHasImported")} color="success" variant="outlined" sx={chipSx} /> : null}
|
||||
</Box>
|
||||
<TextField
|
||||
label={t("gmailReviewNotes")}
|
||||
value={notes[thread.threadId] ?? ""}
|
||||
onChange={(event) => setNotes((prev) => ({ ...prev, [thread.threadId]: event.target.value }))}
|
||||
onChange={(event) => {
|
||||
if (!activeRef.current || mutationRef.current) return;
|
||||
setNotes((prev) => ({ ...prev, [thread.threadId]: event.target.value }));
|
||||
}}
|
||||
disabled={!active || operation !== null}
|
||||
size="small"
|
||||
fullWidth
|
||||
multiline
|
||||
@@ -217,17 +290,18 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", justifyContent: "flex-end" }}>
|
||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", justifyContent: "flex-end", minWidth: 0, maxWidth: "100%", "& .MuiButton-root": { maxWidth: "100%", whiteSpace: "normal", overflowWrap: "anywhere" } }}>
|
||||
{thread.jobCandidates.slice(0, 2).map((candidate) => (
|
||||
<Chip
|
||||
key={candidate.jobApplicationId}
|
||||
label={`${candidate.companyName} • ${candidate.jobTitle} (${candidate.score})`}
|
||||
variant="outlined"
|
||||
sx={chipSx}
|
||||
color={candidate.confidence === "high" ? "success" : candidate.confidence === "medium" ? "warning" : "default"}
|
||||
/>
|
||||
))}
|
||||
{thread.jobCandidates[0] ? (
|
||||
<Button size="small" variant="text" onClick={() => navigate(`/jobs/${thread.jobCandidates[0].jobApplicationId}?section=communication`)}>
|
||||
<Button size="small" variant="text" disabled={!active || operation !== null} onClick={() => navigate(`/jobs/${thread.jobCandidates[0].jobApplicationId}?section=communication`)}>
|
||||
{t("gmailReviewOpenTopJob")}
|
||||
</Button>
|
||||
) : null}
|
||||
@@ -235,7 +309,7 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
disabled={savingThreadId === thread.threadId}
|
||||
disabled={controlsDisabled}
|
||||
onClick={() => void saveDecision(thread.threadId, "linked", thread.jobCandidates[0].jobApplicationId)}
|
||||
>
|
||||
{t("gmailReviewLinkTopJob")}
|
||||
@@ -245,7 +319,7 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
disabled={savingThreadId === thread.threadId}
|
||||
disabled={controlsDisabled}
|
||||
onClick={() => void saveDecision(thread.threadId, "review")}
|
||||
>
|
||||
{t("gmailReviewKeepInReview")}
|
||||
@@ -254,26 +328,26 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
disabled={savingThreadId === thread.threadId}
|
||||
disabled={controlsDisabled}
|
||||
onClick={() => void saveDecision(thread.threadId, "suggested")}
|
||||
>
|
||||
{t("gmailReviewSuggestedJobAction")}
|
||||
</Button>
|
||||
{suggestion ? (
|
||||
{createdJobs[thread.threadId] ? <Button size="small" variant="outlined" disabled={!active || operation !== null} onClick={() => navigate(`/jobs/${createdJobs[thread.threadId]}?section=communication`)}>{t("emailInboxOpenJob")}</Button> : suggestion ? (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
disabled={creatingThreadId === thread.threadId}
|
||||
disabled={controlsDisabled}
|
||||
onClick={() => void createSuggestedJob(thread.threadId)}
|
||||
>
|
||||
{creatingThreadId === thread.threadId ? t("gmailReviewCreating") : t("createJob")}
|
||||
{operation?.kind === "create" && operation.threadId === thread.threadId ? t("gmailReviewCreating") : t("createJob")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="error"
|
||||
disabled={savingThreadId === thread.threadId}
|
||||
disabled={controlsDisabled}
|
||||
onClick={() => void saveDecision(thread.threadId, "rejected")}
|
||||
>
|
||||
{t("gmailReviewReject")}
|
||||
@@ -284,6 +358,11 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
</Box>;
|
||||
|
||||
return embedded ? <PageSection title={t("gmailReviewEmbeddedTitle")} description={t("gmailReviewSubtitle")} actions={actions}>{body}</PageSection> :
|
||||
<Box component="section" aria-labelledby={sectionId} sx={{ minWidth: 0, display: "grid", gap: 2 }}>
|
||||
<PageHeader title={t("gmailReviewTitle")} titleId={sectionId} description={t("gmailReviewSubtitle")} actions={actions} />
|
||||
{body}
|
||||
</Box>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user