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"; 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(null); const [suggestions, setSuggestions] = useState(null); const [loading, setLoading] = useState(false); const [readError, setReadError] = useState(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>({}); const [routingFilter, setRoutingFilter] = useState<"all" | "auto-link" | "review" | "unmatched" | "suggested" | "linked" | "rejected">("all"); const [notes, setNotes] = useState>({}); const sectionId = useId(); const bodyRef = useRef(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"); 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 () => { 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("/gmail/review-candidates"), api.get("/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) { if (next[thread.threadId] === undefined) next[thread.threadId] = thread.decisionNote || ""; } return next; }); return true; } catch (error) { if (isCurrentView(view) && request === readSequence.current) setReadError(error); return false; } finally { if (mountedRef.current && request === readSequence.current) { loadingRef.current = false; setLoading(false); } } }, [isCurrentView]); useEffect(() => { 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) => { const view = beginOperation("decision", threadId); if (view === null) return; try { await api.post("/gmail/review-decision", { threadId, decision, jobApplicationId: decision === "linked" ? jobApplicationId ?? null : null, note: notes[threadId]?.trim() || null, }); if (!isCurrentView(view)) return; await load(); if (!isCurrentView(view)) return; toast( decision === "linked" ? tRef.current("gmailReviewLinked") : decision === "rejected" ? tRef.current("gmailReviewRejected") : decision === "suggested" ? tRef.current("gmailReviewKeptSuggested") : tRef.current("gmailReviewReturned"), "success", ); } catch (error) { if (isCurrentView(view)) setWriteError({ cause: error, key: "gmailReviewSaveFailed" }); } finally { finishOperation(view); } }, [beginOperation, finishOperation, isCurrentView, load, notes, toast]); const runManualSync = useCallback(async () => { const view = beginOperation("sync"); if (view === null) return; try { const res = await api.post("/gmail/manual-sync", { lookbackDays: 365, maxResultsPerQuery: 8, autoImportHighConfidence: true, includeSpamTrash: false, }); if (!isCurrentView(view)) return; await load(); if (!isCurrentView(view)) return; toast( tRef.current("gmailReviewSyncFinished", { linked: res.data.importedThreads, review: res.data.reviewThreadCount, unmatched: res.data.unmatchedThreadCount }), "success", ); } catch (error) { if (isCurrentView(view)) setWriteError({ cause: error, key: "gmailReviewSyncFailed" }); } finally { finishOperation(view); } }, [beginOperation, finishOperation, isCurrentView, load, toast]); const createSuggestedJob = useCallback(async (threadId: string) => { const suggestion = suggestions?.items.find((item) => item.threadId === threadId); if (!suggestion || createdJobs[threadId]) return; const view = beginOperation("create", threadId); if (view === null) return; 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", }); 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) { if (isCurrentView(view)) setWriteError({ cause: error, key: "gmailReviewCreateFailed" }); } finally { finishOperation(view); } }, [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]); 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 = <> {!embedded ? : null} ; const body = { bodyRef.current?.focus(); void load(); }} /> {writeError ? : null} {(["all", "auto-link", "review", "unmatched", "suggested", "linked", "rejected"] as const).map((value) => ( ))} {!unavailable && data ? ( {suggestions?.count ? : null} ) : null} {!loading && !readError && data && filteredThreads.length === 0 ? {t("gmailReviewNoCandidates")} : null} {!unavailable && filteredThreads.map((thread) => { const suggestion = (suggestions?.items ?? []).find((item) => item.threadId === thread.threadId); const headingId = `${sectionId}-${encodeURIComponent(thread.threadId)}`; return ( {thread.subject} {t("gmailReviewThreadSummary", { count: thread.messageCount, route: routeLabel(thread.routing) })} {thread.matchedQueries.slice(0, 3).map((query) => ( ))} {thread.hasImportedMessages ? : null} { if (!activeRef.current || mutationRef.current) return; setNotes((prev) => ({ ...prev, [thread.threadId]: event.target.value })); }} disabled={!active || operation !== null} 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} {createdJobs[thread.threadId] ? : suggestion ? ( ) : null} ); })} ; return embedded ? {body} : {body} ; }