fix(i18n): localize job email hub
This commit is contained in:
@@ -21,6 +21,7 @@ import MailOutlineIcon from "@mui/icons-material/MailOutline";
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { useConfirm } from "../confirm";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import GmailReviewPage from "./GmailReviewPage";
|
||||
|
||||
export type CorrespondenceInboxItem = {
|
||||
@@ -66,13 +67,6 @@ type JobChoice = {
|
||||
companyName: string;
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -124,6 +118,7 @@ export default function CorrespondenceInboxPage() {
|
||||
const view = searchParams.get("view") === "review" ? "review" : "inbox";
|
||||
const { toast } = useToast();
|
||||
const { confirm } = useConfirm();
|
||||
const { language, t } = useI18n();
|
||||
const [items, setItems] = useState<CorrespondenceInboxItem[]>([]);
|
||||
const [inboxPage, setInboxPage] = useState(1);
|
||||
const [inboxTotal, setInboxTotal] = useState(0);
|
||||
@@ -176,11 +171,11 @@ export default function CorrespondenceInboxPage() {
|
||||
setSelectedMessageId(null);
|
||||
setMessageDetail(null);
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, "Failed to load correspondence inbox."), "error");
|
||||
toast(getApiErrorMessage(error, t("emailInboxLoadFailed")), "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [direction, inboxPage, linkState, query, toast]);
|
||||
}, [direction, inboxPage, language, linkState, query, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (view === "inbox") void load();
|
||||
@@ -218,9 +213,9 @@ export default function CorrespondenceInboxPage() {
|
||||
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.");
|
||||
setDraftError(t("emailInboxDraftsLoadFailed"));
|
||||
}
|
||||
}, []);
|
||||
}, [language]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadStoredDrafts();
|
||||
@@ -258,7 +253,7 @@ export default function CorrespondenceInboxPage() {
|
||||
return;
|
||||
} catch {
|
||||
if (request !== detailRequest.current) return;
|
||||
setDetailNotice("The provider copy is unavailable. Showing the saved JobTracker copy.");
|
||||
setDetailNotice(t("emailInboxProviderCopyUnavailable"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,7 +262,7 @@ export default function CorrespondenceInboxPage() {
|
||||
setMessageDetail(saved.data);
|
||||
} catch (error) {
|
||||
if (request !== detailRequest.current) return;
|
||||
setDetailError(getApiErrorMessage(error, "Failed to load this message."));
|
||||
setDetailError(getApiErrorMessage(error, t("emailInboxMessageLoadFailed")));
|
||||
} finally {
|
||||
if (request === detailRequest.current) setDetailLoading(false);
|
||||
}
|
||||
@@ -278,21 +273,21 @@ export default function CorrespondenceInboxPage() {
|
||||
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",
|
||||
title: t("emailInboxReplaceDraftTitle"),
|
||||
message: t("emailInboxReplaceReplyMessage"),
|
||||
confirmLabel: t("emailInboxReplaceDraft"),
|
||||
destructive: true,
|
||||
}))) return;
|
||||
const subject = messageDetail.subject.trim();
|
||||
setDraft({
|
||||
jobApplicationId: item.jobApplicationId,
|
||||
companyName: item.companyName || "Unknown company",
|
||||
jobTitle: item.jobTitle || "Unknown role",
|
||||
companyName: item.companyName || t("emailInboxUnknownCompany"),
|
||||
jobTitle: item.jobTitle || t("emailInboxUnknownRole"),
|
||||
provider: item.provider,
|
||||
providerName: provider.displayName,
|
||||
fromAddress: provider.address || "Connected account",
|
||||
fromAddress: provider.address || t("emailInboxConnectedAccount"),
|
||||
to: item.direction === "outbound" ? messageDetail.to : messageDetail.from,
|
||||
subject: /^re:/i.test(subject) ? subject : `Re: ${subject || "Your message"}`,
|
||||
subject: /^re:/i.test(subject) ? subject : `Re: ${subject || t("emailInboxYourMessage")}`,
|
||||
bodyText: "",
|
||||
threadId: messageDetail.threadId || item.externalThreadId || "",
|
||||
clientRequestId: globalThis.crypto.randomUUID(),
|
||||
@@ -304,9 +299,9 @@ export default function CorrespondenceInboxPage() {
|
||||
|
||||
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",
|
||||
title: t("emailInboxReplaceDraftTitle"),
|
||||
message: draft.id ? t("emailInboxCurrentDraftSaved") : t("emailInboxUnsavedDraftLost"),
|
||||
confirmLabel: t("emailInboxReplaceDraft"),
|
||||
destructive: !draft.id,
|
||||
}))) return;
|
||||
const provider = providers.find((candidate) => candidate.provider === stored.provider);
|
||||
@@ -314,9 +309,9 @@ export default function CorrespondenceInboxPage() {
|
||||
setDraft({
|
||||
...stored,
|
||||
companyName: item?.companyName || `Job #${stored.jobApplicationId}`,
|
||||
jobTitle: item?.jobTitle || "Saved email draft",
|
||||
jobTitle: item?.jobTitle || t("emailInboxSavedDraft"),
|
||||
providerName: provider?.displayName || stored.provider,
|
||||
fromAddress: provider?.address || "Reconnect before sending",
|
||||
fromAddress: provider?.address || t("emailInboxReconnectBeforeSending"),
|
||||
});
|
||||
setSendResult(null);
|
||||
setSendError(null);
|
||||
@@ -334,18 +329,18 @@ export default function CorrespondenceInboxPage() {
|
||||
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",
|
||||
title: t("emailInboxReplaceDraftTitle"),
|
||||
message: draft.id ? t("emailInboxCurrentDraftSaved") : t("emailInboxUnsavedDraftLost"),
|
||||
confirmLabel: t("emailInboxReplaceDraft"),
|
||||
destructive: !draft.id,
|
||||
}))) return;
|
||||
setDraft({
|
||||
jobApplicationId: job.id,
|
||||
companyName: job.companyName || "Unknown company",
|
||||
jobTitle: job.jobTitle || "Unknown role",
|
||||
companyName: job.companyName || t("emailInboxUnknownCompany"),
|
||||
jobTitle: job.jobTitle || t("emailInboxUnknownRole"),
|
||||
provider: provider.provider,
|
||||
providerName: provider.displayName,
|
||||
fromAddress: provider.address || "Connected account",
|
||||
fromAddress: provider.address || t("emailInboxConnectedAccount"),
|
||||
to: "",
|
||||
subject: "",
|
||||
bodyText: "",
|
||||
@@ -370,9 +365,9 @@ export default function CorrespondenceInboxPage() {
|
||||
|
||||
const discardDraft = async () => {
|
||||
if (draft?.bodyText.trim() && !(await confirm({
|
||||
title: "Discard email draft?",
|
||||
message: "Your unsent draft text will be lost.",
|
||||
confirmLabel: "Discard draft",
|
||||
title: t("emailInboxDiscardDraftTitle"),
|
||||
message: t("emailInboxDiscardDraftMessage"),
|
||||
confirmLabel: t("emailInboxDiscardDraft"),
|
||||
destructive: true,
|
||||
}))) return;
|
||||
if (draft?.id && draft.revision) {
|
||||
@@ -380,7 +375,7 @@ export default function CorrespondenceInboxPage() {
|
||||
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."));
|
||||
setDraftError(getApiErrorMessage(error, t("emailInboxDraftChangedDiscard")));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -412,11 +407,11 @@ export default function CorrespondenceInboxPage() {
|
||||
});
|
||||
setDraft((current) => current ? { ...current, ...response.data } : null);
|
||||
await loadStoredDrafts();
|
||||
toast("Email draft saved.", "success");
|
||||
toast(t("emailInboxDraftSaved"), "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."));
|
||||
? t("emailInboxDraftChangedElsewhere")
|
||||
: getApiErrorMessage(error, t("emailInboxDraftSaveFailed")));
|
||||
} finally {
|
||||
setSavingDraft(false);
|
||||
}
|
||||
@@ -425,9 +420,9 @@ export default function CorrespondenceInboxPage() {
|
||||
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",
|
||||
title: t("emailInboxConfirmSendTitle"),
|
||||
message: t("emailInboxConfirmSendMessage", { provider: draft.providerName, from: draft.fromAddress, to: draft.to.trim(), subject: draft.subject.trim(), thread: draft.threadId || t("emailInboxNewMessage") }),
|
||||
confirmLabel: t("emailInboxSendEmail"),
|
||||
});
|
||||
if (!approved) return;
|
||||
|
||||
@@ -446,14 +441,14 @@ export default function CorrespondenceInboxPage() {
|
||||
});
|
||||
setSendResult(response.data);
|
||||
if (response.data.status === "sent") {
|
||||
toast("Email sent and saved to this job.", "success");
|
||||
toast(t("emailInboxSentSaved"), "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");
|
||||
toast(t("emailInboxSentNewerDraft"), "warning");
|
||||
}
|
||||
}
|
||||
await load();
|
||||
@@ -467,7 +462,7 @@ export default function CorrespondenceInboxPage() {
|
||||
} else {
|
||||
setSendResult({ attemptId: draft.clientRequestId, status: "failed", duplicate: false });
|
||||
}
|
||||
setSendError(getApiErrorMessage(error, "The email could not be sent."));
|
||||
setSendError(getApiErrorMessage(error, t("emailInboxSendFailed")));
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
@@ -483,8 +478,8 @@ export default function CorrespondenceInboxPage() {
|
||||
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."));
|
||||
? t("emailInboxNewAttemptConflict")
|
||||
: getApiErrorMessage(error, t("emailInboxNewAttemptFailed")));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
@@ -497,9 +492,9 @@ export default function CorrespondenceInboxPage() {
|
||||
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",
|
||||
title: t("emailInboxUnlinkTitle"),
|
||||
message: t("emailInboxUnlinkMessage", { job: item.companyName || t("emailInboxThisJob") }),
|
||||
confirmLabel: t("emailInboxUnlinkThread"),
|
||||
destructive: true,
|
||||
}))) return;
|
||||
|
||||
@@ -517,14 +512,21 @@ export default function CorrespondenceInboxPage() {
|
||||
setMessageDetail(null);
|
||||
}
|
||||
await load();
|
||||
toast("Gmail thread returned to recruitment review.", "success");
|
||||
toast(t("emailInboxUnlinked"), "success");
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, "Failed to unlink the Gmail thread."), "error");
|
||||
toast(getApiErrorMessage(error, t("emailInboxUnlinkFailed")), "error");
|
||||
} finally {
|
||||
setUnlinkingThreadId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const providerStatusLabel = (provider: EmailProviderStatus) => {
|
||||
if (!provider.connected) return t("emailInboxProviderNotConnected", { provider: provider.displayName });
|
||||
const identity = provider.address || t("emailInboxConnectedAccount");
|
||||
if (!provider.canRead) return t("emailInboxProviderReadUnavailable", { provider: provider.displayName, identity });
|
||||
return t(provider.canSend ? "emailInboxProviderReadSend" : "emailInboxProviderReadOnly", { provider: provider.displayName, identity });
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
@@ -537,46 +539,46 @@ export default function CorrespondenceInboxPage() {
|
||||
>
|
||||
<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 component="h1" variant="h5" sx={{ fontWeight: 900 }}>{t("correspondenceInbox")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
Review linked correspondence and suggested recruitment messages in one place.
|
||||
{t("emailInboxSubtitle")}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||
{view === "inbox" ? <Chip icon={<MailOutlineIcon />} label={`${inboxTotal} 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>
|
||||
{view === "inbox" ? <Chip icon={<MailOutlineIcon />} label={t("emailInboxItems", { count: inboxTotal })} variant="outlined" /> : null}
|
||||
{view === "inbox" ? <Chip label={t("emailInboxLinked", { count: filteredSummary.linked })} variant="outlined" color={filteredSummary.linked > 0 ? "success" : "default"} /> : null}
|
||||
{view === "inbox" ? <Chip label={t("emailInboxInboundCount", { count: filteredSummary.inbound })} variant="outlined" /> : null}
|
||||
<Button variant={view === "inbox" ? "contained" : "text"} size="small" onClick={() => setSearchParams({})}>{t("emailInboxLinkedMessages")}</Button>
|
||||
<Button variant={view === "review" ? "contained" : "text"} size="small" onClick={() => setSearchParams({ view: "review" })}>{t("emailInboxReviewSuggestions")}</Button>
|
||||
<Button variant="outlined" size="small" onClick={openComposeSetup} disabled={jobs.length === 0 || sendCapableProviders.length === 0}>{t("emailInboxCompose")}</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 2 }} aria-label="Email provider status">
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 2 }} aria-label={t("emailInboxProviderStatus")}>
|
||||
{providers.map((provider) => (
|
||||
<Chip
|
||||
key={provider.provider}
|
||||
size="small"
|
||||
color={provider.connected ? "success" : "default"}
|
||||
variant="outlined"
|
||||
label={emailProviderStatusLabel(provider)}
|
||||
label={providerStatusLabel(provider)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
{providerStatusError ? (
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>Email provider status is temporarily unavailable. Saved JobTracker correspondence remains available.</Alert>
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>{t("emailInboxProviderStatusUnavailable")}</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}
|
||||
{jobsLoaded && jobs.length === 0 ? <Alert severity="info" sx={{ mb: 2 }}>{t("emailInboxAddJobFirst")}</Alert> : null}
|
||||
{providerStatusLoaded && !providerStatusError && sendCapableProviders.length === 0 ? <Alert severity="info" sx={{ mb: 2 }}>{t("emailInboxReconnectToCompose")}</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>
|
||||
<Typography variant="body2">{t(storedDrafts.length === 1 ? "emailInboxSavedDraftOne" : "emailInboxSavedDraftMany", { count: storedDrafts.length })}</Typography>
|
||||
{storedDrafts.slice(0, 5).map((stored) => (
|
||||
<Button key={stored.id} size="small" onClick={() => void resumeDraft(stored)}>
|
||||
Resume {stored.subject || `job #${stored.jobApplicationId}`}
|
||||
{t("emailInboxResumeDraft", { name: stored.subject || `${t("emailInboxJobNumber")} #${stored.jobApplicationId}` })}
|
||||
</Button>
|
||||
))}
|
||||
</Stack>
|
||||
@@ -585,7 +587,7 @@ export default function CorrespondenceInboxPage() {
|
||||
|
||||
{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>
|
||||
<Typography id="compose-new-title" component="h2" variant="h6" sx={{ fontWeight: 900, mb: 1.5 }}>{t("emailInboxCompose")}</Typography>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr auto" }, gap: 1.25, alignItems: "center" }}>
|
||||
<Autocomplete
|
||||
options={jobs}
|
||||
@@ -596,18 +598,18 @@ export default function CorrespondenceInboxPage() {
|
||||
}}
|
||||
onChange={(_, value) => setComposeJobId(value?.id ?? "")}
|
||||
isOptionEqualToValue={(option, value) => option.id === value.id}
|
||||
getOptionLabel={(job) => `${job.companyName || "Unknown company"} · ${job.jobTitle}`}
|
||||
renderInput={(params) => <TextField {...params} label="Job" placeholder="Search every job" />}
|
||||
getOptionLabel={(job) => `${job.companyName || t("emailInboxUnknownCompany")} · ${job.jobTitle}`}
|
||||
renderInput={(params) => <TextField {...params} label={t("emailInboxJob")} placeholder={t("emailInboxSearchJobs")} />}
|
||||
/>
|
||||
<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>)}
|
||||
<InputLabel id="compose-provider-label">{t("emailInboxSendingProvider")}</InputLabel>
|
||||
<Select labelId="compose-provider-label" value={composeProvider} label={t("emailInboxSendingProvider")} onChange={(event) => setComposeProvider(String(event.target.value))}>
|
||||
{sendCapableProviders.map((provider) => <MenuItem key={provider.provider} value={provider.provider}>{provider.displayName} · {provider.address || t("emailInboxConnectedAccount")}</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>
|
||||
<Button onClick={() => setComposeSetupOpen(false)}>{t("cancel")}</Button>
|
||||
<Button variant="contained" onClick={() => void startNewMessage()} disabled={!composeJobId || !composeProvider}>{t("emailInboxStartDraft")}</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
@@ -615,60 +617,60 @@ export default function CorrespondenceInboxPage() {
|
||||
|
||||
{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); setInboxPage(1); }} placeholder="Company, role, recruiter, subject" />
|
||||
<TextField label={t("correspondenceSearch")} value={query} onChange={(e) => { setQuery(e.target.value); setInboxPage(1); }} placeholder={t("emailInboxSearchPlaceholder")} />
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Direction</InputLabel>
|
||||
<Select value={direction} label="Direction" onChange={(e) => { setDirection(String(e.target.value)); setInboxPage(1); }}>
|
||||
<MenuItem value="all">All</MenuItem>
|
||||
<MenuItem value="inbound">Inbound</MenuItem>
|
||||
<MenuItem value="outbound">Outbound</MenuItem>
|
||||
<MenuItem value="internal">Internal</MenuItem>
|
||||
<InputLabel id="email-direction-label">{t("emailInboxDirection")}</InputLabel>
|
||||
<Select labelId="email-direction-label" value={direction} label={t("emailInboxDirection")} onChange={(e) => { setDirection(String(e.target.value)); setInboxPage(1); }}>
|
||||
<MenuItem value="all">{t("emailInboxAll")}</MenuItem>
|
||||
<MenuItem value="inbound">{t("emailInboxInbound")}</MenuItem>
|
||||
<MenuItem value="outbound">{t("emailInboxOutbound")}</MenuItem>
|
||||
<MenuItem value="internal">{t("emailInboxInternal")}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Link state</InputLabel>
|
||||
<Select value={linkState} label="Link state" onChange={(e) => { setLinkState(String(e.target.value)); setInboxPage(1); }}>
|
||||
<MenuItem value="all">All</MenuItem>
|
||||
<MenuItem value="linked">Linked threads</MenuItem>
|
||||
<MenuItem value="manual">Manual/internal only</MenuItem>
|
||||
<InputLabel id="email-link-state-label">{t("emailInboxLinkState")}</InputLabel>
|
||||
<Select labelId="email-link-state-label" value={linkState} label={t("emailInboxLinkState")} onChange={(e) => { setLinkState(String(e.target.value)); setInboxPage(1); }}>
|
||||
<MenuItem value="all">{t("emailInboxAll")}</MenuItem>
|
||||
<MenuItem value="linked">{t("emailInboxLinkedThreads")}</MenuItem>
|
||||
<MenuItem value="manual">{t("emailInboxManualOnly")}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Button variant="contained" onClick={() => void load()} disabled={loading}>{loading ? "Loading..." : "Refresh"}</Button>
|
||||
<Button variant="contained" onClick={() => void load()} disabled={loading}>{loading ? t("loading") : t("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 id="email-draft-title" component="h2" variant="h6" sx={{ fontWeight: 900 }}>{t("emailInboxDraftTitle")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{draft.companyName} · {draft.jobTitle}</Typography>
|
||||
</Box>
|
||||
<Button onClick={() => void discardDraft()} disabled={sending}>Discard draft</Button>
|
||||
<Button onClick={() => void discardDraft()} disabled={sending}>{t("emailInboxDiscardDraft")}</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" }} />
|
||||
<TextField label={t("emailInboxProvider")} value={`${draft.providerName} · ${draft.fromAddress}`} slotProps={{ input: { readOnly: true } }} />
|
||||
<TextField label={t("emailInboxThread")} value={draft.threadId || t("emailInboxNewMessage")} slotProps={{ input: { readOnly: true } }} />
|
||||
<TextField label={t("emailInboxRecipient")} type="email" value={draft.to} onChange={(event) => updateDraft({ to: event.target.value })} disabled={sending} inputProps={{ maxLength: 320 }} />
|
||||
<TextField label={t("emailInboxSubject")} value={draft.subject} onChange={(event) => updateDraft({ subject: event.target.value })} disabled={sending} inputProps={{ maxLength: 998 }} />
|
||||
<TextField label={t("emailInboxMessage")} value={draft.bodyText} onChange={(event) => updateDraft({ bodyText: event.target.value })} disabled={sending} multiline minRows={6} inputProps={{ maxLength: 200000 }} helperText={t("emailInboxCharacters", { count: draft.bodyText.length })} 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 === "sent" ? <Alert severity="success" sx={{ mt: 1.5 }}>{t("emailInboxSentSuccess")}</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>
|
||||
<Alert severity="warning" sx={{ mt: 1.5 }}>{t("emailInboxDeliveryUncertain")}</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 severity="error" sx={{ mt: 1.5 }} action={<Button color="inherit" size="small" onClick={() => void prepareNewAttempt()}>{t("emailInboxPrepareAttempt")}</Button>}>
|
||||
{t("emailInboxDeliveryFailed")}
|
||||
</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"}
|
||||
{savingDraft ? t("saving") : draft.id ? t("profileSaveChanges") : t("emailInboxSaveDraft")}
|
||||
</Button>
|
||||
<Button variant="contained" onClick={() => void sendDraft()} disabled={sending || !!sendResult || !draft.to.trim() || !draft.subject.trim() || !draft.bodyText.trim()}>
|
||||
{sending ? "Sending…" : "Review and send"}
|
||||
{sending ? t("emailInboxSending") : t("emailInboxReviewSend")}
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
@@ -677,7 +679,7 @@ export default function CorrespondenceInboxPage() {
|
||||
{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>
|
||||
<Typography sx={{ color: "text.secondary", py: 4, textAlign: "center" }}>{t("emailInboxNoMatches")}</Typography>
|
||||
) : null}
|
||||
|
||||
<Stack spacing={1.25}>
|
||||
@@ -685,44 +687,44 @@ export default function CorrespondenceInboxPage() {
|
||||
<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 sx={{ fontWeight: 800, overflowWrap: "anywhere" }}>{item.companyName || t("emailInboxUnknownCompany")} • {item.jobTitle || t("emailInboxUnknownRole")}</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()}
|
||||
{item.externalFrom || item.from} {item.externalTo ? `→ ${item.externalTo}` : ""} · {new Date(item.date).toLocaleString(language === "nb" ? "nb-NO" : "en")}
|
||||
</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.direction ? <Chip size="small" label={t(item.direction === "inbound" ? "emailInboxInbound" : item.direction === "outbound" ? "emailInboxOutbound" : "emailInboxInternal")} 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.externalThreadId ? <Chip size="small" label={t("emailInboxThreadId", { id: item.externalThreadId })} color="success" variant="outlined" /> : <Chip size="small" label={t("emailInboxManualInternal")} variant="outlined" />}
|
||||
{item.labelCount > 0 ? <Chip size="small" label={t("emailInboxLabels", { count: item.labelCount })} variant="outlined" /> : null}
|
||||
{item.attachmentCount > 0 ? <Chip size="small" label={t("emailInboxAttachments", { count: item.attachmentCount })} variant="outlined" /> : null}
|
||||
<Button size="small" variant="text" onClick={() => void showMessage(item)}>{selectedMessageId === item.id ? t("emailInboxHideMessage") : t("emailInboxViewMessage")}</Button>
|
||||
<Button size="small" variant="text" onClick={() => navigate(`/jobs/${item.jobApplicationId}?section=communication`)}>{t("emailInboxOpenJob")}</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"}
|
||||
{unlinkingThreadId === item.externalThreadId ? t("emailInboxUnlinking") : t("emailInboxUnlinkThread")}
|
||||
</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}
|
||||
{detailLoading ? <Box sx={{ display: "flex", gap: 1, alignItems: "center" }}><CircularProgress size={18} /><Typography variant="body2">{t("emailInboxLoadingMessage")}</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 sx={{ fontWeight: 800 }}>{messageDetail.subject || t("emailInboxNoSubject")}</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" />)}
|
||||
{messageDetail.attachments.map((attachment, index) => <Chip key={`${attachment.fileName || "attachment"}-${index}`} size="small" label={attachment.fileName || t("emailInboxAttachment")} 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>
|
||||
<Button sx={{ mt: 1.5 }} variant="outlined" onClick={() => void startReply(item)}>{t("emailInboxReply")}</Button>
|
||||
) : item.provider && item.provider !== "manual" ? (
|
||||
<Alert severity="info" sx={{ mt: 1.5 }}>Reconnect this provider with send access to reply from JobTracker.</Alert>
|
||||
<Alert severity="info" sx={{ mt: 1.5 }}>{t("emailInboxReconnectToReply")}</Alert>
|
||||
) : null}
|
||||
</> : null}
|
||||
</Box>
|
||||
@@ -737,7 +739,7 @@ export default function CorrespondenceInboxPage() {
|
||||
count={inboxTotalPages}
|
||||
onChange={(_, value) => setInboxPage(value)}
|
||||
color="primary"
|
||||
aria-label="Correspondence pages"
|
||||
aria-label={t("emailInboxPages")}
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user