import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Box, Button, Chip, CircularProgress, 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"; export default function GmailReviewPage({ embedded = false }: { embedded?: boolean }) { const { toast } = useToast(); const { t } = useI18n(); const navigate = useNavigate(); const [data, setData] = useState(null); const [suggestions, setSuggestions] = useState(null); const [loading, setLoading] = useState(false); const [syncing, setSyncing] = useState(false); const [savingThreadId, setSavingThreadId] = useState(null); const [creatingThreadId, setCreatingThreadId] = useState(null); const [routingFilter, setRoutingFilter] = useState<"all" | "auto-link" | "review" | "unmatched" | "suggested" | "linked" | "rejected">("all"); const [notes, setNotes] = useState>({}); 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); try { const [reviewRes, suggestedRes] = await Promise.all([ api.get("/gmail/review-candidates"), api.get("/gmail/suggested-jobs"), ]); setData(reviewRes.data); setSuggestions(suggestedRes.data); setNotes((prev) => { const next = { ...prev }; for (const thread of reviewRes.data.threads) { if (next[thread.threadId] === undefined) next[thread.threadId] = thread.decisionNote || ""; } return next; }); } catch (error) { toast(getApiErrorMessage(error, t("gmailReviewLoadFailed")), "error"); } finally { setLoading(false); } }, [t, toast]); useEffect(() => { void load(); }, [load]); const saveDecision = useCallback(async (threadId: string, decision: "linked" | "rejected" | "review" | "suggested", jobApplicationId?: number) => { setSavingThreadId(threadId); try { await api.post("/gmail/review-decision", { threadId, decision, jobApplicationId: decision === "linked" ? jobApplicationId ?? null : null, note: notes[threadId]?.trim() || null, }); await load(); toast( decision === "linked" ? t("gmailReviewLinked") : decision === "rejected" ? t("gmailReviewRejected") : decision === "suggested" ? t("gmailReviewKeptSuggested") : t("gmailReviewReturned"), "success", ); } catch (error) { toast(getApiErrorMessage(error, t("gmailReviewSaveFailed")), "error"); } finally { setSavingThreadId(null); } }, [load, notes, t, toast]); const runManualSync = useCallback(async () => { setSyncing(true); try { const res = await api.post("/gmail/manual-sync", { lookbackDays: 365, maxResultsPerQuery: 8, autoImportHighConfidence: true, includeSpamTrash: false, }); await load(); toast( t("gmailReviewSyncFinished", { linked: res.data.importedThreads, review: res.data.reviewThreadCount, unmatched: res.data.unmatchedThreadCount }), "success", ); } catch (error) { toast(getApiErrorMessage(error, t("gmailReviewSyncFailed")), "error"); } finally { setSyncing(false); } }, [load, t, toast]); const createSuggestedJob = useCallback(async (threadId: string) => { const suggestion = suggestions?.items.find((item) => item.threadId === threadId); if (!suggestion) return; setCreatingThreadId(threadId); try { const res = await api.post("/gmail/create-suggested-job", { threadId, 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(t(res.data.imported === 1 ? "gmailReviewCreatedImportedMessage" : "gmailReviewCreatedImportedMessages", { count: res.data.imported }), "success"); navigate(`/jobs/${res.data.jobApplicationId}?section=communication`); } catch (error) { toast(getApiErrorMessage(error, t("gmailReviewCreateFailed")), "error"); } finally { setCreatingThreadId(null); } }, [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 ( {embedded ? t("gmailReviewEmbeddedTitle") : t("gmailReviewTitle")} {t("gmailReviewSubtitle")} {!embedded ? : null} {(["all", "auto-link", "review", "unmatched", "suggested", "linked", "rejected"] as const).map((value) => ( ))} {data ? ( {suggestions?.count ? : null} ) : null} {loading ? : null} {!loading && data && filteredThreads.length === 0 ? {t("gmailReviewNoCandidates")} : null} {filteredThreads.map((thread) => { const suggestion = (suggestions?.items ?? []).find((item) => item.threadId === thread.threadId); return ( {thread.subject} {t("gmailReviewThreadSummary", { count: thread.messageCount, route: routeLabel(thread.routing) })} {thread.matchedQueries.slice(0, 3).map((query) => ( ))} {thread.hasImportedMessages ? : null} setNotes((prev) => ({ ...prev, [thread.threadId]: event.target.value }))} size="small" fullWidth multiline minRows={2} sx={{ mt: 1.25 }} placeholder={t("gmailReviewNotesPlaceholder")} /> {suggestion ? ( {t("gmailReviewSuggestedJob", { company: suggestion.companyName || t("gmailReviewUnknownCompany"), role: suggestion.suggestedJobTitle || t("gmailReviewUnknownRole") })} ) : null} {thread.jobCandidates.slice(0, 2).map((candidate) => ( ))} {thread.jobCandidates[0] ? ( ) : null} {thread.jobCandidates[0] ? ( ) : null} {suggestion ? ( ) : null} ); })} ); }