fix(i18n): localize job email workflows

This commit is contained in:
cesnimda
2026-08-29 22:45:49 +02:00
parent ee8eacf0de
commit 3c9b22c97e
8 changed files with 469 additions and 144 deletions
+51 -40
View File
@@ -4,9 +4,11 @@ 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";
export default function GmailReviewPage({ embedded = false }: { embedded?: boolean }) {
const { toast } = useToast();
const { t } = useI18n();
const navigate = useNavigate();
const [data, setData] = useState<GmailReviewQueueResponse | null>(null);
const [suggestions, setSuggestions] = useState<GmailSuggestedJobsResponse | null>(null);
@@ -16,6 +18,15 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
const [creatingThreadId, setCreatingThreadId] = useState<string | null>(null);
const [routingFilter, setRoutingFilter] = useState<"all" | "auto-link" | "review" | "unmatched" | "suggested" | "linked" | "rejected">("all");
const [notes, setNotes] = useState<Record<string, string>>({});
const routeLabel = (value: string) => {
if (value === "auto-link") return t("gmailReviewRouteAutoLink");
if (value === "review") return t("gmailReviewRouteReview");
if (value === "unmatched") return t("gmailReviewRouteUnmatched");
if (value === "suggested") return t("gmailReviewRouteSuggested");
if (value === "linked") return t("gmailReviewRouteLinked");
if (value === "rejected") return t("gmailReviewRouteRejected");
return t("gmailReviewRouteAll");
};
const load = useCallback(async () => {
setLoading(true);
@@ -34,11 +45,11 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
return next;
});
} catch (error) {
toast(getApiErrorMessage(error, "Failed to load Gmail review candidates."), "error");
toast(getApiErrorMessage(error, t("gmailReviewLoadFailed")), "error");
} finally {
setLoading(false);
}
}, [toast]);
}, [t, toast]);
useEffect(() => {
void load();
@@ -56,20 +67,20 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
await load();
toast(
decision === "linked"
? "Thread linked and imported."
? t("gmailReviewLinked")
: decision === "rejected"
? "Thread rejected from review."
? t("gmailReviewRejected")
: decision === "suggested"
? "Thread kept as suggested job material."
: "Thread returned to review.",
? t("gmailReviewKeptSuggested")
: t("gmailReviewReturned"),
"success",
);
} catch (error) {
toast(getApiErrorMessage(error, "Failed to save Gmail review decision."), "error");
toast(getApiErrorMessage(error, t("gmailReviewSaveFailed")), "error");
} finally {
setSavingThreadId(null);
}
}, [load, notes, toast]);
}, [load, notes, t, toast]);
const runManualSync = useCallback(async () => {
setSyncing(true);
@@ -82,15 +93,15 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
});
await load();
toast(
`Manual Gmail sync finished: ${res.data.importedThreads} threads linked, ${res.data.reviewThreadCount} review, ${res.data.unmatchedThreadCount} unmatched.`,
t("gmailReviewSyncFinished", { linked: res.data.importedThreads, review: res.data.reviewThreadCount, unmatched: res.data.unmatchedThreadCount }),
"success",
);
} catch (error) {
toast(getApiErrorMessage(error, "Failed to run Gmail manual sync."), "error");
toast(getApiErrorMessage(error, t("gmailReviewSyncFailed")), "error");
} finally {
setSyncing(false);
}
}, [load, toast]);
}, [load, t, toast]);
const createSuggestedJob = useCallback(async (threadId: string) => {
const suggestion = suggestions?.items.find((item) => item.threadId === threadId);
@@ -100,22 +111,22 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
try {
const res = await api.post<CreatedSuggestedGmailJobResult>("/gmail/create-suggested-job", {
threadId,
companyName: suggestion.companyName || "Unknown company",
jobTitle: suggestion.suggestedJobTitle || suggestion.subject || "Suggested role",
companyName: suggestion.companyName || t("gmailReviewUnknownCompany"),
jobTitle: suggestion.suggestedJobTitle || suggestion.subject || t("gmailReviewSuggestedRole"),
recruiterName: suggestion.recruiterName || null,
recruiterEmail: suggestion.recruiterEmail || null,
notes: notes[threadId]?.trim() || suggestion.preview || null,
status: "Applied",
});
await load();
toast(`Created suggested job and imported ${res.data.imported} message${res.data.imported === 1 ? "" : "s"}.`, "success");
toast(t(res.data.imported === 1 ? "gmailReviewCreatedImportedMessage" : "gmailReviewCreatedImportedMessages", { count: res.data.imported }), "success");
navigate(`/jobs/${res.data.jobApplicationId}?section=communication`);
} catch (error) {
toast(getApiErrorMessage(error, "Failed to create the suggested job."), "error");
toast(getApiErrorMessage(error, t("gmailReviewCreateFailed")), "error");
} finally {
setCreatingThreadId(null);
}
}, [load, navigate, notes, suggestions?.items, toast]);
}, [load, navigate, notes, suggestions?.items, t, toast]);
const filteredThreads = useMemo(() => {
const threads = data?.threads ?? [];
@@ -134,43 +145,43 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
>
<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 ? "Recruitment message review" : "Gmail review queue"}</Typography>
<Typography component={embedded ? "h2" : "h1"} variant="h5" sx={{ fontWeight: 900 }}>{embedded ? t("gmailReviewEmbeddedTitle") : t("gmailReviewTitle")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
Manual sync, high-confidence auto-linking, medium-confidence review, and suggested jobs from unmatched Gmail threads.
{t("gmailReviewSubtitle")}
</Typography>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Button variant="contained" onClick={() => void runManualSync()} disabled={syncing}>
{syncing ? "Syncing..." : "Run manual sync"}
{syncing ? t("gmailReviewSyncing") : t("gmailReviewRunSync")}
</Button>
<Button variant="outlined" onClick={() => void load()} disabled={loading || syncing}>
{loading ? "Loading..." : "Refresh"}
{loading ? t("loading") : t("correspondenceRefresh")}
</Button>
{!embedded ? <Button variant="text" onClick={() => navigate("/correspondence")}>Back to inbox</Button> : null}
{!embedded ? <Button variant="text" onClick={() => navigate("/correspondence")}>{t("gmailReviewBackToInbox")}</Button> : null}
</Box>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 2 }}>
<Chip label={`Filter: ${routingFilter}`} variant="outlined" />
<Chip label={t("gmailReviewFilter", { value: routeLabel(routingFilter) })} variant="outlined" />
{(["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)}>
{value}
{routeLabel(value)}
</Button>
))}
</Box>
{data ? (
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 2 }}>
<Chip label={`${data.candidateThreadCount} candidate threads`} variant="outlined" />
<Chip label={`${data.autoLinkThreadCount} auto-link`} color="success" variant="outlined" />
<Chip label={`${data.reviewThreadCount} review`} color="warning" variant="outlined" />
<Chip label={`${data.unmatchedThreadCount} unmatched`} variant="outlined" />
{suggestions?.count ? <Chip label={`${suggestions.count} suggested jobs`} color="secondary" variant="outlined" /> : null}
<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}
</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" }}>No Gmail review candidates match the current filter.</Typography> : null}
{!loading && data && filteredThreads.length === 0 ? <Typography sx={{ color: "text.secondary" }}>{t("gmailReviewNoCandidates")}</Typography> : null}
<Stack spacing={1.25}>
{filteredThreads.map((thread) => {
@@ -181,16 +192,16 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
<Box sx={{ minWidth: 0, flex: "1 1 420px" }}>
<Typography sx={{ fontWeight: 800, overflowWrap: "anywhere" }}>{thread.subject}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{thread.messageCount} messages · {thread.routing}
{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" />
))}
{thread.hasImportedMessages ? <Chip size="small" label="Has imported messages" color="success" variant="outlined" /> : null}
{thread.hasImportedMessages ? <Chip size="small" label={t("gmailReviewHasImported")} color="success" variant="outlined" /> : null}
</Box>
<TextField
label="Review notes"
label={t("gmailReviewNotes")}
value={notes[thread.threadId] ?? ""}
onChange={(event) => setNotes((prev) => ({ ...prev, [thread.threadId]: event.target.value }))}
size="small"
@@ -198,11 +209,11 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
multiline
minRows={2}
sx={{ mt: 1.25 }}
placeholder="Why this should link, stay in review, or become a suggested job."
placeholder={t("gmailReviewNotesPlaceholder")}
/>
{suggestion ? (
<Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>
Suggested job: {suggestion.companyName || "Unknown company"} · {suggestion.suggestedJobTitle || "Unknown role"}
{t("gmailReviewSuggestedJob", { company: suggestion.companyName || t("gmailReviewUnknownCompany"), role: suggestion.suggestedJobTitle || t("gmailReviewUnknownRole") })}
</Typography>
) : null}
</Box>
@@ -217,7 +228,7 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
))}
{thread.jobCandidates[0] ? (
<Button size="small" variant="text" onClick={() => navigate(`/jobs/${thread.jobCandidates[0].jobApplicationId}?section=communication`)}>
Open top job
{t("gmailReviewOpenTopJob")}
</Button>
) : null}
{thread.jobCandidates[0] ? (
@@ -227,7 +238,7 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
disabled={savingThreadId === thread.threadId}
onClick={() => void saveDecision(thread.threadId, "linked", thread.jobCandidates[0].jobApplicationId)}
>
Link top job
{t("gmailReviewLinkTopJob")}
</Button>
) : null}
<Button
@@ -237,7 +248,7 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
disabled={savingThreadId === thread.threadId}
onClick={() => void saveDecision(thread.threadId, "review")}
>
Keep in review
{t("gmailReviewKeepInReview")}
</Button>
<Button
size="small"
@@ -246,7 +257,7 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
disabled={savingThreadId === thread.threadId}
onClick={() => void saveDecision(thread.threadId, "suggested")}
>
Suggested job
{t("gmailReviewSuggestedJobAction")}
</Button>
{suggestion ? (
<Button
@@ -255,7 +266,7 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
disabled={creatingThreadId === thread.threadId}
onClick={() => void createSuggestedJob(thread.threadId)}
>
{creatingThreadId === thread.threadId ? "Creating..." : "Create job"}
{creatingThreadId === thread.threadId ? t("gmailReviewCreating") : t("createJob")}
</Button>
) : null}
<Button
@@ -265,7 +276,7 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
disabled={savingThreadId === thread.threadId}
onClick={() => void saveDecision(thread.threadId, "rejected")}
>
Reject
{t("gmailReviewReject")}
</Button>
</Box>
</Box>