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"; export default function GmailReviewPage() { const { toast } = useToast(); 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 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, "Failed to load Gmail review candidates."), "error"); } finally { setLoading(false); } }, [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" ? "Thread linked and imported." : decision === "rejected" ? "Thread rejected from review." : decision === "suggested" ? "Thread kept as suggested job material." : "Thread returned to review.", "success", ); } catch (error) { toast(getApiErrorMessage(error, "Failed to save Gmail review decision."), "error"); } finally { setSavingThreadId(null); } }, [load, notes, 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( `Manual Gmail sync finished: ${res.data.importedThreads} threads linked, ${res.data.reviewThreadCount} review, ${res.data.unmatchedThreadCount} unmatched.`, "success", ); } catch (error) { toast(getApiErrorMessage(error, "Failed to run Gmail manual sync."), "error"); } finally { setSyncing(false); } }, [load, 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 || "Unknown company", jobTitle: suggestion.suggestedJobTitle || suggestion.subject || "Suggested role", 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"); navigate(`/jobs?open=${res.data.jobApplicationId}`); } catch (error) { toast(getApiErrorMessage(error, "Failed to create the suggested job."), "error"); } finally { setCreatingThreadId(null); } }, [load, navigate, notes, suggestions?.items, toast]); const filteredThreads = useMemo(() => { const threads = data?.threads ?? []; return routingFilter === "all" ? threads : threads.filter((thread) => thread.routing === routingFilter); }, [data?.threads, routingFilter]); return ( Gmail review queue Manual sync, high-confidence auto-linking, medium-confidence review, and suggested jobs from unmatched Gmail threads. {(["all", "auto-link", "review", "unmatched", "suggested", "linked", "rejected"] as const).map((value) => ( ))} {data ? ( {suggestions?.count ? : null} ) : null} {loading ? : null} {!loading && data && filteredThreads.length === 0 ? No Gmail review candidates match the current filter. : null} {filteredThreads.map((thread) => { const suggestion = (suggestions?.items ?? []).find((item) => item.threadId === thread.threadId); return ( {thread.subject} {thread.messageCount} messages · {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="Why this should link, stay in review, or become a suggested job." /> {suggestion ? ( Suggested job: {suggestion.companyName || "Unknown company"} · {suggestion.suggestedJobTitle || "Unknown role"} ) : null} {thread.jobCandidates.slice(0, 2).map((candidate) => ( ))} {thread.jobCandidates[0] ? ( ) : null} {thread.jobCandidates[0] ? ( ) : null} {suggestion ? ( ) : null} ); })} ); }