743 lines
36 KiB
TypeScript
743 lines
36 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
import {
|
|
Box,
|
|
Button,
|
|
Chip,
|
|
CircularProgress,
|
|
Dialog,
|
|
DialogActions,
|
|
DialogContent,
|
|
DialogTitle,
|
|
Divider,
|
|
FormControl,
|
|
InputLabel,
|
|
List,
|
|
ListItemButton,
|
|
ListItemText,
|
|
MenuItem,
|
|
Paper,
|
|
Select,
|
|
Tab,
|
|
Tabs,
|
|
TextField,
|
|
ToggleButton,
|
|
ToggleButtonGroup,
|
|
Typography,
|
|
} from "@mui/material";
|
|
import { alpha, useTheme } from "@mui/material/styles";
|
|
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
|
import AutoAwesomeIcon from "@mui/icons-material/AutoAwesome";
|
|
import MailOutlineIcon from "@mui/icons-material/MailOutline";
|
|
import SearchIcon from "@mui/icons-material/Search";
|
|
import { IconButton } from "@mui/material";
|
|
|
|
import { api, getApiErrorMessage } from "../api";
|
|
import { useToast } from "../toast";
|
|
import {
|
|
CorrespondenceMessage,
|
|
GmailImportMessageResult,
|
|
GmailImportThreadResult,
|
|
GmailJobMatchesResponse,
|
|
GmailRelinkResult,
|
|
GmailStatus,
|
|
GmailThreadRefreshResult,
|
|
GmailUnlinkResult,
|
|
JobApplication,
|
|
} from "../types";
|
|
import { useDialogActions } from "../dialogs";
|
|
import { useI18n } from "../i18n/I18nProvider";
|
|
|
|
function parseRawEmail(raw: string): { subject?: string; date?: string; from?: string; to?: string; body: string } {
|
|
const lines = raw.replace(/\r\n/g, "\n").split("\n");
|
|
const headers: Record<string, string> = {};
|
|
let i = 0;
|
|
|
|
for (; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
if (line.trim() === "") {
|
|
i++;
|
|
break;
|
|
}
|
|
|
|
const idx = line.indexOf(":");
|
|
if (idx <= 0) continue;
|
|
const k = line.slice(0, idx).trim().toLowerCase();
|
|
const v = line.slice(idx + 1).trim();
|
|
headers[k] = headers[k] ? `${headers[k]} ${v}` : v;
|
|
}
|
|
|
|
const body = lines.slice(i).join("\n").trim();
|
|
const dateRaw = headers["date"];
|
|
let iso: string | undefined;
|
|
if (dateRaw) {
|
|
const d = new Date(dateRaw);
|
|
if (!Number.isNaN(+d)) iso = d.toISOString();
|
|
}
|
|
|
|
return { subject: headers["subject"], from: headers["from"], to: headers["to"], date: iso, body };
|
|
}
|
|
|
|
function formatConfidence(value: string) {
|
|
return value ? `${value[0].toUpperCase()}${value.slice(1)} confidence` : "Confidence unknown";
|
|
}
|
|
|
|
function formatReasonLabel(label: string) {
|
|
switch (label) {
|
|
case "company":
|
|
return "Company";
|
|
case "recruiterEmail":
|
|
return "Recruiter email";
|
|
case "recruiter":
|
|
return "Recruiter";
|
|
case "jobTitle":
|
|
return "Job title";
|
|
case "existingSubject":
|
|
return "Existing subject";
|
|
case "recency":
|
|
return "Recent";
|
|
case "status":
|
|
return "Status";
|
|
default:
|
|
return label;
|
|
}
|
|
}
|
|
|
|
interface PagedResult<T> {
|
|
items: T[];
|
|
}
|
|
|
|
export type CorrespondenceJobContext = {
|
|
companyName?: string | null;
|
|
recruiterEmail?: string | null;
|
|
jobTitle?: string | null;
|
|
};
|
|
|
|
export function buildSuggestedCorrespondenceQueries(
|
|
context: CorrespondenceJobContext,
|
|
subjects: Array<string | null | undefined>,
|
|
) {
|
|
const uniqueSubjects = Array.from(new Set(subjects.filter(Boolean) as string[])).slice(0, 2);
|
|
const companyName = context.companyName?.trim();
|
|
const recruiterEmail = context.recruiterEmail?.trim();
|
|
const jobTitle = context.jobTitle?.trim();
|
|
|
|
return [
|
|
recruiterEmail ? { label: "Recruiter mailbox", value: `(from:${recruiterEmail} OR to:${recruiterEmail}) newer_than:365d` } : null,
|
|
companyName && jobTitle ? { label: "Company + role", value: `"${companyName}" "${jobTitle}" newer_than:365d` } : null,
|
|
companyName ? { label: "Company mail", value: `"${companyName}" (application OR interview OR recruiter) newer_than:365d` } : null,
|
|
...uniqueSubjects.map((subject) => ({ label: `Subject: ${subject}`, value: `subject:"${subject}" newer_than:365d` })),
|
|
].filter(Boolean).slice(0, 6) as Array<{ label: string; value: string }>;
|
|
}
|
|
|
|
export default function Correspondence({ jobId, jobContext }: { jobId: number; jobContext: CorrespondenceJobContext }) {
|
|
const theme = useTheme();
|
|
const { toast } = useToast();
|
|
const { t } = useI18n();
|
|
const { confirmAction } = useDialogActions();
|
|
const [messages, setMessages] = useState<CorrespondenceMessage[]>([]);
|
|
const [from, setFrom] = useState<"Me" | "Company">("Me");
|
|
const [text, setText] = useState("");
|
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
const [importOpen, setImportOpen] = useState(false);
|
|
const [importTab, setImportTab] = useState(0);
|
|
const [rawEmail, setRawEmail] = useState("");
|
|
|
|
const [gmailStatus, setGmailStatus] = useState<GmailStatus | null>(null);
|
|
const [gmailLoading, setGmailLoading] = useState(false);
|
|
const [gmailQuery, setGmailQuery] = useState("");
|
|
const [gmailMatches, setGmailMatches] = useState<GmailJobMatchesResponse | null>(null);
|
|
const [gmailMatchesLoading, setGmailMatchesLoading] = useState(false);
|
|
const [linkedThreadRefresh, setLinkedThreadRefresh] = useState<GmailThreadRefreshResult | null>(null);
|
|
const [linkedThreadRefreshLoading, setLinkedThreadRefreshLoading] = useState(false);
|
|
const [importingMessageId, setImportingMessageId] = useState<string | null>(null);
|
|
const [importingThreadId, setImportingThreadId] = useState<string | null>(null);
|
|
const [availableJobs, setAvailableJobs] = useState<JobApplication[]>([]);
|
|
const [manageThreadId, setManageThreadId] = useState<string | null>(null);
|
|
const [manageTargetJobId, setManageTargetJobId] = useState<number>(jobId);
|
|
const [manageNote, setManageNote] = useState("");
|
|
const [manageSaving, setManageSaving] = useState(false);
|
|
const autoRefreshKeyRef = useRef<string | null>(null);
|
|
|
|
const load = useCallback(async () => {
|
|
const res = await api.get<CorrespondenceMessage[]>(`/correspondence/${jobId}`);
|
|
setMessages(res.data);
|
|
}, [jobId]);
|
|
|
|
const loadGmailStatus = useCallback(async () => {
|
|
try {
|
|
setGmailLoading(true);
|
|
const res = await api.get<GmailStatus>("/gmail/status");
|
|
setGmailStatus(res.data);
|
|
} catch {
|
|
setGmailStatus({ connected: false });
|
|
} finally {
|
|
setGmailLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
const loadGmailMatches = useCallback(async (queryOverride?: string) => {
|
|
try {
|
|
setGmailMatchesLoading(true);
|
|
const res = await api.get<GmailJobMatchesResponse>("/gmail/job-candidates", {
|
|
params: {
|
|
jobApplicationId: jobId,
|
|
queryOverride: queryOverride?.trim() || undefined,
|
|
maxResultsPerQuery: 6,
|
|
},
|
|
});
|
|
setGmailMatches(res.data);
|
|
} catch (error: any) {
|
|
toast(getApiErrorMessage(error, "Failed to load Gmail suggestions."), "error");
|
|
} finally {
|
|
setGmailMatchesLoading(false);
|
|
}
|
|
}, [jobId, toast]);
|
|
|
|
const loadAvailableJobs = useCallback(async () => {
|
|
try {
|
|
const res = await api.get<PagedResult<JobApplication>>("/jobapplications", { params: { page: 1, pageSize: 100, sortBy: "dateApplied", sortDir: "desc" } });
|
|
setAvailableJobs((res.data?.items ?? []).filter((item) => item.id !== jobId));
|
|
} catch {
|
|
setAvailableJobs([]);
|
|
}
|
|
}, [jobId]);
|
|
|
|
const linkedThreadIds = useMemo(
|
|
() => Array.from(new Set(messages.map((message) => message.externalThreadId).filter(Boolean) as string[])).sort(),
|
|
[messages],
|
|
);
|
|
|
|
const refreshLinkedThreads = useCallback(async (mode: "auto" | "manual" = "manual") => {
|
|
if (!gmailStatus?.connected || linkedThreadIds.length === 0) return null;
|
|
|
|
try {
|
|
setLinkedThreadRefreshLoading(true);
|
|
const res = await api.post<GmailThreadRefreshResult>("/gmail/refresh-linked-threads", { jobApplicationId: jobId });
|
|
setLinkedThreadRefresh(res.data);
|
|
await load();
|
|
await loadGmailStatus();
|
|
if (importOpen && importTab === 1) {
|
|
await loadGmailMatches(gmailQuery);
|
|
}
|
|
|
|
if (mode === "manual") {
|
|
if (res.data.imported > 0) {
|
|
toast(`Imported ${res.data.imported} new Gmail message${res.data.imported === 1 ? "" : "s"}.`, "success");
|
|
} else if (res.data.hasLinkedThreads) {
|
|
toast("Linked Gmail threads are already current.", "success");
|
|
} else {
|
|
toast("This job does not have any linked Gmail threads yet.", "success");
|
|
}
|
|
} else if (res.data.imported > 0) {
|
|
toast(`Linked Gmail threads imported ${res.data.imported} new message${res.data.imported === 1 ? "" : "s"}.`, "success");
|
|
}
|
|
|
|
return res.data;
|
|
} catch (error: any) {
|
|
if (mode === "manual") {
|
|
toast(getApiErrorMessage(error, "Failed to refresh linked Gmail threads."), "error");
|
|
}
|
|
return null;
|
|
} finally {
|
|
setLinkedThreadRefreshLoading(false);
|
|
}
|
|
}, [gmailQuery, gmailStatus?.connected, importOpen, importTab, jobId, linkedThreadIds.length, load, loadGmailMatches, loadGmailStatus, toast]);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
useEffect(() => {
|
|
const el = scrollRef.current;
|
|
if (!el) return;
|
|
el.scrollTop = el.scrollHeight;
|
|
}, [messages.length]);
|
|
|
|
useEffect(() => {
|
|
void loadGmailStatus();
|
|
void loadAvailableJobs();
|
|
}, [loadAvailableJobs, loadGmailStatus]);
|
|
|
|
useEffect(() => {
|
|
if (!gmailStatus?.connected || linkedThreadIds.length === 0) {
|
|
autoRefreshKeyRef.current = null;
|
|
return;
|
|
}
|
|
|
|
const refreshKey = `${jobId}:${linkedThreadIds.join("|")}`;
|
|
if (autoRefreshKeyRef.current === refreshKey) return;
|
|
autoRefreshKeyRef.current = refreshKey;
|
|
void refreshLinkedThreads("auto");
|
|
}, [gmailStatus?.connected, jobId, linkedThreadIds, refreshLinkedThreads]);
|
|
|
|
useEffect(() => {
|
|
if (!importOpen || importTab !== 1 || !gmailStatus?.connected) return;
|
|
void loadGmailMatches(gmailQuery);
|
|
}, [importOpen, importTab, gmailStatus?.connected, gmailQuery, loadGmailMatches]);
|
|
|
|
useEffect(() => {
|
|
const onMessage = (event: MessageEvent) => {
|
|
const data = event.data as { source?: string; status?: string; message?: string };
|
|
if (data?.source !== "jobtracker-gmail-oauth") return;
|
|
if (data.status === "connected") {
|
|
toast(data.message || t("googleLinkedSuccess"), "success");
|
|
void loadGmailStatus();
|
|
setImportTab(1);
|
|
void loadGmailMatches(gmailQuery);
|
|
} else {
|
|
toast(data.message || t("googleAuthFailed"), "error");
|
|
}
|
|
};
|
|
|
|
window.addEventListener("message", onMessage);
|
|
return () => window.removeEventListener("message", onMessage);
|
|
}, [gmailQuery, loadGmailMatches, loadGmailStatus, t, toast]);
|
|
|
|
const canSend = useMemo(() => text.trim().length > 0, [text]);
|
|
|
|
const suggestedQueries = buildSuggestedCorrespondenceQueries(jobContext, messages.map((message) => message.subject));
|
|
|
|
const send = async () => {
|
|
if (!canSend) return;
|
|
try {
|
|
await api.post("/correspondence", { jobApplicationId: jobId, from, content: text });
|
|
setText("");
|
|
await load();
|
|
} catch (error) {
|
|
toast(getApiErrorMessage(error, "Failed to add message."), "error");
|
|
}
|
|
};
|
|
|
|
const importEmail = async () => {
|
|
const parsed = parseRawEmail(rawEmail);
|
|
if (!parsed.body && !parsed.subject && !rawEmail.trim()) {
|
|
toast(t("addJobModalPasteUrlFirst"), "error");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await api.post("/correspondence", {
|
|
jobApplicationId: jobId,
|
|
from,
|
|
channel: "Email",
|
|
subject: parsed.subject ?? null,
|
|
content: parsed.body || rawEmail,
|
|
date: parsed.date ?? null,
|
|
externalFrom: parsed.from ?? null,
|
|
externalTo: parsed.to ?? null,
|
|
});
|
|
setImportOpen(false);
|
|
setRawEmail("");
|
|
await load();
|
|
toast(t("correspondenceLogEmail"), "success");
|
|
} catch (error) {
|
|
toast(getApiErrorMessage(error, t("addJobModalImportFailed")), "error");
|
|
}
|
|
};
|
|
|
|
const connectGmail = async () => {
|
|
try {
|
|
const res = await api.get<{ url: string }>("/gmail/connect-url");
|
|
const popup = window.open(res.data.url, "jobtracker-gmail-connect", "width=620,height=760,resizable=yes,scrollbars=yes");
|
|
if (!popup) toast(t("correspondenceBlockedPopup"), "error");
|
|
} catch (error) {
|
|
toast(getApiErrorMessage(error, t("correspondenceStartGmailFailed")), "error");
|
|
}
|
|
};
|
|
|
|
const disconnectGmail = async () => {
|
|
try {
|
|
await api.delete("/gmail/connection");
|
|
setGmailStatus({ connected: false });
|
|
setGmailMatches(null);
|
|
setLinkedThreadRefresh(null);
|
|
toast(t("googleUnlinked"), "success");
|
|
} catch (error) {
|
|
toast(getApiErrorMessage(error, t("correspondenceDisconnectFailed")), "error");
|
|
}
|
|
};
|
|
|
|
const deleteMessage = async (messageId: number) => {
|
|
if (!(await confirmAction(t("correspondenceDeleteConfirm"), { title: t("correspondenceDeleteTitle"), confirmLabel: t("adminUsersDelete"), destructive: true }))) return;
|
|
try {
|
|
await api.delete(`/correspondence/${messageId}`);
|
|
await load();
|
|
toast(t("correspondenceDeleted"), "success");
|
|
} catch (error) {
|
|
toast(getApiErrorMessage(error, t("correspondenceDeleteFailed")), "error");
|
|
}
|
|
};
|
|
|
|
const importGmailMessage = async (messageId: string) => {
|
|
try {
|
|
setImportingMessageId(messageId);
|
|
const res = await api.post<GmailImportMessageResult>("/gmail/import", { jobApplicationId: jobId, messageId });
|
|
await load();
|
|
await loadGmailMatches(gmailQuery);
|
|
if (res.data.imported > 0) {
|
|
toast(t("correspondenceImportEmail"), "success");
|
|
} else {
|
|
toast("This Gmail message is already linked to the job.", "success");
|
|
}
|
|
} catch (error: any) {
|
|
toast(getApiErrorMessage(error, t("correspondenceImportGmailFailed")), "error");
|
|
} finally {
|
|
setImportingMessageId(null);
|
|
}
|
|
};
|
|
|
|
const importGmailThread = async (threadId: string, messageIds: string[]) => {
|
|
try {
|
|
setImportingThreadId(threadId);
|
|
const res = await api.post<GmailImportThreadResult>("/gmail/import-thread", { jobApplicationId: jobId, threadId, messageIds });
|
|
await load();
|
|
await loadGmailMatches(gmailQuery);
|
|
toast(t("correspondenceImportThreadResult", { imported: res.data.imported, skippedText: res.data.skipped ? t("correspondenceImportThreadSkipped", { count: res.data.skipped }) : "" }), "success");
|
|
} catch (error: any) {
|
|
toast(getApiErrorMessage(error, t("correspondenceImportThreadFailed")), "error");
|
|
} finally {
|
|
setImportingThreadId(null);
|
|
}
|
|
};
|
|
|
|
const openManageThread = (threadId: string) => {
|
|
setManageThreadId(threadId);
|
|
setManageTargetJobId(jobId);
|
|
setManageNote("");
|
|
};
|
|
|
|
const unlinkThread = async () => {
|
|
if (!manageThreadId) return;
|
|
setManageSaving(true);
|
|
try {
|
|
const res = await api.post<GmailUnlinkResult>("/gmail/unlink-thread", {
|
|
jobApplicationId: jobId,
|
|
threadId: manageThreadId,
|
|
note: manageNote.trim() || null,
|
|
nextDecision: "review",
|
|
});
|
|
await load();
|
|
await loadGmailMatches(gmailQuery);
|
|
setManageThreadId(null);
|
|
toast(`Unlinked ${res.data.removedMessages} message${res.data.removedMessages === 1 ? "" : "s"} from this job.`, "success");
|
|
} catch (error: any) {
|
|
toast(getApiErrorMessage(error, "Failed to unlink the Gmail thread."), "error");
|
|
} finally {
|
|
setManageSaving(false);
|
|
}
|
|
};
|
|
|
|
const relinkThread = async () => {
|
|
if (!manageThreadId || manageTargetJobId <= 0 || manageTargetJobId === jobId) return;
|
|
setManageSaving(true);
|
|
try {
|
|
const res = await api.post<GmailRelinkResult>("/gmail/relink-thread", {
|
|
jobApplicationId: manageTargetJobId,
|
|
threadId: manageThreadId,
|
|
removeFromOtherJobs: true,
|
|
note: manageNote.trim() || null,
|
|
});
|
|
await load();
|
|
await loadGmailMatches(gmailQuery);
|
|
setManageThreadId(null);
|
|
const targetJob = availableJobs.find((item) => item.id === manageTargetJobId);
|
|
toast(`Moved thread to ${targetJob?.company?.name || targetJob?.jobTitle || `job ${res.data.jobApplicationId}`}.`, "success");
|
|
} catch (error: any) {
|
|
toast(getApiErrorMessage(error, "Failed to move the Gmail thread."), "error");
|
|
} finally {
|
|
setManageSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Box>
|
|
<Paper ref={scrollRef} sx={{ p: 1.5, maxHeight: 360, overflowY: "auto", background: theme.palette.mode === "dark" ? "rgba(15,23,42,0.45)" : "rgba(255,255,255,0.75)", backdropFilter: "blur(8px)" }}>
|
|
{messages.length === 0 ? (
|
|
<Typography sx={{ color: "text.secondary", py: 2, textAlign: "center" }}>{t("correspondenceNoMessages")}</Typography>
|
|
) : (
|
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
|
{messages.map((m) => {
|
|
const isMe = (m.from || "").toLowerCase() === "me";
|
|
const accent = isMe ? theme.palette.primary.main : theme.palette.warning.main;
|
|
return (
|
|
<Box key={m.id} sx={{ display: "flex", justifyContent: isMe ? "flex-end" : "flex-start" }}>
|
|
<Box sx={{ maxWidth: "80%", borderRadius: 3, p: 1.25, border: `1px solid ${alpha(accent, theme.palette.mode === "dark" ? 0.32 : 0.22)}`, background: alpha(accent, theme.palette.mode === "dark" ? 0.14 : 0.1), color: "text.primary" }}>
|
|
{m.subject ? <Typography sx={{ fontWeight: 800, mb: 0.5 }}>{m.subject}</Typography> : null}
|
|
<Typography sx={{ whiteSpace: "pre-wrap", lineHeight: 1.35 }}>{m.content}</Typography>
|
|
{(m.externalThreadId || m.externalFrom || m.externalTo || m.externalLabelsJson || m.attachmentMetadataJson) ? (
|
|
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 1 }}>
|
|
{m.externalThreadId ? <Chip size="small" label={`Thread ${m.externalThreadId}`} variant="outlined" /> : null}
|
|
{m.externalFrom ? <Chip size="small" label={`From ${m.externalFrom}`} variant="outlined" /> : null}
|
|
{m.externalTo ? <Chip size="small" label={`To ${m.externalTo}`} variant="outlined" /> : null}
|
|
{m.externalLabelsJson ? <Chip size="small" label={`${JSON.parse(m.externalLabelsJson).length} Gmail label${JSON.parse(m.externalLabelsJson).length === 1 ? "" : "s"}`} variant="outlined" /> : null}
|
|
{m.attachmentMetadataJson ? <Chip size="small" label={`${JSON.parse(m.attachmentMetadataJson).length} attachment${JSON.parse(m.attachmentMetadataJson).length === 1 ? "" : "s"}`} variant="outlined" /> : null}
|
|
</Box>
|
|
) : null}
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "flex-end", mt: 0.75 }}>
|
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
|
{isMe ? t("correspondenceMe") : t("correspondenceCompany")}{m.channel ? ` - ${m.channel}` : ""}{m.date ? ` - ${new Date(m.date).toLocaleString()}` : ""}
|
|
</Typography>
|
|
<IconButton size="small" onClick={() => void deleteMessage(m.id)} sx={{ color: "text.secondary" }}>
|
|
<DeleteOutlineIcon fontSize="small" />
|
|
</IconButton>
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
})}
|
|
</Box>
|
|
)}
|
|
</Paper>
|
|
|
|
<Box sx={{ mt: 1.5, p: 1.5, borderRadius: 3, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", backgroundColor: "background.default" }}>
|
|
<Typography variant="overline">Linked Gmail thread continuity</Typography>
|
|
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.25 }}>
|
|
Linked Gmail refresh only checks threads that are already tied to this job, so new correspondence can appear here without re-importing the whole thread.
|
|
</Typography>
|
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
|
<Chip size="small" color={gmailStatus?.connected ? "success" : "default"} variant="outlined" label={gmailStatus?.connected ? "Gmail connected" : "Gmail not connected"} />
|
|
<Chip size="small" color={linkedThreadIds.length > 0 ? "success" : "default"} variant="outlined" label={linkedThreadIds.length > 0 ? `Linked threads: ${linkedThreadIds.length}` : "No linked threads yet"} />
|
|
{linkedThreadIds.slice(0, 6).map((threadId) => (
|
|
<Button key={threadId} size="small" variant="text" onClick={() => openManageThread(threadId)}>
|
|
Manage {threadId}
|
|
</Button>
|
|
))}
|
|
{gmailStatus?.lastSyncStatus ? (
|
|
<Chip
|
|
size="small"
|
|
color={gmailStatus.lastSyncStatus === "success" ? "success" : "warning"}
|
|
variant="outlined"
|
|
label={gmailStatus.lastSyncStatus === "success"
|
|
? `Last Gmail sync ${gmailStatus.lastSyncMode || "sync"} ok`
|
|
: `Last Gmail sync ${gmailStatus.lastSyncMode || "sync"} failed`}
|
|
/>
|
|
) : null}
|
|
{linkedThreadRefresh ? (
|
|
<Chip
|
|
size="small"
|
|
variant="outlined"
|
|
label={linkedThreadRefresh.imported > 0
|
|
? `Last linked refresh imported ${linkedThreadRefresh.imported} new message${linkedThreadRefresh.imported === 1 ? "" : "s"}`
|
|
: linkedThreadRefresh.hasLinkedThreads
|
|
? `Last linked refresh checked ${linkedThreadRefresh.threadsChecked} linked thread${linkedThreadRefresh.threadsChecked === 1 ? "" : "s"}`
|
|
: "No linked Gmail refresh history yet"}
|
|
/>
|
|
) : null}
|
|
</Box>
|
|
{gmailStatus?.lastSyncError ? (
|
|
<Typography variant="body2" sx={{ color: "warning.main", mt: 1 }}>
|
|
Latest Gmail sync issue: {gmailStatus.lastSyncError}
|
|
</Typography>
|
|
) : null}
|
|
</Box>
|
|
|
|
<Box sx={{ display: "flex", gap: 1, alignItems: "flex-start", mt: 1.5, flexWrap: "wrap" }}>
|
|
<ToggleButtonGroup exclusive value={from} onChange={(_, v) => v && setFrom(v)} size="small">
|
|
<ToggleButton value="Me">{t("correspondenceMe")}</ToggleButton>
|
|
<ToggleButton value="Company">{t("correspondenceCompany")}</ToggleButton>
|
|
</ToggleButtonGroup>
|
|
|
|
<Button variant="outlined" size="small" onClick={() => setImportOpen(true)}>{t("correspondenceImportEmail")}</Button>
|
|
|
|
<TextField label={t("correspondenceLogNoteOrMessage")} value={text} onChange={(e) => setText(e.target.value)} multiline minRows={3} sx={{ flex: "1 1 320px" }} helperText={t("correspondenceCharacters", { count: text.length })} />
|
|
|
|
<Button variant="contained" onClick={send} disabled={!canSend}>{t("correspondenceAdd")}</Button>
|
|
</Box>
|
|
|
|
<Dialog open={Boolean(manageThreadId)} onClose={() => setManageThreadId(null)} fullWidth maxWidth="sm">
|
|
<DialogTitle>Manage linked Gmail thread</DialogTitle>
|
|
<DialogContent sx={{ display: "flex", flexDirection: "column", gap: 2, pt: 1 }}>
|
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
|
Unlink this thread from the current job, or move it to another existing job.
|
|
</Typography>
|
|
{manageThreadId ? <Chip label={`Thread ${manageThreadId}`} variant="outlined" sx={{ width: "fit-content" }} /> : null}
|
|
<TextField
|
|
label="Review note"
|
|
value={manageNote}
|
|
onChange={(event) => setManageNote(event.target.value)}
|
|
multiline
|
|
minRows={2}
|
|
placeholder="Why this thread should stay in review or move to another job."
|
|
/>
|
|
<FormControl fullWidth>
|
|
<InputLabel>Move to job</InputLabel>
|
|
<Select
|
|
value={String(manageTargetJobId)}
|
|
label="Move to job"
|
|
onChange={(event) => setManageTargetJobId(Number(event.target.value))}
|
|
>
|
|
<MenuItem value={String(jobId)}>Keep on current job</MenuItem>
|
|
{availableJobs.map((item) => (
|
|
<MenuItem key={item.id} value={String(item.id)}>
|
|
{item.company?.name || "Unknown company"} • {item.jobTitle}
|
|
</MenuItem>
|
|
))}
|
|
</Select>
|
|
</FormControl>
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button onClick={() => setManageThreadId(null)} disabled={manageSaving}>Close</Button>
|
|
<Button color="warning" variant="outlined" onClick={() => void unlinkThread()} disabled={manageSaving || !manageThreadId}>Unlink from this job</Button>
|
|
<Button variant="contained" onClick={() => void relinkThread()} disabled={manageSaving || !manageThreadId || manageTargetJobId === jobId}>Move thread</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
|
|
<Dialog open={importOpen} onClose={() => setImportOpen(false)} fullWidth maxWidth="md">
|
|
<DialogTitle>{t("correspondenceImportTitle")}</DialogTitle>
|
|
<DialogContent>
|
|
<Tabs value={importTab} onChange={(_, v) => setImportTab(v)} sx={{ mb: 2 }}>
|
|
<Tab label={t("correspondencePasteEmail")} />
|
|
<Tab label={t("google")} />
|
|
</Tabs>
|
|
|
|
{importTab === 0 ? (
|
|
<>
|
|
<Typography sx={{ color: "text.secondary", mb: 1 }}>{t("correspondencePasteEmailHelp")}</Typography>
|
|
<TextField multiline minRows={10} fullWidth value={rawEmail} onChange={(e) => setRawEmail(e.target.value)} placeholder={"Subject: ...\nDate: ...\nFrom: ...\nTo: ...\n\nBody..."} />
|
|
</>
|
|
) : (
|
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
|
<Box>
|
|
<Typography sx={{ fontWeight: 800 }}>{t("correspondenceGoogleGmail")}</Typography>
|
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
|
{gmailLoading ? t("correspondenceCheckingConnection") : gmailStatus?.connected ? t("correspondenceConnectedAs", { email: gmailStatus.gmailAddress || "" }) : t("correspondenceConnectGmailHint")}
|
|
</Typography>
|
|
{jobContext.companyName || jobContext.jobTitle ? (
|
|
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.5 }}>
|
|
Matching against {jobContext.companyName || "this company"} / {jobContext.jobTitle || "this role"}
|
|
</Typography>
|
|
) : null}
|
|
</Box>
|
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
|
{gmailStatus?.connected ? (
|
|
<>
|
|
{linkedThreadIds.length > 0 ? (
|
|
<Button variant="outlined" onClick={() => void refreshLinkedThreads("manual")} disabled={linkedThreadRefreshLoading}>
|
|
{linkedThreadRefreshLoading ? "Refreshing linked threads..." : "Refresh linked threads"}
|
|
</Button>
|
|
) : null}
|
|
<Button variant="outlined" onClick={() => void loadGmailMatches(gmailQuery)} disabled={gmailMatchesLoading}>{t("correspondenceRefresh")}</Button>
|
|
<Button variant="outlined" color="error" onClick={() => void disconnectGmail()}>{t("correspondenceDisconnect")}</Button>
|
|
</>
|
|
) : (
|
|
<Button variant="contained" onClick={() => void connectGmail()}>{t("correspondenceConnectGmail")}</Button>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
|
|
{gmailStatus?.connected ? (
|
|
<>
|
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
|
{suggestedQueries.map((item) => (
|
|
<Chip key={item.label} icon={<AutoAwesomeIcon />} label={item.label} clickable variant="outlined" onClick={() => setGmailQuery(item.value)} />
|
|
))}
|
|
</Box>
|
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
|
<TextField label={t("correspondenceSearchGmail")} value={gmailQuery} onChange={(e) => setGmailQuery(e.target.value)} placeholder={t("correspondenceSearchGmailPlaceholder")} size="small" fullWidth />
|
|
<Button variant="outlined" startIcon={<SearchIcon />} onClick={() => void loadGmailMatches(gmailQuery)} disabled={gmailMatchesLoading}>{t("correspondenceSearch")}</Button>
|
|
</Box>
|
|
{gmailMatches?.queries?.length ? (
|
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
|
{gmailMatches.queries.slice(0, 4).map((query) => (
|
|
<Chip key={query} size="small" label={query} variant="outlined" />
|
|
))}
|
|
</Box>
|
|
) : null}
|
|
{gmailStatus.lastSyncedAt ? <Chip label={t("correspondenceLastSynced", { date: new Date(gmailStatus.lastSyncedAt).toLocaleString() })} size="small" /> : null}
|
|
{gmailStatus.lastSyncAttemptedAt ? <Chip label={`Sync checked ${new Date(gmailStatus.lastSyncAttemptedAt).toLocaleString()}`} size="small" variant="outlined" /> : null}
|
|
{gmailStatus.lastSyncStatus === "error" && gmailStatus.lastSyncError ? <Chip label={`Sync issue: ${gmailStatus.lastSyncError}`} size="small" color="warning" variant="outlined" /> : null}
|
|
{linkedThreadIds.length > 0 ? (
|
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
|
<Chip size="small" color="success" variant="outlined" label={`Linked threads: ${linkedThreadIds.length}`} />
|
|
{linkedThreadRefresh ? (
|
|
<Chip
|
|
size="small"
|
|
variant="outlined"
|
|
label={linkedThreadRefresh.imported > 0
|
|
? `Last refresh imported ${linkedThreadRefresh.imported} new message${linkedThreadRefresh.imported === 1 ? "" : "s"}`
|
|
: linkedThreadRefresh.hasLinkedThreads
|
|
? `Last refresh checked ${linkedThreadRefresh.threadsChecked} linked thread${linkedThreadRefresh.threadsChecked === 1 ? "" : "s"}`
|
|
: "No linked Gmail threads yet"}
|
|
/>
|
|
) : null}
|
|
</Box>
|
|
) : null}
|
|
<Paper variant="outlined" sx={{ maxHeight: 420, overflowY: "auto" }}>
|
|
{gmailMatchesLoading ? (
|
|
<Box sx={{ py: 5, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box>
|
|
) : !gmailMatches || gmailMatches.threads.length === 0 ? (
|
|
<Typography sx={{ color: "text.secondary", p: 2 }}>
|
|
{gmailQuery.trim() ? "No Gmail matches for this job and search override yet." : t("correspondenceNoGmailMessages")}
|
|
</Typography>
|
|
) : (
|
|
<List disablePadding>
|
|
{gmailMatches.threads.map((thread, threadIndex) => (
|
|
<React.Fragment key={thread.threadId}>
|
|
{threadIndex > 0 ? <Divider /> : null}
|
|
<Box sx={{ p: 1.5, backgroundColor: alpha(theme.palette.primary.main, 0.04) }}>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1 }}>
|
|
<Box>
|
|
<Typography sx={{ fontWeight: 800 }}>{thread.subject || t("correspondenceNoSubject")}</Typography>
|
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.75 }}>
|
|
<Chip size="small" label={`${formatConfidence(thread.confidence)} · score ${thread.score}`} color={thread.confidence === "high" ? "success" : thread.confidence === "medium" ? "warning" : "default"} />
|
|
<Chip size="small" label={`${thread.messageCount} message${thread.messageCount === 1 ? "" : "s"}`} variant="outlined" />
|
|
{thread.hasImportedMessages ? <Chip size="small" label="Already linked" variant="outlined" color="success" /> : null}
|
|
</Box>
|
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 1 }}>
|
|
{thread.matchReasons.map((reason) => (
|
|
<Chip key={`${thread.threadId}-${reason.label}-${reason.value}`} size="small" label={`${formatReasonLabel(reason.label)}: ${reason.value}`} variant="outlined" />
|
|
))}
|
|
</Box>
|
|
</Box>
|
|
<Button startIcon={<MailOutlineIcon />} variant="outlined" size="small" disabled={importingThreadId === thread.threadId} onClick={() => void importGmailThread(thread.threadId, thread.messages.map((x) => x.id))}>
|
|
{importingThreadId === thread.threadId ? t("correspondenceImporting") : t("correspondenceImportThread")}
|
|
</Button>
|
|
</Box>
|
|
{thread.messages.map((message, index) => (
|
|
<React.Fragment key={message.id}>
|
|
{index > 0 ? <Divider sx={{ my: 1 }} /> : null}
|
|
<ListItemButton sx={{ alignItems: "flex-start", px: 0, py: 1 }}>
|
|
<ListItemText
|
|
secondaryTypographyProps={{ component: "div" }}
|
|
primary={<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}><Typography sx={{ fontWeight: 700 }}>{message.subject || t("correspondenceNoSubject")}</Typography><Typography variant="caption" sx={{ color: "text.secondary" }}>{message.date ? new Date(message.date).toLocaleString() : ""}</Typography></Box>}
|
|
secondary={<Box component="span" sx={{ mt: 0.5, display: "block" }}>
|
|
<Typography component="span" variant="body2" sx={{ color: "text.primary", display: "block" }}>{t("correspondenceFromLabel", { value: message.from || t("correspondenceUnknown") })}</Typography>
|
|
<Typography component="span" variant="body2" sx={{ color: "text.secondary", mt: 0.25, display: "block" }}>{message.snippet}</Typography>
|
|
<Box component="span" sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 1 }}>
|
|
<Chip size="small" label={`${formatConfidence(message.confidence)} · score ${message.score}`} variant="outlined" />
|
|
{message.alreadyImported ? <Chip size="small" label="Already linked" color="success" variant="outlined" /> : null}
|
|
{message.matchReasons.map((reason) => (
|
|
<Chip key={`${message.id}-${reason.label}-${reason.value}`} size="small" label={`${formatReasonLabel(reason.label)}: ${reason.value}`} variant="outlined" />
|
|
))}
|
|
</Box>
|
|
</Box>}
|
|
/>
|
|
<Button variant="contained" size="small" disabled={importingMessageId === message.id} onClick={() => void importGmailMessage(message.id)}>
|
|
{importingMessageId === message.id ? t("correspondenceImporting") : t("correspondenceImportEmail")}
|
|
</Button>
|
|
</ListItemButton>
|
|
</React.Fragment>
|
|
))}
|
|
</Box>
|
|
</React.Fragment>
|
|
))}
|
|
</List>
|
|
)}
|
|
</Paper>
|
|
</>
|
|
) : null}
|
|
</Box>
|
|
)}
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button onClick={() => setImportOpen(false)}>{t("correspondenceClose")}</Button>
|
|
{importTab === 0 ? <Button variant="contained" onClick={importEmail}>{t("correspondenceLogEmail")}</Button> : null}
|
|
</DialogActions>
|
|
</Dialog>
|
|
</Box>
|
|
);
|
|
}
|