feat: add gmail review decisions
This commit is contained in:
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user