feat: add gmail review queue surface

This commit is contained in:
2026-04-01 17:16:00 +02:00
parent 69e78d8951
commit 5af2c66616
6 changed files with 308 additions and 2 deletions
@@ -0,0 +1,73 @@
import React, { useEffect, useState } from "react";
import { Box, Button, Chip, CircularProgress, Paper, Stack, Typography } from "@mui/material";
import { api, getApiErrorMessage } from "../api";
import { GmailReviewQueueResponse } 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<GmailReviewQueueResponse | null>(null);
const [loading, setLoading] = useState(false);
const load = async () => {
setLoading(true);
try {
const res = await api.get<GmailReviewQueueResponse>("/gmail/review-candidates");
setData(res.data);
} catch (error) {
toast(getApiErrorMessage(error, "Failed to load Gmail review candidates."), "error");
} finally {
setLoading(false);
}
};
useEffect(() => { void load(); }, []);
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.</Typography>
</Box>
<Button variant="contained" onClick={() => void load()} disabled={loading}>{loading ? "Loading..." : "Refresh"}</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" />
</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}
<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'} />
))}
{thread.jobCandidates[0] ? <Button size="small" variant="text" onClick={() => navigate(`/jobs?open=${thread.jobCandidates[0].jobApplicationId}`)}>Open top job</Button> : null}
</Box>
</Box>
</Paper>
))}
</Stack>
</Paper>
);
}