feat: add gmail review decisions

This commit is contained in:
2026-04-01 21:45:01 +02:00
parent a0e823facf
commit 161ecb4b94
7 changed files with 160 additions and 16 deletions
+15 -1
View File
@@ -1,6 +1,6 @@
import React from 'react';
import '@testing-library/jest-dom';
import { render, screen, waitFor } from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from './toast';
import { I18nProvider } from './i18n/I18nProvider';
@@ -77,4 +77,18 @@ describe('GmailReviewPage', () => {
expect(mockedApi.get).toHaveBeenCalledWith('/gmail/review-candidates');
});
});
test('persists a review decision for the top job', async () => {
renderPage();
fireEvent.click(await screen.findByRole('button', { name: /link top job/i }));
await waitFor(() => {
expect(mockedApi.post).toHaveBeenCalledWith('/gmail/review-decision', {
threadId: 'thread-1',
decision: 'linked',
jobApplicationId: 42,
});
});
});
});
+82 -10
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react";
import React, { useCallback, useEffect, useState } from "react";
import { Box, Button, Chip, CircularProgress, Paper, Stack, Typography } from "@mui/material";
import { api, getApiErrorMessage } from "../api";
import { GmailReviewQueueResponse } from "../types";
@@ -10,8 +10,9 @@ export default function GmailReviewPage() {
const navigate = useNavigate();
const [data, setData] = useState<GmailReviewQueueResponse | null>(null);
const [loading, setLoading] = useState(false);
const [savingThreadId, setSavingThreadId] = useState<string | null>(null);
const load = async () => {
const load = useCallback(async () => {
setLoading(true);
try {
const res = await api.get<GmailReviewQueueResponse>("/gmail/review-candidates");
@@ -21,18 +22,48 @@ export default function GmailReviewPage() {
} finally {
setLoading(false);
}
};
}, [toast]);
useEffect(() => { void load(); }, []);
useEffect(() => {
void load();
}, [load]);
const saveDecision = useCallback(async (threadId: string, decision: "linked" | "rejected" | "review", jobApplicationId?: number) => {
setSavingThreadId(threadId);
try {
await api.post("/gmail/review-decision", {
threadId,
decision,
jobApplicationId: decision === "linked" ? jobApplicationId ?? null : null,
});
await load();
toast(
decision === "linked"
? "Thread linked for review."
: decision === "rejected"
? "Thread rejected from review."
: "Thread returned to review.",
"success",
);
} catch (error) {
toast(getApiErrorMessage(error, "Failed to save Gmail review decision."), "error");
} finally {
setSavingThreadId(null);
}
}, [load, toast]);
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>
<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>
<Button variant="contained" onClick={() => void load()} disabled={loading}>
{loading ? "Loading..." : "Refresh"}
</Button>
</Box>
{data ? (
@@ -53,16 +84,57 @@ export default function GmailReviewPage() {
<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>
<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.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'} />
<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="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="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>
</Box>
</Box>
</Paper>