Complete Gmail correspondence workflow
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { Box, Button, Chip, CircularProgress, Paper, Stack, Typography } from "@mui/material";
|
||||
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 { GmailReviewQueueResponse } from "../types";
|
||||
import { CreatedSuggestedGmailJobResult, GmailManualSyncResult, GmailReviewQueueResponse, GmailSuggestedJobsResponse } from "../types";
|
||||
import { useToast } from "../toast";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
@@ -9,14 +9,30 @@ export default function GmailReviewPage() {
|
||||
const { toast } = useToast();
|
||||
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 [routingFilter, setRoutingFilter] = useState<"all" | "auto-link" | "review" | "unmatched" | "suggested" | "linked" | "rejected">("all");
|
||||
const [notes, setNotes] = useState<Record<string, string>>({});
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get<GmailReviewQueueResponse>("/gmail/review-candidates");
|
||||
setData(res.data);
|
||||
const [reviewRes, suggestedRes] = await Promise.all([
|
||||
api.get<GmailReviewQueueResponse>("/gmail/review-candidates"),
|
||||
api.get<GmailSuggestedJobsResponse>("/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 {
|
||||
@@ -28,21 +44,24 @@ export default function GmailReviewPage() {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const saveDecision = useCallback(async (threadId: string, decision: "linked" | "rejected" | "review", jobApplicationId?: number) => {
|
||||
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 for review."
|
||||
? "Thread linked and imported."
|
||||
: decision === "rejected"
|
||||
? "Thread rejected from review."
|
||||
: "Thread returned to review.",
|
||||
: decision === "suggested"
|
||||
? "Thread kept as suggested job material."
|
||||
: "Thread returned to review.",
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -50,20 +69,85 @@ export default function GmailReviewPage() {
|
||||
} finally {
|
||||
setSavingThreadId(null);
|
||||
}
|
||||
}, [load, notes, toast]);
|
||||
|
||||
const runManualSync = useCallback(async () => {
|
||||
setSyncing(true);
|
||||
try {
|
||||
const res = await api.post<GmailManualSyncResult>("/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<CreatedSuggestedGmailJobResult>("/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 (
|
||||
<Paper sx={{ mt: 0, p: 2 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap", mb: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 900 }}>Gmail review queue</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
Review medium-confidence Gmail correspondence routing and unmatched job-like threads.
|
||||
Manual sync, high-confidence auto-linking, medium-confidence review, and suggested jobs from unmatched Gmail threads.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button variant="contained" onClick={() => void load()} disabled={loading}>
|
||||
{loading ? "Loading..." : "Refresh"}
|
||||
</Button>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
<Button variant="contained" onClick={() => void runManualSync()} disabled={syncing}>
|
||||
{syncing ? "Syncing..." : "Run manual sync"}
|
||||
</Button>
|
||||
<Button variant="outlined" onClick={() => void load()} disabled={loading || syncing}>
|
||||
{loading ? "Loading..." : "Refresh"}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 2 }}>
|
||||
<Chip label={`Filter: ${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}
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{data ? (
|
||||
@@ -72,73 +156,113 @@ export default function GmailReviewPage() {
|
||||
<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}
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{loading ? <Box sx={{ py: 6, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : null}
|
||||
{!loading && data && data.threads.length === 0 ? <Typography sx={{ color: "text.secondary" }}>No Gmail review candidates right now.</Typography> : null}
|
||||
{!loading && data && filteredThreads.length === 0 ? <Typography sx={{ color: "text.secondary" }}>No Gmail review candidates match the current filter.</Typography> : null}
|
||||
|
||||
<Stack spacing={1.25}>
|
||||
{data?.threads.map((thread) => (
|
||||
<Paper key={thread.threadId} variant="outlined" sx={{ p: 1.5, borderRadius: 3 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "flex-start" }}>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography sx={{ fontWeight: 800, overflowWrap: "anywhere" }}>{thread.subject}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
{thread.messageCount} messages · {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" />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", justifyContent: "flex-end" }}>
|
||||
{thread.jobCandidates.slice(0, 2).map((candidate) => (
|
||||
<Chip
|
||||
key={candidate.jobApplicationId}
|
||||
label={`${candidate.companyName} • ${candidate.jobTitle} (${candidate.score})`}
|
||||
variant="outlined"
|
||||
color={candidate.confidence === "high" ? "success" : candidate.confidence === "medium" ? "warning" : "default"}
|
||||
{filteredThreads.map((thread) => {
|
||||
const suggestion = (suggestions?.items ?? []).find((item) => item.threadId === thread.threadId);
|
||||
return (
|
||||
<Paper key={thread.threadId} variant="outlined" sx={{ p: 1.5, borderRadius: 3 }}>
|
||||
<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 variant="body2" sx={{ color: "text.secondary" }}>
|
||||
{thread.messageCount} messages · {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}
|
||||
</Box>
|
||||
<TextField
|
||||
label="Review notes"
|
||||
value={notes[thread.threadId] ?? ""}
|
||||
onChange={(event) => 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."
|
||||
/>
|
||||
))}
|
||||
{thread.jobCandidates[0] ? (
|
||||
<Button size="small" variant="text" onClick={() => navigate(`/jobs?open=${thread.jobCandidates[0].jobApplicationId}`)}>
|
||||
Open top job
|
||||
</Button>
|
||||
) : null}
|
||||
{thread.jobCandidates[0] ? (
|
||||
{suggestion ? (
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>
|
||||
Suggested job: {suggestion.companyName || "Unknown company"} · {suggestion.suggestedJobTitle || "Unknown role"}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", justifyContent: "flex-end" }}>
|
||||
{thread.jobCandidates.slice(0, 2).map((candidate) => (
|
||||
<Chip
|
||||
key={candidate.jobApplicationId}
|
||||
label={`${candidate.companyName} • ${candidate.jobTitle} (${candidate.score})`}
|
||||
variant="outlined"
|
||||
color={candidate.confidence === "high" ? "success" : candidate.confidence === "medium" ? "warning" : "default"}
|
||||
/>
|
||||
))}
|
||||
{thread.jobCandidates[0] ? (
|
||||
<Button size="small" variant="text" onClick={() => navigate(`/jobs?open=${thread.jobCandidates[0].jobApplicationId}`)}>
|
||||
Open top job
|
||||
</Button>
|
||||
) : null}
|
||||
{thread.jobCandidates[0] ? (
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
disabled={savingThreadId === thread.threadId}
|
||||
onClick={() => void saveDecision(thread.threadId, "linked", thread.jobCandidates[0].jobApplicationId)}
|
||||
>
|
||||
Link top job
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
disabled={savingThreadId === thread.threadId}
|
||||
onClick={() => void saveDecision(thread.threadId, "linked", thread.jobCandidates[0].jobApplicationId)}
|
||||
onClick={() => void saveDecision(thread.threadId, "review")}
|
||||
>
|
||||
Link top job
|
||||
Keep in review
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
disabled={savingThreadId === thread.threadId}
|
||||
onClick={() => void saveDecision(thread.threadId, "review")}
|
||||
>
|
||||
Keep in review
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="error"
|
||||
disabled={savingThreadId === thread.threadId}
|
||||
onClick={() => void saveDecision(thread.threadId, "rejected")}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
disabled={savingThreadId === thread.threadId}
|
||||
onClick={() => void saveDecision(thread.threadId, "suggested")}
|
||||
>
|
||||
Suggested job
|
||||
</Button>
|
||||
{suggestion ? (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
disabled={creatingThreadId === thread.threadId}
|
||||
onClick={() => void createSuggestedJob(thread.threadId)}
|
||||
>
|
||||
{creatingThreadId === thread.threadId ? "Creating..." : "Create job"}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="error"
|
||||
disabled={savingThreadId === thread.threadId}
|
||||
onClick={() => void saveDecision(thread.threadId, "rejected")}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
))}
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user