Files
jobtrackingapp/job-tracker-ui/src/components/Correspondence.tsx
T
2026-10-07 01:58:31 +02:00

760 lines
39 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Autocomplete,
Box,
Button,
Chip,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
List,
ListItemButton,
ListItemText,
Paper,
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,
} from "../types";
import { useDialogActions } from "../dialogs";
import { useI18n } from "../i18n/I18nProvider";
import ViewStateNotice from "./ViewStateNotice";
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 };
}
type JobChoice = { id: number; jobTitle: string; companyName: string };
export type CorrespondenceJobContext = {
companyName?: string | null;
recruiterEmail?: string | null;
jobTitle?: string | null;
};
export function buildSuggestedCorrespondenceQueries(
context: CorrespondenceJobContext,
subjects: Array<string | null | undefined>,
labels: { recruiterMailbox: string; companyAndRole: string; companyMail: string; subject: string } = {
recruiterMailbox: "Recruiter mailbox",
companyAndRole: "Company + role",
companyMail: "Company mail",
subject: "Subject",
},
) {
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: labels.recruiterMailbox, value: `(from:${recruiterEmail} OR to:${recruiterEmail}) newer_than:365d` } : null,
companyName && jobTitle ? { label: labels.companyAndRole, value: `"${companyName}" "${jobTitle}" newer_than:365d` } : null,
companyName ? { label: labels.companyMail, value: `"${companyName}" (application OR interview OR recruiter) newer_than:365d` } : null,
...uniqueSubjects.map((subject) => ({ label: `${labels.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 { language, t } = useI18n();
const { confirmAction } = useDialogActions();
const formatConfidence = (value: string) => {
if (value === "high") return t("correspondenceConfidenceHigh");
if (value === "medium") return t("correspondenceConfidenceMedium");
if (value === "low") return t("correspondenceConfidenceLow");
return t("correspondenceConfidenceUnknown");
};
const formatReasonLabel = (label: string) => {
if (label === "company") return t("company");
if (label === "recruiterEmail") return t("correspondenceRecruiterEmail");
if (label === "recruiter") return t("correspondenceRecruiter");
if (label === "jobTitle") return t("correspondenceJobTitle");
if (label === "existingSubject") return t("correspondenceExistingSubject");
if (label === "recency") return t("correspondenceRecent");
if (label === "status") return t("correspondenceStatus");
return label;
};
const [messages, setMessages] = useState<CorrespondenceMessage[]>([]);
const [messagesLoading, setMessagesLoading] = useState(true);
const [messagesError, setMessagesError] = useState<string | null>(null);
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<JobChoice[]>([]);
const [jobChoiceQuery, setJobChoiceQuery] = useState("");
const [jobChoicesLoading, setJobChoicesLoading] = useState(false);
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 () => {
setMessagesLoading(true);
try {
const res = await api.get<CorrespondenceMessage[]>(`/correspondence/${jobId}`);
setMessages(res.data);
setMessagesError(null);
} catch (error) {
setMessagesError(getApiErrorMessage(error, t("correspondenceLoadFailed")));
throw error; // Existing mutation callers still handle their own operation failure.
} finally {
setMessagesLoading(false);
}
}, [jobId, t]);
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, t("correspondenceLoadSuggestionsFailed")), "error");
} finally {
setGmailMatchesLoading(false);
}
}, [jobId, t, toast]);
const loadAvailableJobs = useCallback(async (query?: string) => {
try {
setJobChoicesLoading(true);
const res = await api.get<JobChoice[]>("/jobapplications/choices", { params: { q: query?.trim() || undefined, limit: 50 } });
setAvailableJobs((res.data ?? []).filter((item) => item.id !== jobId));
} catch {
setAvailableJobs([]);
} finally {
setJobChoicesLoading(false);
}
}, [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(t(res.data.imported === 1 ? "correspondenceImportedNewMessage" : "correspondenceImportedNewMessages", { count: res.data.imported }), "success");
} else if (res.data.hasLinkedThreads) {
toast(t("correspondenceLinkedThreadsCurrent"), "success");
} else {
toast(t("correspondenceNoLinkedThreadsForJob"), "success");
}
} else if (res.data.imported > 0) {
toast(t(res.data.imported === 1 ? "correspondenceLinkedImportedMessage" : "correspondenceLinkedImportedMessages", { count: res.data.imported }), "success");
}
return res.data;
} catch (error: any) {
if (mode === "manual") {
toast(getApiErrorMessage(error, t("correspondenceRefreshLinkedFailed")), "error");
}
return null;
} finally {
setLinkedThreadRefreshLoading(false);
}
}, [gmailQuery, gmailStatus?.connected, importOpen, importTab, jobId, linkedThreadIds.length, load, loadGmailMatches, loadGmailStatus, t, toast]);
useEffect(() => {
void load().catch(() => undefined);
}, [load]);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
el.scrollTop = el.scrollHeight;
}, [messages.length]);
useEffect(() => {
void loadGmailStatus();
}, [loadGmailStatus]);
useEffect(() => {
if (!manageThreadId) return;
const timer = window.setTimeout(() => void loadAvailableJobs(jobChoiceQuery), jobChoiceQuery.trim() ? 250 : 0);
return () => window.clearTimeout(timer);
}, [jobChoiceQuery, loadAvailableJobs, manageThreadId]);
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), {
recruiterMailbox: t("correspondenceRecruiterMailbox"),
companyAndRole: t("correspondenceCompanyAndRole"),
companyMail: t("correspondenceCompanyMail"),
subject: t("correspondenceSubject"),
});
const send = async () => {
if (!canSend) return;
try {
await api.post("/correspondence", { jobApplicationId: jobId, from, content: text });
setText("");
await load();
} catch (error) {
toast(getApiErrorMessage(error, t("correspondenceAddFailed")), "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(t("correspondenceAlreadyLinkedToJob"), "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);
setJobChoiceQuery("");
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(t(res.data.removedMessages === 1 ? "correspondenceUnlinkedMessage" : "correspondenceUnlinkedMessages", { count: res.data.removedMessages }), "success");
} catch (error: any) {
toast(getApiErrorMessage(error, t("correspondenceUnlinkThreadFailed")), "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(t("correspondenceMovedThread", { job: targetJob?.companyName || targetJob?.jobTitle || t("correspondenceJobNumber", { id: res.data.jobApplicationId }) }), "success");
} catch (error: any) {
toast(getApiErrorMessage(error, t("correspondenceMoveThreadFailed")), "error");
} finally {
setManageSaving(false);
}
};
return (
<Box>
<ViewStateNotice title={t("correspondenceLoadFailed")} loading={messagesLoading} error={messagesError ? { kind: "error", message: messagesError, retryable: true } : null} onRetry={() => load().catch(() => undefined)} compact />
<Paper variant="outlined" ref={scrollRef} sx={{ p: 1.5, maxHeight: 360, overflowY: "auto", bgcolor: "background.paper", borderRadius: 1 }}>
{messages.length === 0 && !messagesLoading && !messagesError ? (
<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: { xs: "100%", sm: "85%" }, minWidth: 0, overflowWrap: "anywhere", borderRadius: 1, p: 1.25, border: "1px solid", borderColor: "divider", borderLeft: `3px solid ${accent}`, bgcolor: "background.default", color: "text.primary", "& .MuiChip-root": { maxWidth: "100%", height: "auto", minHeight: 24 }, "& .MuiChip-label": { whiteSpace: "normal", overflowWrap: "anywhere" } }}>
{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={t("correspondenceThread", { id: m.externalThreadId })} variant="outlined" /> : null}
{m.externalFrom ? <Chip size="small" label={t("correspondenceFrom", { value: m.externalFrom })} variant="outlined" /> : null}
{m.externalTo ? <Chip size="small" label={t("correspondenceTo", { value: m.externalTo })} variant="outlined" /> : null}
{m.externalLabelsJson ? <Chip size="small" label={t(JSON.parse(m.externalLabelsJson).length === 1 ? "correspondenceGmailLabel" : "correspondenceGmailLabels", { count: JSON.parse(m.externalLabelsJson).length })} variant="outlined" /> : null}
{m.attachmentMetadataJson ? <Chip size="small" label={t(JSON.parse(m.attachmentMetadataJson).length === 1 ? "correspondenceAttachment" : "correspondenceAttachments", { count: JSON.parse(m.attachmentMetadataJson).length })} 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(language === "nb" ? "nb-NO" : "en")}` : ""}
</Typography>
<IconButton size="small" aria-label={t("correspondenceDeleteTitle")} 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: 1, border: 1, borderColor: "divider", bgcolor: "background.default", "& .MuiChip-root": { maxWidth: "100%", height: "auto", minHeight: 24 }, "& .MuiChip-label": { whiteSpace: "normal", overflowWrap: "anywhere" } }}>
<Typography variant="overline">{t("correspondenceLinkedContinuity")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.25 }}>
{t("correspondenceLinkedContinuityHelp")}
</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Chip size="small" color={gmailStatus?.connected ? "success" : "default"} variant="outlined" label={gmailStatus?.connected ? t("correspondenceGmailConnected") : t("correspondenceGmailNotConnected")} />
<Chip size="small" color={linkedThreadIds.length > 0 ? "success" : "default"} variant="outlined" label={linkedThreadIds.length > 0 ? t("correspondenceLinkedThreads", { count: linkedThreadIds.length }) : t("correspondenceNoLinkedThreads")} />
{linkedThreadIds.slice(0, 6).map((threadId) => (
<Button key={threadId} size="small" variant="text" onClick={() => openManageThread(threadId)}>
{t("correspondenceManageThread", { id: threadId })}
</Button>
))}
{gmailStatus?.lastSyncStatus ? (
<Chip
size="small"
color={gmailStatus.lastSyncStatus === "success" ? "success" : "warning"}
variant="outlined"
label={gmailStatus.lastSyncStatus === "success"
? t("correspondenceLastSyncOk", { mode: gmailStatus.lastSyncMode || t("correspondenceSync") })
: t("correspondenceLastSyncFailed", { mode: gmailStatus.lastSyncMode || t("correspondenceSync") })}
/>
) : null}
{linkedThreadRefresh ? (
<Chip
size="small"
variant="outlined"
label={linkedThreadRefresh.imported > 0
? t(linkedThreadRefresh.imported === 1 ? "correspondenceLastRefreshImportedMessage" : "correspondenceLastRefreshImportedMessages", { count: linkedThreadRefresh.imported })
: linkedThreadRefresh.hasLinkedThreads
? t(linkedThreadRefresh.threadsChecked === 1 ? "correspondenceLastRefreshCheckedThread" : "correspondenceLastRefreshCheckedThreads", { count: linkedThreadRefresh.threadsChecked })
: t("correspondenceNoRefreshHistory")}
/>
) : null}
</Box>
{gmailStatus?.lastSyncError ? (
<Typography variant="body2" sx={{ color: "warning.main", mt: 1 }}>
{t("correspondenceLatestSyncIssue", { error: 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: { xs: "1 1 100%", sm: "1 1 320px" }, minWidth: 0 }} 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>{t("correspondenceManageLinkedThread")}</DialogTitle>
<DialogContent sx={{ display: "flex", flexDirection: "column", gap: 2, pt: 1 }}>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{t("correspondenceManageLinkedThreadHelp")}
</Typography>
{manageThreadId ? <Chip label={t("correspondenceThread", { id: manageThreadId })} variant="outlined" sx={{ width: "fit-content" }} /> : null}
<TextField
label={t("correspondenceReviewNote")}
value={manageNote}
onChange={(event) => setManageNote(event.target.value)}
multiline
minRows={2}
placeholder={t("correspondenceReviewNotePlaceholder")}
/>
<Autocomplete
options={availableJobs}
loading={jobChoicesLoading}
value={availableJobs.find((item) => item.id === manageTargetJobId) ?? null}
onInputChange={(_, value, reason) => {
if (reason === "input" || reason === "clear") setJobChoiceQuery(value);
}}
onChange={(_, value) => setManageTargetJobId(value?.id ?? jobId)}
isOptionEqualToValue={(option, value) => option.id === value.id}
getOptionLabel={(item) => `${item.companyName || t("correspondenceUnknownCompany")} • ${item.jobTitle}`}
renderInput={(params) => <TextField {...params} label={t("correspondenceMoveToJob")} placeholder={t("correspondenceSearchEveryJob")} />}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setManageThreadId(null)} disabled={manageSaving}>{t("close")}</Button>
<Button color="warning" variant="outlined" onClick={() => void unlinkThread()} disabled={manageSaving || !manageThreadId}>{t("correspondenceUnlinkFromJob")}</Button>
<Button variant="contained" onClick={() => void relinkThread()} disabled={manageSaving || !manageThreadId || manageTargetJobId === jobId}>{t("correspondenceMoveThread")}</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={t("correspondencePasteEmailPlaceholder")} />
</>
) : (
<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 }}>
{t("correspondenceMatchingAgainst", { company: jobContext.companyName || t("correspondenceThisCompany"), role: jobContext.jobTitle || t("correspondenceThisRole") })}
</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 ? t("correspondenceRefreshingLinkedThreads") : t("correspondenceRefreshLinkedThreads")}
</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(language === "nb" ? "nb-NO" : "en") })} size="small" /> : null}
{gmailStatus.lastSyncAttemptedAt ? <Chip label={t("correspondenceSyncChecked", { date: new Date(gmailStatus.lastSyncAttemptedAt).toLocaleString(language === "nb" ? "nb-NO" : "en") })} size="small" variant="outlined" /> : null}
{gmailStatus.lastSyncStatus === "error" && gmailStatus.lastSyncError ? <Chip label={t("correspondenceSyncIssue", { error: 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={t("correspondenceLinkedThreads", { count: linkedThreadIds.length })} />
{linkedThreadRefresh ? (
<Chip
size="small"
variant="outlined"
label={linkedThreadRefresh.imported > 0
? t(linkedThreadRefresh.imported === 1 ? "correspondenceLastRefreshImportedMessage" : "correspondenceLastRefreshImportedMessages", { count: linkedThreadRefresh.imported })
: linkedThreadRefresh.hasLinkedThreads
? t(linkedThreadRefresh.threadsChecked === 1 ? "correspondenceLastRefreshCheckedThread" : "correspondenceLastRefreshCheckedThreads", { count: linkedThreadRefresh.threadsChecked })
: t("correspondenceNoLinkedThreadsForJob")}
/>
) : 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() ? t("correspondenceNoMatchesForOverride") : 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={t("correspondenceConfidenceScore", { confidence: formatConfidence(thread.confidence), score: thread.score })} color={thread.confidence === "high" ? "success" : thread.confidence === "medium" ? "warning" : "default"} />
<Chip size="small" label={t(thread.messageCount === 1 ? "correspondenceMessageCount" : "correspondenceMessagesCount", { count: thread.messageCount })} variant="outlined" />
{thread.hasImportedMessages ? <Chip size="small" label={t("correspondenceAlreadyLinked")} 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(language === "nb" ? "nb-NO" : "en") : ""}</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={t("correspondenceConfidenceScore", { confidence: formatConfidence(message.confidence), score: message.score })} variant="outlined" />
{message.alreadyImported ? <Chip size="small" label={t("correspondenceAlreadyLinked")} 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>
);
}