fix(ui): preserve inbox context and review recovery

This commit is contained in:
cesnimda
2026-10-14 07:14:50 +02:00
parent 4c3254c472
commit a791e353b0
19 changed files with 2852 additions and 227 deletions
+164 -85
View File
@@ -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>;
}