feat: add gmail review queue surface
This commit is contained in:
@@ -14,6 +14,7 @@ import AlarmIcon from "@mui/icons-material/Alarm";
|
||||
import AccountCircleIcon from "@mui/icons-material/AccountCircle";
|
||||
import ShieldIcon from "@mui/icons-material/Shield";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import MailOutlineIcon from "@mui/icons-material/MailOutline";
|
||||
import MemoryIcon from "@mui/icons-material/Memory";
|
||||
|
||||
import { Navigate, Route, Routes, useLocation, useNavigate, createBrowserRouter, RouterProvider } from "react-router-dom";
|
||||
@@ -46,6 +47,8 @@ const ProfilePage = lazy(() => import("./pages/ProfilePage"));
|
||||
const AdminAuditPage = lazy(() => import("./pages/AdminAuditPage"));
|
||||
const AdminUsersPage = lazy(() => import("./pages/AdminUsersPage"));
|
||||
const AdminSystemPage = lazy(() => import("./pages/AdminSystemPage"));
|
||||
const CorrespondenceInboxPage = lazy(() => import("./pages/CorrespondenceInboxPage"));
|
||||
const GmailReviewPage = lazy(() => import("./pages/GmailReviewPage"));
|
||||
const NotFoundPage = lazy(() => import("./pages/NotFoundPage"));
|
||||
|
||||
type AuthConfig = { requireAuth: boolean };
|
||||
@@ -67,6 +70,8 @@ function breadcrumbsFor(path: string, t: (k: any) => string): string[] {
|
||||
if (path.startsWith("/reminders")) return [t("home"), t("reminders")];
|
||||
if (path.startsWith("/kanban")) return [t("home"), t("kanbanBoard")];
|
||||
if (path.startsWith("/companies")) return [t("home"), t("companies")];
|
||||
if (path.startsWith("/correspondence/review")) return [t("home"), "Gmail review queue"];
|
||||
if (path.startsWith("/correspondence")) return [t("home"), "Correspondence inbox"];
|
||||
if (path.startsWith("/trash")) return [t("home"), t("trash")];
|
||||
if (path.startsWith("/settings")) return [t("home"), t("settings")];
|
||||
if (path.startsWith("/profile")) return [t("home"), t("account"), t("profile")];
|
||||
@@ -82,6 +87,8 @@ function titleFor(path: string, t: (k: any) => string): string {
|
||||
if (path.startsWith("/jobs")) return t("jobApplications");
|
||||
if (path.startsWith("/kanban")) return t("kanbanBoard");
|
||||
if (path.startsWith("/companies")) return t("companies");
|
||||
if (path.startsWith("/correspondence/review")) return "Gmail review queue";
|
||||
if (path.startsWith("/correspondence")) return "Correspondence inbox";
|
||||
if (path.startsWith("/trash")) return t("trash");
|
||||
if (path.startsWith("/settings")) return t("settings");
|
||||
if (path.startsWith("/profile")) return t("profile");
|
||||
@@ -154,6 +161,8 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
{ to: "/reminders", label: t("reminders"), icon: <AlarmIcon fontSize="small" />, badgeCount: notifCount, section: t("manage") },
|
||||
{ to: "/kanban", label: t("kanbanBoard"), icon: <ViewKanbanIcon fontSize="small" />, section: t("manage") },
|
||||
{ to: "/companies", label: t("companies"), icon: <BusinessIcon fontSize="small" />, section: t("manage") },
|
||||
{ to: "/correspondence", label: "Correspondence", icon: <MailOutlineIcon fontSize="small" />, section: t("manage") },
|
||||
{ to: "/correspondence/review", label: "Gmail review", icon: <MailOutlineIcon fontSize="small" />, section: t("manage") },
|
||||
{ to: "/trash", label: t("trash"), icon: <DeleteOutlineIcon fontSize="small" />, section: t("manage") },
|
||||
];
|
||||
|
||||
@@ -226,6 +235,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
<Route path="/kanban" element={<KanbanBoard />} />
|
||||
<Route path="/companies" element={<CompaniesTable />} />
|
||||
<Route path="/correspondence" element={<CorrespondenceInboxPage />} />
|
||||
<Route path="/correspondence/review" element={<GmailReviewPage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/admin/audit" element={<AdminAuditPage />} />
|
||||
<Route path="/admin/users" element={<AdminUsersPage />} />
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from './toast';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
import GmailReviewPage from './pages/GmailReviewPage';
|
||||
import { api } from './api';
|
||||
|
||||
jest.mock('./api', () => ({
|
||||
api: {
|
||||
get: jest.fn(),
|
||||
post: jest.fn(),
|
||||
put: jest.fn(),
|
||||
patch: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
|
||||
},
|
||||
getApiErrorMessage: (error: any, fallback?: string) => fallback || 'Request failed.',
|
||||
}));
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<ToastProvider>
|
||||
<I18nProvider>
|
||||
<MemoryRouter>
|
||||
<GmailReviewPage />
|
||||
</MemoryRouter>
|
||||
</I18nProvider>
|
||||
</ToastProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('GmailReviewPage', () => {
|
||||
beforeEach(() => {
|
||||
mockedApi.get.mockResolvedValue({
|
||||
data: {
|
||||
queries: ['"Acme" "Backend Developer" newer_than:365d'],
|
||||
candidateThreadCount: 2,
|
||||
autoLinkThreadCount: 1,
|
||||
reviewThreadCount: 1,
|
||||
unmatchedThreadCount: 0,
|
||||
threads: [
|
||||
{
|
||||
threadId: 'thread-1',
|
||||
subject: 'Backend Developer interview',
|
||||
latestDate: new Date().toISOString(),
|
||||
messageCount: 2,
|
||||
routing: 'review',
|
||||
hasImportedMessages: false,
|
||||
matchedQueries: ['"Acme" "Backend Developer" newer_than:365d'],
|
||||
jobCandidates: [
|
||||
{ jobApplicationId: 42, jobTitle: 'Backend Developer', companyName: 'Acme', score: 24, confidence: 'medium', reasons: [{ label: 'company', value: 'Acme', points: 18 }] },
|
||||
],
|
||||
messages: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('renders Gmail review queue summary and candidate threads', async () => {
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText(/gmail review queue/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/2 candidate threads/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/backend developer interview/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/acme • backend developer \(24\)/i)).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.get).toHaveBeenCalledWith('/gmail/review-candidates');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -299,6 +299,36 @@ export interface GmailThreadRefreshResult {
|
||||
threads: GmailThreadRefreshThreadResult[];
|
||||
}
|
||||
|
||||
export interface GmailReviewJobCandidate {
|
||||
jobApplicationId: number;
|
||||
jobTitle: string;
|
||||
companyName: string;
|
||||
score: number;
|
||||
confidence: string;
|
||||
reasons: GmailJobMatchReason[];
|
||||
}
|
||||
|
||||
export interface GmailReviewThread {
|
||||
threadId: string;
|
||||
subject: string;
|
||||
latestDate?: string;
|
||||
messageCount: number;
|
||||
routing: string;
|
||||
hasImportedMessages: boolean;
|
||||
matchedQueries: string[];
|
||||
jobCandidates: GmailReviewJobCandidate[];
|
||||
messages: GmailJobMatchedMessage[];
|
||||
}
|
||||
|
||||
export interface GmailReviewQueueResponse {
|
||||
queries: string[];
|
||||
candidateThreadCount: number;
|
||||
autoLinkThreadCount: number;
|
||||
reviewThreadCount: number;
|
||||
unmatchedThreadCount: number;
|
||||
threads: GmailReviewThread[];
|
||||
}
|
||||
|
||||
export interface GmailStatus {
|
||||
connected: boolean;
|
||||
gmailAddress?: string;
|
||||
|
||||
Reference in New Issue
Block a user