109745edb0
Make /jobs/:id the canonical application workspace while preserving list state and compatibility links. Replace popup and expandable-row navigation with accessible whole-row routing and richer job details.
702 lines
33 KiB
TypeScript
702 lines
33 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
|
import {
|
|
Box,
|
|
Alert,
|
|
Chip,
|
|
CircularProgress,
|
|
FormControl,
|
|
InputLabel,
|
|
MenuItem,
|
|
Paper,
|
|
Select,
|
|
Stack,
|
|
TextField,
|
|
Typography,
|
|
Button,
|
|
} from "@mui/material";
|
|
import MailOutlineIcon from "@mui/icons-material/MailOutline";
|
|
import { api, getApiErrorMessage } from "../api";
|
|
import { useConfirm } from "../confirm";
|
|
import { useToast } from "../toast";
|
|
import GmailReviewPage from "./GmailReviewPage";
|
|
|
|
export type CorrespondenceInboxItem = {
|
|
id: number;
|
|
jobApplicationId: number;
|
|
companyName?: string | null;
|
|
jobTitle?: string | null;
|
|
from: string;
|
|
direction?: string | null;
|
|
subject?: string | null;
|
|
channel?: string | null;
|
|
date: string;
|
|
contentPreview: string;
|
|
externalThreadId?: string | null;
|
|
externalMessageId?: string | null;
|
|
provider?: string | null;
|
|
externalFrom?: string | null;
|
|
externalTo?: string | null;
|
|
labelCount: number;
|
|
attachmentCount: number;
|
|
};
|
|
|
|
type EmailProviderStatus = {
|
|
provider: string;
|
|
displayName: string;
|
|
connected: boolean;
|
|
address?: string | null;
|
|
canRead: boolean;
|
|
canSend: boolean;
|
|
};
|
|
|
|
type JobChoice = {
|
|
id: number;
|
|
jobTitle: string;
|
|
company?: { name?: string | null } | null;
|
|
};
|
|
|
|
export function emailProviderStatusLabel(provider: EmailProviderStatus) {
|
|
if (!provider.connected) return `${provider.displayName}: Not connected`;
|
|
const identity = provider.address || "Connected account";
|
|
if (!provider.canRead) return `${provider.displayName}: ${identity} · Mailbox access unavailable`;
|
|
return `${provider.displayName}: ${identity}${provider.canSend ? " · Read + send" : " · Read only; reconnect to enable send"}`;
|
|
}
|
|
|
|
type EmailMessageDetail = {
|
|
id: string;
|
|
threadId: string;
|
|
subject: string;
|
|
from: string;
|
|
to: string;
|
|
date?: string | null;
|
|
snippet: string;
|
|
bodyText: string;
|
|
labels: string[];
|
|
attachments: Array<{ fileName?: string | null; mimeType?: string | null; sizeBytes?: number | null }>;
|
|
};
|
|
|
|
type EmailDraft = {
|
|
id?: string;
|
|
revision?: number;
|
|
jobApplicationId: number;
|
|
companyName: string;
|
|
jobTitle: string;
|
|
provider: string;
|
|
providerName: string;
|
|
fromAddress: string;
|
|
to: string;
|
|
subject: string;
|
|
bodyText: string;
|
|
threadId: string;
|
|
clientRequestId: string;
|
|
};
|
|
|
|
type StoredEmailDraft = Pick<EmailDraft, "id" | "revision" | "jobApplicationId" | "provider" | "to" | "subject" | "bodyText" | "threadId" | "clientRequestId"> & {
|
|
id: string;
|
|
revision: number;
|
|
createdAtUtc: string;
|
|
updatedAtUtc: string;
|
|
};
|
|
|
|
type EmailSendResult = {
|
|
attemptId: string;
|
|
status: "pending" | "sending" | "sent" | "failed" | "uncertain";
|
|
duplicate: boolean;
|
|
externalMessageId?: string | null;
|
|
externalThreadId?: string | null;
|
|
failureCategory?: string | null;
|
|
};
|
|
|
|
export default function CorrespondenceInboxPage() {
|
|
const navigate = useNavigate();
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const view = searchParams.get("view") === "review" ? "review" : "inbox";
|
|
const { toast } = useToast();
|
|
const { confirm } = useConfirm();
|
|
const [items, setItems] = useState<CorrespondenceInboxItem[]>([]);
|
|
const [providers, setProviders] = useState<EmailProviderStatus[]>([]);
|
|
const [providerStatusLoaded, setProviderStatusLoaded] = useState(false);
|
|
const [jobs, setJobs] = useState<JobChoice[]>([]);
|
|
const [jobsLoaded, setJobsLoaded] = useState(false);
|
|
const [composeSetupOpen, setComposeSetupOpen] = useState(false);
|
|
const [composeJobId, setComposeJobId] = useState<number | "">("");
|
|
const [composeProvider, setComposeProvider] = useState("");
|
|
const [providerStatusError, setProviderStatusError] = useState(false);
|
|
const [selectedMessageId, setSelectedMessageId] = useState<number | null>(null);
|
|
const [messageDetail, setMessageDetail] = useState<EmailMessageDetail | null>(null);
|
|
const [detailLoading, setDetailLoading] = useState(false);
|
|
const [detailError, setDetailError] = useState<string | null>(null);
|
|
const [detailNotice, setDetailNotice] = useState<string | null>(null);
|
|
const detailRequest = useRef(0);
|
|
const [loading, setLoading] = useState(false);
|
|
const [query, setQuery] = useState("");
|
|
const [direction, setDirection] = useState<string>("all");
|
|
const [linkState, setLinkState] = useState<string>("all");
|
|
const [draft, setDraft] = useState<EmailDraft | null>(null);
|
|
const [storedDrafts, setStoredDrafts] = useState<StoredEmailDraft[]>([]);
|
|
const [savingDraft, setSavingDraft] = useState(false);
|
|
const [draftError, setDraftError] = useState<string | null>(null);
|
|
const [sendResult, setSendResult] = useState<EmailSendResult | null>(null);
|
|
const [sendError, setSendError] = useState<string | null>(null);
|
|
const [sending, setSending] = useState(false);
|
|
const [unlinkingThreadId, setUnlinkingThreadId] = useState<string | null>(null);
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await api.get<CorrespondenceInboxItem[]>("/correspondence", {
|
|
params: {
|
|
q: query.trim() || undefined,
|
|
direction: direction === "all" ? undefined : direction,
|
|
linkState: linkState === "all" ? undefined : linkState,
|
|
},
|
|
});
|
|
setItems(res.data ?? []);
|
|
} catch (error) {
|
|
toast(getApiErrorMessage(error, "Failed to load correspondence inbox."), "error");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [direction, linkState, query, toast]);
|
|
|
|
useEffect(() => {
|
|
if (view === "inbox") void load();
|
|
}, [load, view]);
|
|
|
|
useEffect(() => {
|
|
api.get<EmailProviderStatus[]>("/email/providers")
|
|
.then((response) => {
|
|
setProviders(response.data ?? []);
|
|
setProviderStatusError(false);
|
|
})
|
|
.catch(() => {
|
|
setProviders([]);
|
|
setProviderStatusError(true);
|
|
})
|
|
.finally(() => setProviderStatusLoaded(true));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
api.get<{ items?: JobChoice[] }>("/jobapplications", { params: { page: 1, pageSize: 100, sortBy: "dateApplied", sortDir: "desc" } })
|
|
.then((response) => setJobs(Array.isArray(response.data?.items) ? response.data.items : []))
|
|
.catch(() => setJobs([]))
|
|
.finally(() => setJobsLoaded(true));
|
|
}, []);
|
|
|
|
const loadStoredDrafts = useCallback(async () => {
|
|
try {
|
|
const response = await api.get<StoredEmailDraft[]>("/email/drafts");
|
|
setStoredDrafts(Array.isArray(response.data) ? response.data : []);
|
|
} catch {
|
|
setDraftError("Saved drafts could not be loaded. New email can still be reviewed before sending.");
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void loadStoredDrafts();
|
|
}, [loadStoredDrafts]);
|
|
|
|
const filteredSummary = useMemo(() => {
|
|
const linked = items.filter((item) => item.externalThreadId).length;
|
|
const inbound = items.filter((item) => item.direction === "inbound").length;
|
|
return { linked, inbound };
|
|
}, [items]);
|
|
const sendCapableProviders = useMemo(() => providers.filter((provider) => provider.connected && provider.canSend), [providers]);
|
|
|
|
const showMessage = async (item: CorrespondenceInboxItem) => {
|
|
if (selectedMessageId === item.id) {
|
|
detailRequest.current += 1;
|
|
setSelectedMessageId(null);
|
|
setMessageDetail(null);
|
|
return;
|
|
}
|
|
|
|
const request = ++detailRequest.current;
|
|
setSelectedMessageId(item.id);
|
|
setMessageDetail(null);
|
|
setDetailError(null);
|
|
setDetailNotice(null);
|
|
setDetailLoading(true);
|
|
try {
|
|
if (item.provider && item.provider !== "manual" && item.externalMessageId) {
|
|
try {
|
|
const live = await api.get<EmailMessageDetail>("/email/message", {
|
|
params: { provider: item.provider, messageId: item.externalMessageId },
|
|
});
|
|
if (request !== detailRequest.current) return;
|
|
setMessageDetail(live.data);
|
|
return;
|
|
} catch {
|
|
if (request !== detailRequest.current) return;
|
|
setDetailNotice("The provider copy is unavailable. Showing the saved JobTracker copy.");
|
|
}
|
|
}
|
|
|
|
const saved = await api.get<EmailMessageDetail>(`/correspondence/message/${item.id}`);
|
|
if (request !== detailRequest.current) return;
|
|
setMessageDetail(saved.data);
|
|
} catch (error) {
|
|
if (request !== detailRequest.current) return;
|
|
setDetailError(getApiErrorMessage(error, "Failed to load this message."));
|
|
} finally {
|
|
if (request === detailRequest.current) setDetailLoading(false);
|
|
}
|
|
};
|
|
|
|
const startReply = async (item: CorrespondenceInboxItem) => {
|
|
if (!messageDetail || !item.provider) return;
|
|
const provider = providers.find((candidate) => candidate.provider === item.provider);
|
|
if (!provider?.canSend) return;
|
|
if (draft?.bodyText.trim() && !(await confirm({
|
|
title: "Replace email draft?",
|
|
message: "Starting this reply will discard the draft you are currently editing.",
|
|
confirmLabel: "Replace draft",
|
|
destructive: true,
|
|
}))) return;
|
|
const subject = messageDetail.subject.trim();
|
|
setDraft({
|
|
jobApplicationId: item.jobApplicationId,
|
|
companyName: item.companyName || "Unknown company",
|
|
jobTitle: item.jobTitle || "Unknown role",
|
|
provider: item.provider,
|
|
providerName: provider.displayName,
|
|
fromAddress: provider.address || "Connected account",
|
|
to: item.direction === "outbound" ? messageDetail.to : messageDetail.from,
|
|
subject: /^re:/i.test(subject) ? subject : `Re: ${subject || "Your message"}`,
|
|
bodyText: "",
|
|
threadId: messageDetail.threadId || item.externalThreadId || "",
|
|
clientRequestId: globalThis.crypto.randomUUID(),
|
|
});
|
|
setSendResult(null);
|
|
setSendError(null);
|
|
setDraftError(null);
|
|
};
|
|
|
|
const resumeDraft = async (stored: StoredEmailDraft) => {
|
|
if (draft?.bodyText.trim() && !(await confirm({
|
|
title: "Replace email draft?",
|
|
message: draft.id ? "Your current draft is already saved and can be resumed later." : "Your current unsaved draft text will be lost.",
|
|
confirmLabel: "Replace draft",
|
|
destructive: !draft.id,
|
|
}))) return;
|
|
const provider = providers.find((candidate) => candidate.provider === stored.provider);
|
|
const item = items.find((candidate) => candidate.jobApplicationId === stored.jobApplicationId);
|
|
setDraft({
|
|
...stored,
|
|
companyName: item?.companyName || `Job #${stored.jobApplicationId}`,
|
|
jobTitle: item?.jobTitle || "Saved email draft",
|
|
providerName: provider?.displayName || stored.provider,
|
|
fromAddress: provider?.address || "Reconnect before sending",
|
|
});
|
|
setSendResult(null);
|
|
setSendError(null);
|
|
setDraftError(null);
|
|
};
|
|
|
|
const openComposeSetup = () => {
|
|
setComposeJobId((current) => current || jobs[0]?.id || "");
|
|
setComposeProvider((current) => current || sendCapableProviders[0]?.provider || "");
|
|
setComposeSetupOpen(true);
|
|
};
|
|
|
|
const startNewMessage = async () => {
|
|
const job = jobs.find((candidate) => candidate.id === composeJobId);
|
|
const provider = sendCapableProviders.find((candidate) => candidate.provider === composeProvider);
|
|
if (!job || !provider) return;
|
|
if (draft?.bodyText.trim() && !(await confirm({
|
|
title: "Replace email draft?",
|
|
message: draft.id ? "Your current draft is already saved and can be resumed later." : "Your current unsaved draft text will be lost.",
|
|
confirmLabel: "Replace draft",
|
|
destructive: !draft.id,
|
|
}))) return;
|
|
setDraft({
|
|
jobApplicationId: job.id,
|
|
companyName: job.company?.name || "Unknown company",
|
|
jobTitle: job.jobTitle || "Unknown role",
|
|
provider: provider.provider,
|
|
providerName: provider.displayName,
|
|
fromAddress: provider.address || "Connected account",
|
|
to: "",
|
|
subject: "",
|
|
bodyText: "",
|
|
threadId: "",
|
|
clientRequestId: globalThis.crypto.randomUUID(),
|
|
});
|
|
setComposeSetupOpen(false);
|
|
setSendResult(null);
|
|
setSendError(null);
|
|
setDraftError(null);
|
|
};
|
|
|
|
const updateDraft = (changes: Partial<Pick<EmailDraft, "to" | "subject" | "bodyText">>) => {
|
|
setDraft((current) => current ? {
|
|
...current,
|
|
...changes,
|
|
clientRequestId: sendResult ? globalThis.crypto.randomUUID() : current.clientRequestId,
|
|
} : null);
|
|
if (sendResult) setSendResult(null);
|
|
setSendError(null);
|
|
};
|
|
|
|
const discardDraft = async () => {
|
|
if (draft?.bodyText.trim() && !(await confirm({
|
|
title: "Discard email draft?",
|
|
message: "Your unsent draft text will be lost.",
|
|
confirmLabel: "Discard draft",
|
|
destructive: true,
|
|
}))) return;
|
|
if (draft?.id && draft.revision) {
|
|
try {
|
|
await api.delete(`/email/drafts/${draft.id}`, { params: { revision: draft.revision } });
|
|
await loadStoredDrafts();
|
|
} catch (error) {
|
|
setDraftError(getApiErrorMessage(error, "The saved draft changed elsewhere. Reload it before discarding."));
|
|
return;
|
|
}
|
|
}
|
|
setDraft(null);
|
|
setSendResult(null);
|
|
setSendError(null);
|
|
setDraftError(null);
|
|
};
|
|
|
|
const saveDraft = async () => {
|
|
if (!draft || savingDraft || sendResult) return;
|
|
setSavingDraft(true);
|
|
setDraftError(null);
|
|
try {
|
|
const response = draft.id && draft.revision
|
|
? await api.put<StoredEmailDraft>(`/email/drafts/${draft.id}`, {
|
|
revision: draft.revision,
|
|
to: draft.to,
|
|
subject: draft.subject,
|
|
bodyText: draft.bodyText,
|
|
})
|
|
: await api.post<StoredEmailDraft>("/email/drafts", {
|
|
jobApplicationId: draft.jobApplicationId,
|
|
provider: draft.provider,
|
|
to: draft.to,
|
|
subject: draft.subject,
|
|
bodyText: draft.bodyText,
|
|
threadId: draft.threadId || null,
|
|
});
|
|
setDraft((current) => current ? { ...current, ...response.data } : null);
|
|
await loadStoredDrafts();
|
|
toast("Email draft saved.", "success");
|
|
} catch (error: any) {
|
|
setDraftError(error?.response?.status === 409
|
|
? "This draft changed in another tab. Resume the latest saved version before editing again."
|
|
: getApiErrorMessage(error, "Failed to save the email draft."));
|
|
} finally {
|
|
setSavingDraft(false);
|
|
}
|
|
};
|
|
|
|
const sendDraft = async () => {
|
|
if (!draft || sending || !draft.to.trim() || !draft.subject.trim() || !draft.bodyText.trim()) return;
|
|
const approved = await confirm({
|
|
title: "Confirm email send",
|
|
message: `Send with ${draft.providerName} (${draft.fromAddress}) to ${draft.to.trim()}? Subject: ${draft.subject.trim()}. Thread: ${draft.threadId || "New message"}.`,
|
|
confirmLabel: "Send email",
|
|
});
|
|
if (!approved) return;
|
|
|
|
setSending(true);
|
|
setSendError(null);
|
|
try {
|
|
const response = await api.post<EmailSendResult>("/email/send", {
|
|
jobApplicationId: draft.jobApplicationId,
|
|
provider: draft.provider,
|
|
clientRequestId: draft.clientRequestId,
|
|
to: draft.to.trim(),
|
|
subject: draft.subject.trim(),
|
|
bodyText: draft.bodyText,
|
|
threadId: draft.threadId || null,
|
|
confirmed: true,
|
|
});
|
|
setSendResult(response.data);
|
|
if (response.data.status === "sent") {
|
|
toast("Email sent and saved to this job.", "success");
|
|
if (draft.id && draft.revision) {
|
|
try {
|
|
await api.delete(`/email/drafts/${draft.id}`, { params: { revision: draft.revision } });
|
|
setDraft((current) => current ? { ...current, id: undefined, revision: undefined } : null);
|
|
await loadStoredDrafts();
|
|
} catch {
|
|
toast("Email was sent, but a newer saved draft still exists. Review it before taking another action.", "warning");
|
|
}
|
|
}
|
|
await load();
|
|
}
|
|
} catch (error: any) {
|
|
const result = error?.response?.data;
|
|
if (["failed", "uncertain", "sent", "pending", "sending"].includes(result?.status)) {
|
|
setSendResult(result as EmailSendResult);
|
|
} else if (!error?.response || error.response.status >= 500) {
|
|
setSendResult({ attemptId: draft.clientRequestId, status: "uncertain", duplicate: false, failureCategory: "client_connection_lost" });
|
|
} else {
|
|
setSendResult({ attemptId: draft.clientRequestId, status: "failed", duplicate: false });
|
|
}
|
|
setSendError(getApiErrorMessage(error, "The email could not be sent."));
|
|
} finally {
|
|
setSending(false);
|
|
}
|
|
};
|
|
|
|
const prepareNewAttempt = async () => {
|
|
if (!draft || sendResult?.status !== "failed") return;
|
|
setDraftError(null);
|
|
if (draft.id && draft.revision) {
|
|
try {
|
|
const response = await api.post<StoredEmailDraft>(`/email/drafts/${draft.id}/new-attempt`, { revision: draft.revision });
|
|
setDraft((current) => current ? { ...current, ...response.data } : null);
|
|
await loadStoredDrafts();
|
|
} catch (error: any) {
|
|
setDraftError(error?.response?.status === 409
|
|
? "A new attempt is allowed only after the latest saved draft has a definitive failed delivery. Reload the draft before continuing."
|
|
: getApiErrorMessage(error, "Failed to prepare a new delivery attempt."));
|
|
return;
|
|
}
|
|
} else {
|
|
setDraft((current) => current ? { ...current, clientRequestId: globalThis.crypto.randomUUID() } : null);
|
|
}
|
|
setSendResult(null);
|
|
setSendError(null);
|
|
};
|
|
|
|
const unlinkGmailThread = async (item: CorrespondenceInboxItem) => {
|
|
if (item.provider !== "gmail" || !item.externalThreadId || unlinkingThreadId) return;
|
|
if (!(await confirm({
|
|
title: "Unlink Gmail thread?",
|
|
message: `Remove this Gmail thread from ${item.companyName || "this job"} and return it to recruitment review? The provider copy is not deleted.`,
|
|
confirmLabel: "Unlink thread",
|
|
destructive: true,
|
|
}))) return;
|
|
|
|
setUnlinkingThreadId(item.externalThreadId);
|
|
try {
|
|
await api.post("/gmail/unlink-thread", {
|
|
jobApplicationId: item.jobApplicationId,
|
|
threadId: item.externalThreadId,
|
|
note: "Unlinked from Job email hub",
|
|
nextDecision: "review",
|
|
});
|
|
if (selectedMessageId === item.id) {
|
|
detailRequest.current += 1;
|
|
setSelectedMessageId(null);
|
|
setMessageDetail(null);
|
|
}
|
|
await load();
|
|
toast("Gmail thread returned to recruitment review.", "success");
|
|
} catch (error) {
|
|
toast(getApiErrorMessage(error, "Failed to unlink the Gmail thread."), "error");
|
|
} finally {
|
|
setUnlinkingThreadId(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Paper
|
|
sx={{
|
|
mt: 0,
|
|
p: 2,
|
|
borderRadius: 4,
|
|
border: "none",
|
|
boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)",
|
|
}}
|
|
>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap", mb: 2 }}>
|
|
<Box>
|
|
<Typography component="h1" variant="h5" sx={{ fontWeight: 900 }}>Job email</Typography>
|
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
|
Review linked correspondence and suggested recruitment messages in one place.
|
|
</Typography>
|
|
</Box>
|
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
|
{view === "inbox" ? <Chip icon={<MailOutlineIcon />} label={`${items.length} items`} variant="outlined" /> : null}
|
|
{view === "inbox" ? <Chip label={`${filteredSummary.linked} linked`} variant="outlined" color={filteredSummary.linked > 0 ? "success" : "default"} /> : null}
|
|
{view === "inbox" ? <Chip label={`${filteredSummary.inbound} inbound`} variant="outlined" /> : null}
|
|
<Button variant={view === "inbox" ? "contained" : "text"} size="small" onClick={() => setSearchParams({})}>Linked messages</Button>
|
|
<Button variant={view === "review" ? "contained" : "text"} size="small" onClick={() => setSearchParams({ view: "review" })}>Review suggestions</Button>
|
|
<Button variant="outlined" size="small" onClick={openComposeSetup} disabled={jobs.length === 0 || sendCapableProviders.length === 0}>Compose new email</Button>
|
|
</Box>
|
|
</Box>
|
|
|
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 2 }} aria-label="Email provider status">
|
|
{providers.map((provider) => (
|
|
<Chip
|
|
key={provider.provider}
|
|
size="small"
|
|
color={provider.connected ? "success" : "default"}
|
|
variant="outlined"
|
|
label={emailProviderStatusLabel(provider)}
|
|
/>
|
|
))}
|
|
</Box>
|
|
{providerStatusError ? (
|
|
<Alert severity="warning" sx={{ mb: 2 }}>Email provider status is temporarily unavailable. Saved JobTracker correspondence remains available.</Alert>
|
|
) : null}
|
|
{jobsLoaded && jobs.length === 0 ? <Alert severity="info" sx={{ mb: 2 }}>Add a job before composing a new email.</Alert> : null}
|
|
{providerStatusLoaded && !providerStatusError && sendCapableProviders.length === 0 ? <Alert severity="info" sx={{ mb: 2 }}>Reconnect Gmail or Outlook with send access before composing a new email.</Alert> : null}
|
|
{draftError && !draft ? <Alert severity="warning" sx={{ mb: 2 }}>{draftError}</Alert> : null}
|
|
|
|
{storedDrafts.length > 0 ? (
|
|
<Alert severity="info" sx={{ mb: 2 }}>
|
|
<Stack direction="row" spacing={1} useFlexGap flexWrap="wrap" alignItems="center">
|
|
<Typography variant="body2">{storedDrafts.length} saved email draft{storedDrafts.length === 1 ? "" : "s"} available.</Typography>
|
|
{storedDrafts.slice(0, 5).map((stored) => (
|
|
<Button key={stored.id} size="small" onClick={() => void resumeDraft(stored)}>
|
|
Resume {stored.subject || `job #${stored.jobApplicationId}`}
|
|
</Button>
|
|
))}
|
|
</Stack>
|
|
</Alert>
|
|
) : null}
|
|
|
|
{composeSetupOpen ? (
|
|
<Paper component="section" aria-labelledby="compose-new-title" variant="outlined" sx={{ p: 2, borderRadius: 3, mb: 2 }}>
|
|
<Typography id="compose-new-title" component="h2" variant="h6" sx={{ fontWeight: 900, mb: 1.5 }}>Compose new email</Typography>
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr auto" }, gap: 1.25, alignItems: "center" }}>
|
|
<FormControl fullWidth>
|
|
<InputLabel id="compose-job-label">Job</InputLabel>
|
|
<Select labelId="compose-job-label" value={composeJobId} label="Job" onChange={(event) => setComposeJobId(Number(event.target.value))}>
|
|
{jobs.map((job) => <MenuItem key={job.id} value={job.id}>{job.company?.name || "Unknown company"} · {job.jobTitle}</MenuItem>)}
|
|
</Select>
|
|
</FormControl>
|
|
<FormControl fullWidth>
|
|
<InputLabel id="compose-provider-label">Sending provider</InputLabel>
|
|
<Select labelId="compose-provider-label" value={composeProvider} label="Sending provider" onChange={(event) => setComposeProvider(String(event.target.value))}>
|
|
{sendCapableProviders.map((provider) => <MenuItem key={provider.provider} value={provider.provider}>{provider.displayName} · {provider.address || "Connected account"}</MenuItem>)}
|
|
</Select>
|
|
</FormControl>
|
|
<Box sx={{ display: "flex", gap: 1 }}>
|
|
<Button onClick={() => setComposeSetupOpen(false)}>Cancel</Button>
|
|
<Button variant="contained" onClick={() => void startNewMessage()} disabled={!composeJobId || !composeProvider}>Start draft</Button>
|
|
</Box>
|
|
</Box>
|
|
</Paper>
|
|
) : null}
|
|
|
|
{view === "review" ? <GmailReviewPage embedded /> : <>
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "2fr 1fr 1fr auto" }, gap: 1.25, mb: 2 }}>
|
|
<TextField label="Search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Company, role, recruiter, subject" />
|
|
<FormControl fullWidth>
|
|
<InputLabel>Direction</InputLabel>
|
|
<Select value={direction} label="Direction" onChange={(e) => setDirection(String(e.target.value))}>
|
|
<MenuItem value="all">All</MenuItem>
|
|
<MenuItem value="inbound">Inbound</MenuItem>
|
|
<MenuItem value="outbound">Outbound</MenuItem>
|
|
<MenuItem value="internal">Internal</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
<FormControl fullWidth>
|
|
<InputLabel>Link state</InputLabel>
|
|
<Select value={linkState} label="Link state" onChange={(e) => setLinkState(String(e.target.value))}>
|
|
<MenuItem value="all">All</MenuItem>
|
|
<MenuItem value="linked">Linked threads</MenuItem>
|
|
<MenuItem value="manual">Manual/internal only</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
<Button variant="contained" onClick={() => void load()} disabled={loading}>{loading ? "Loading..." : "Refresh"}</Button>
|
|
</Box>
|
|
|
|
{draft ? (
|
|
<Paper component="section" aria-labelledby="email-draft-title" variant="outlined" sx={{ p: 2, borderRadius: 3, mb: 2 }}>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", mb: 1.5 }}>
|
|
<Box>
|
|
<Typography id="email-draft-title" component="h2" variant="h6" sx={{ fontWeight: 900 }}>Email draft</Typography>
|
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>{draft.companyName} · {draft.jobTitle}</Typography>
|
|
</Box>
|
|
<Button onClick={() => void discardDraft()} disabled={sending}>Discard draft</Button>
|
|
</Box>
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.25 }}>
|
|
<TextField label="Provider" value={`${draft.providerName} · ${draft.fromAddress}`} slotProps={{ input: { readOnly: true } }} />
|
|
<TextField label="Thread" value={draft.threadId || "New message"} slotProps={{ input: { readOnly: true } }} />
|
|
<TextField label="Recipient" type="email" value={draft.to} onChange={(event) => updateDraft({ to: event.target.value })} disabled={sending} inputProps={{ maxLength: 320 }} />
|
|
<TextField label="Subject" value={draft.subject} onChange={(event) => updateDraft({ subject: event.target.value })} disabled={sending} inputProps={{ maxLength: 998 }} />
|
|
<TextField label="Message" value={draft.bodyText} onChange={(event) => updateDraft({ bodyText: event.target.value })} disabled={sending} multiline minRows={6} inputProps={{ maxLength: 200000 }} helperText={`${draft.bodyText.length} / 200000 characters`} sx={{ gridColumn: "1 / -1" }} />
|
|
</Box>
|
|
{sendResult?.status === "sent" ? <Alert severity="success" sx={{ mt: 1.5 }}>Sent successfully. This exact attempt cannot be sent again.</Alert> : null}
|
|
{sendResult?.status === "uncertain" || sendResult?.status === "sending" || sendResult?.status === "pending" ? (
|
|
<Alert severity="warning" sx={{ mt: 1.5 }}>Delivery status is uncertain. Do not retry this draft. Check the provider Sent folder before taking any further action.</Alert>
|
|
) : null}
|
|
{sendResult?.status === "failed" ? (
|
|
<Alert severity="error" sx={{ mt: 1.5 }} action={<Button color="inherit" size="small" onClick={() => void prepareNewAttempt()}>Prepare new attempt</Button>}>
|
|
The provider confirmed that this attempt did not complete. Review the connection and draft before creating a new attempt.
|
|
</Alert>
|
|
) : null}
|
|
{draftError ? <Alert severity="warning" sx={{ mt: 1.5 }}>{draftError}</Alert> : null}
|
|
{sendError && sendResult?.status !== "uncertain" ? <Typography variant="body2" sx={{ color: "error.main", mt: 1 }}>{sendError}</Typography> : null}
|
|
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 1, mt: 1.5, flexWrap: "wrap" }}>
|
|
<Button variant="outlined" onClick={() => void saveDraft()} disabled={savingDraft || sending || !!sendResult}>
|
|
{savingDraft ? "Saving…" : draft.id ? "Save changes" : "Save draft"}
|
|
</Button>
|
|
<Button variant="contained" onClick={() => void sendDraft()} disabled={sending || !!sendResult || !draft.to.trim() || !draft.subject.trim() || !draft.bodyText.trim()}>
|
|
{sending ? "Sending…" : "Review and send"}
|
|
</Button>
|
|
</Box>
|
|
</Paper>
|
|
) : null}
|
|
|
|
{loading ? <Box sx={{ py: 6, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : null}
|
|
|
|
{!loading && items.length === 0 ? (
|
|
<Typography sx={{ color: "text.secondary", py: 4, textAlign: "center" }}>No correspondence matches the current filters.</Typography>
|
|
) : null}
|
|
|
|
<Stack spacing={1.25}>
|
|
{items.map((item) => (
|
|
<Paper key={item.id} 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" }}>{item.companyName || "Unknown company"} • {item.jobTitle || "Unknown role"}</Typography>
|
|
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>{item.subject || item.contentPreview}</Typography>
|
|
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 0.5 }}>
|
|
{item.externalFrom || item.from} {item.externalTo ? `→ ${item.externalTo}` : ""} · {new Date(item.date).toLocaleString()}
|
|
</Typography>
|
|
</Box>
|
|
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", justifyContent: "flex-end" }}>
|
|
{item.direction ? <Chip size="small" label={item.direction} variant="outlined" /> : null}
|
|
{item.provider ? <Chip size="small" label={item.provider === "microsoft" ? "Outlook" : item.provider === "gmail" ? "Gmail" : item.provider.toUpperCase()} variant="outlined" /> : null}
|
|
{item.externalThreadId ? <Chip size="small" label={`Thread ${item.externalThreadId}`} color="success" variant="outlined" /> : <Chip size="small" label="Manual/internal" variant="outlined" />}
|
|
{item.labelCount > 0 ? <Chip size="small" label={`${item.labelCount} labels`} variant="outlined" /> : null}
|
|
{item.attachmentCount > 0 ? <Chip size="small" label={`${item.attachmentCount} attachments`} variant="outlined" /> : null}
|
|
<Button size="small" variant="text" onClick={() => void showMessage(item)}>{selectedMessageId === item.id ? "Hide message" : "View message"}</Button>
|
|
<Button size="small" variant="text" onClick={() => navigate(`/jobs/${item.jobApplicationId}?section=communication`)}>Open job</Button>
|
|
{item.provider === "gmail" && item.externalThreadId ? (
|
|
<Button size="small" color="warning" variant="text" disabled={unlinkingThreadId === item.externalThreadId} onClick={() => void unlinkGmailThread(item)}>
|
|
{unlinkingThreadId === item.externalThreadId ? "Unlinking…" : "Unlink thread"}
|
|
</Button>
|
|
) : null}
|
|
</Box>
|
|
</Box>
|
|
{selectedMessageId === item.id ? (
|
|
<Box sx={{ mt: 1.5, pt: 1.5, borderTop: "1px solid", borderColor: "divider" }}>
|
|
{detailLoading ? <Box sx={{ display: "flex", gap: 1, alignItems: "center" }}><CircularProgress size={18} /><Typography variant="body2">Loading message…</Typography></Box> : null}
|
|
{detailNotice ? <Alert severity="warning" sx={{ mb: 1 }}>{detailNotice}</Alert> : null}
|
|
{detailError ? <Alert severity="error">{detailError}</Alert> : null}
|
|
{messageDetail ? <>
|
|
<Typography sx={{ fontWeight: 800 }}>{messageDetail.subject || "No subject"}</Typography>
|
|
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mb: 1 }}>{messageDetail.from}{messageDetail.to ? ` → ${messageDetail.to}` : ""}</Typography>
|
|
<Typography sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{messageDetail.bodyText}</Typography>
|
|
{(messageDetail.labels.length > 0 || messageDetail.attachments.length > 0) ? <Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 1 }}>
|
|
{messageDetail.labels.map((label, index) => <Chip key={`${label}-${index}`} size="small" label={label} variant="outlined" />)}
|
|
{messageDetail.attachments.map((attachment, index) => <Chip key={`${attachment.fileName || "attachment"}-${index}`} size="small" label={attachment.fileName || "Attachment"} variant="outlined" />)}
|
|
</Box> : null}
|
|
{item.provider && providers.find((provider) => provider.provider === item.provider)?.canSend ? (
|
|
<Button sx={{ mt: 1.5 }} variant="outlined" onClick={() => void startReply(item)}>Reply with connected provider</Button>
|
|
) : item.provider && item.provider !== "manual" ? (
|
|
<Alert severity="info" sx={{ mt: 1.5 }}>Reconnect this provider with send access to reply from JobTracker.</Alert>
|
|
) : null}
|
|
</> : null}
|
|
</Box>
|
|
) : null}
|
|
</Paper>
|
|
))}
|
|
</Stack>
|
|
</>}
|
|
</Paper>
|
|
);
|
|
}
|