fix(i18n): localize job email workflows

This commit is contained in:
cesnimda
2026-08-29 22:45:49 +02:00
parent ee8eacf0de
commit 3c9b22c97e
8 changed files with 469 additions and 144 deletions
+24 -17
View File
@@ -357,7 +357,7 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr
try {
await api.post(`/jobapplications/${response.data.id}/generate-tailored-cv-draft`);
} catch (error: any) {
toast(getApiErrorMessage(error, "Job created, but the tailored CV could not be generated."), "warning");
toast(getApiErrorMessage(error, t("addJobModalTailoredCvFailed")), "warning");
}
}
@@ -387,7 +387,14 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr
};
const canSave = normalizedCompanyName.length > 0 && jobTitle.trim().length > 0;
const steps = ["Add job", "Review details", "CV", "Cover letter", "Portfolio", "Additional files"];
const steps = [
t("addJobModalStepAddJob"),
t("addJobModalStepReview"),
t("addJobModalResume"),
t("addJobModalCoverLetter"),
t("addJobModalPortfolio"),
t("addJobModalStepAdditionalFiles"),
];
const optionalStep = activeStep >= 2 && activeStep <= 5;
const setFilesForBucket = (bucket: AttachmentBucketKey, files: FileList | null) => {
@@ -438,14 +445,14 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr
{steps.map((label) => <Step key={label}><StepLabel>{label}</StepLabel></Step>)}
</Stepper>
<Typography variant="subtitle2" sx={{ display: { xs: "block", sm: "none" }, mb: 2 }}>
Step {activeStep + 1} of {steps.length}: {steps[activeStep]}
{t("addJobModalStepProgress", { current: activeStep + 1, total: steps.length, step: steps[activeStep] })}
</Typography>
{activeStep === 0 ? <Box sx={{ display: "grid", gap: 2 }}>
<Box>
<Typography variant="h6" sx={{ fontWeight: 900 }}>Start with the job advert</Typography>
<Typography variant="h6" sx={{ fontWeight: 900 }}>{t("addJobModalStartWithAdvert")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.5 }}>
Paste a job URL and Jobbjakt will try to fill the details. You can continue with manual entry whenever extraction is unavailable.
{t("addJobModalStartWithAdvertBody")}
</Typography>
</Box>
<TextField label={t("addJobModalJobUrl")} value={jobUrl} onChange={(e) => setJobUrl(e.target.value)} fullWidth sx={FIELD_SX} />
@@ -453,7 +460,7 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr
<Button variant="contained" onClick={() => void importFromUrl()} disabled={importing || !jobUrl.trim()}>
{importing ? t("addJobModalImporting") : t("addJobModalImportFromUrl")}
</Button>
<Button onClick={() => setActiveStep(1)}>Enter details manually</Button>
<Button onClick={() => setActiveStep(1)}>{t("addJobModalEnterManually")}</Button>
</Box>
</Box> : null}
@@ -570,7 +577,7 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr
rows={6}
value={description}
onChange={(e) => setDescription(e.target.value)}
helperText={`${description.length} characters`}
helperText={t("characterCount", { count: description.length })}
sx={{ gridColumn: "1 / -1" }}
/>
@@ -581,7 +588,7 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr
rows={6}
value={translatedDescription}
onChange={(e) => setTranslatedDescription(e.target.value)}
helperText={`${translatedDescription.length} characters`}
helperText={t("characterCount", { count: translatedDescription.length })}
sx={{ gridColumn: "1 / -1" }}
/>
) : null}
@@ -594,7 +601,7 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr
sx={{ gridColumn: "1 / -1" }}
/>
<TextField label={t("addJobModalNotes")} multiline rows={3} value={notes} onChange={(e) => setNotes(e.target.value)} helperText={`${notes.length} characters`} sx={{ gridColumn: "1 / -1" }} />
<TextField label={t("addJobModalNotes")} multiline rows={3} value={notes} onChange={(e) => setNotes(e.target.value)} helperText={t("characterCount", { count: notes.length })} sx={{ gridColumn: "1 / -1" }} />
</Box>
</> : null}
@@ -603,15 +610,15 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr
<Box>
<Typography variant="h6" sx={{ fontWeight: 900 }}>{steps[activeStep]}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.5 }}>
{activeStep === 2 ? "Use your master CV later in Career Workspace, or attach the version prepared for this application now." : null}
{activeStep === 3 ? "Attach a drafted cover letter now, or add/generate one from the application workspace later." : null}
{activeStep === 4 ? "Add supporting portfolio material now, or select lightweight profile-linked projects later." : null}
{activeStep === 5 ? "Certificates, references, and other supporting documents are optional." : null}
{activeStep === 2 ? t("addJobModalCvStepHelp") : null}
{activeStep === 3 ? t("addJobModalCoverLetterStepHelp") : null}
{activeStep === 4 ? t("addJobModalPortfolioStepHelp") : null}
{activeStep === 5 ? t("addJobModalOtherFilesStepHelp") : null}
</Typography>
</Box>
{activeStep === 2 ? <>
<FormControlLabel control={<Checkbox disabled={!canUseAi} checked={generateTailoredCv && canUseAi} onChange={(event) => setGenerateTailoredCv(event.target.checked)} />} label={canUseAi ? "Generate a tailored CV draft after creating this job" : "Tailored CV generation requires Pro"} />
<Typography variant="caption" sx={{ color: "text.secondary", mt: -1 }}>{canUseAi ? "Uses your reviewed Career Profile and keeps the result as an editable suggestion." : "Create and track the job normally; no AI operation will be started."}</Typography>
<FormControlLabel control={<Checkbox disabled={!canUseAi} checked={generateTailoredCv && canUseAi} onChange={(event) => setGenerateTailoredCv(event.target.checked)} />} label={canUseAi ? t("addJobModalGenerateTailoredCv") : t("addJobModalTailoredCvRequiresPro")} />
<Typography variant="caption" sx={{ color: "text.secondary", mt: -1 }}>{canUseAi ? t("addJobModalTailoredCvHelp") : t("addJobModalNoAiHelp")}</Typography>
{uploadField("resume", t("addJobModalResume"), t("addJobModalResumeHelp"))}
</> : null}
{activeStep === 3 ? uploadField("coverLetter", t("addJobModalCoverLetter"), t("addJobModalCoverLetterHelp")) : null}
@@ -626,9 +633,9 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr
<DialogActions sx={{ px: 3, py: 2, justifyContent: "space-between", flexWrap: "wrap", gap: 1.5 }}>
<Button variant="outlined" onClick={onClose}>{t("close")}</Button>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
{activeStep > 0 ? <Button onClick={() => setActiveStep((step) => step - 1)} disabled={saving}>Back</Button> : null}
{activeStep > 0 ? <Button onClick={() => setActiveStep((step) => step - 1)} disabled={saving}>{t("back")}</Button> : null}
{activeStep < steps.length - 1 ? <Button variant="contained" onClick={() => setActiveStep((step) => step + 1)} disabled={saving || (activeStep === 1 && !canSave)}>
{optionalStep ? "Skip and continue" : "Continue"}
{optionalStep ? t("skipAndContinue") : t("continue")}
</Button> : <>
<Button variant="outlined" onClick={() => void createJob(true)} disabled={saving || !canSave}>
{saving ? t("rulesSaving") : t("createAndAddAnother")}
@@ -66,15 +66,15 @@ export default function AiUsageCard() {
</Stack>
{usage.monthlyCallLimit === 0 ? <Alert severity="info" sx={{ mb: 2 }}>{t("settingsUsageNoAi")}</Alert> : <><Box sx={{ mb: 2 }}>
<Typography variant="body2">{t("settingsUsageGenerations", { used: usage.currentMonth.calls.toLocaleString(), limit: usage.monthlyCallLimit.toLocaleString() })}</Typography>
<LinearProgress variant="determinate" value={callsPercent} aria-label="Monthly AI generations used" sx={{ mt: 0.75, height: 7, borderRadius: 99 }} />
<LinearProgress variant="determinate" value={callsPercent} aria-label={t("settingsUsageGenerationsProgress")} sx={{ mt: 0.75, height: 7, borderRadius: 99 }} />
</Box>
<Box>
<Typography variant="body2">{t("settingsUsageTokens", { used: usage.currentMonth.estimatedTokens.toLocaleString(), limit: usage.monthlyTokenLimit.toLocaleString() })}</Typography>
<LinearProgress variant="determinate" value={tokensPercent} aria-label="Monthly AI tokens used" sx={{ mt: 0.75, height: 7, borderRadius: 99 }} />
<LinearProgress variant="determinate" value={tokensPercent} aria-label={t("settingsUsageTokensProgress")} sx={{ mt: 0.75, height: 7, borderRadius: 99 }} />
</Box></>}
<Box sx={{ mt: 2 }}>
<Typography variant="body2">{t("settingsUsageStorage", { used: formatBytes(usage.storageUsedBytes), limit: formatBytes(usage.storageLimitBytes) })}</Typography>
<LinearProgress variant="determinate" value={storagePercent} aria-label="Attachment storage used" sx={{ mt: 0.75, height: 7, borderRadius: 99 }} />
<LinearProgress variant="determinate" value={storagePercent} aria-label={t("settingsUsageStorageProgress")} sx={{ mt: 0.75, height: 7, borderRadius: 99 }} />
</Box>
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 1.5 }}>{t("settingsUsageReset")}</Typography>
{billing?.canCheckout ? <Button sx={{ mt: 2 }} variant="contained" disabled={billingBusy} onClick={() => void openBilling("checkout")}>{t("settingsBillingUpgrade")}</Button> : null}
@@ -78,31 +78,6 @@ function parseRawEmail(raw: string): { subject?: string; date?: string; from?: s
return { subject: headers["subject"], from: headers["from"], to: headers["to"], date: iso, body };
}
function formatConfidence(value: string) {
return value ? `${value[0].toUpperCase()}${value.slice(1)} confidence` : "Confidence unknown";
}
function formatReasonLabel(label: string) {
switch (label) {
case "company":
return "Company";
case "recruiterEmail":
return "Recruiter email";
case "recruiter":
return "Recruiter";
case "jobTitle":
return "Job title";
case "existingSubject":
return "Existing subject";
case "recency":
return "Recent";
case "status":
return "Status";
default:
return label;
}
}
type JobChoice = { id: number; jobTitle: string; companyName: string };
export type CorrespondenceJobContext = {
@@ -114,6 +89,12 @@ export type CorrespondenceJobContext = {
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();
@@ -121,18 +102,34 @@ export function buildSuggestedCorrespondenceQueries(
const jobTitle = context.jobTitle?.trim();
return [
recruiterEmail ? { label: "Recruiter mailbox", value: `(from:${recruiterEmail} OR to:${recruiterEmail}) newer_than:365d` } : null,
companyName && jobTitle ? { label: "Company + role", value: `"${companyName}" "${jobTitle}" newer_than:365d` } : null,
companyName ? { label: "Company mail", value: `"${companyName}" (application OR interview OR recruiter) newer_than:365d` } : null,
...uniqueSubjects.map((subject) => ({ label: `Subject: ${subject}`, value: `subject:"${subject}" newer_than:365d` })),
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 { t } = useI18n();
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 [from, setFrom] = useState<"Me" | "Company">("Me");
const [text, setText] = useState("");
@@ -189,7 +186,7 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
});
setGmailMatches(res.data);
} catch (error: any) {
toast(getApiErrorMessage(error, "Failed to load Gmail suggestions."), "error");
toast(getApiErrorMessage(error, t("correspondenceLoadSuggestionsFailed")), "error");
} finally {
setGmailMatchesLoading(false);
}
@@ -227,26 +224,26 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
if (mode === "manual") {
if (res.data.imported > 0) {
toast(`Imported ${res.data.imported} new Gmail message${res.data.imported === 1 ? "" : "s"}.`, "success");
toast(t(res.data.imported === 1 ? "correspondenceImportedNewMessage" : "correspondenceImportedNewMessages", { count: res.data.imported }), "success");
} else if (res.data.hasLinkedThreads) {
toast("Linked Gmail threads are already current.", "success");
toast(t("correspondenceLinkedThreadsCurrent"), "success");
} else {
toast("This job does not have any linked Gmail threads yet.", "success");
toast(t("correspondenceNoLinkedThreadsForJob"), "success");
}
} else if (res.data.imported > 0) {
toast(`Linked Gmail threads imported ${res.data.imported} new message${res.data.imported === 1 ? "" : "s"}.`, "success");
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, "Failed to refresh linked Gmail threads."), "error");
toast(getApiErrorMessage(error, t("correspondenceRefreshLinkedFailed")), "error");
}
return null;
} finally {
setLinkedThreadRefreshLoading(false);
}
}, [gmailQuery, gmailStatus?.connected, importOpen, importTab, jobId, linkedThreadIds.length, load, loadGmailMatches, loadGmailStatus, toast]);
}, [gmailQuery, gmailStatus?.connected, importOpen, importTab, jobId, linkedThreadIds.length, load, loadGmailMatches, loadGmailStatus, t, toast]);
useEffect(() => {
void load();
@@ -305,7 +302,12 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
const canSend = useMemo(() => text.trim().length > 0, [text]);
const suggestedQueries = buildSuggestedCorrespondenceQueries(jobContext, messages.map((message) => message.subject));
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;
@@ -314,7 +316,7 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
setText("");
await load();
} catch (error) {
toast(getApiErrorMessage(error, "Failed to add message."), "error");
toast(getApiErrorMessage(error, t("correspondenceAddFailed")), "error");
}
};
@@ -387,7 +389,7 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
if (res.data.imported > 0) {
toast(t("correspondenceImportEmail"), "success");
} else {
toast("This Gmail message is already linked to the job.", "success");
toast(t("correspondenceAlreadyLinkedToJob"), "success");
}
} catch (error: any) {
toast(getApiErrorMessage(error, t("correspondenceImportGmailFailed")), "error");
@@ -430,9 +432,9 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
await load();
await loadGmailMatches(gmailQuery);
setManageThreadId(null);
toast(`Unlinked ${res.data.removedMessages} message${res.data.removedMessages === 1 ? "" : "s"} from this job.`, "success");
toast(t(res.data.removedMessages === 1 ? "correspondenceUnlinkedMessage" : "correspondenceUnlinkedMessages", { count: res.data.removedMessages }), "success");
} catch (error: any) {
toast(getApiErrorMessage(error, "Failed to unlink the Gmail thread."), "error");
toast(getApiErrorMessage(error, t("correspondenceUnlinkThreadFailed")), "error");
} finally {
setManageSaving(false);
}
@@ -452,9 +454,9 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
await loadGmailMatches(gmailQuery);
setManageThreadId(null);
const targetJob = availableJobs.find((item) => item.id === manageTargetJobId);
toast(`Moved thread to ${targetJob?.companyName || targetJob?.jobTitle || `job ${res.data.jobApplicationId}`}.`, "success");
toast(t("correspondenceMovedThread", { job: targetJob?.companyName || targetJob?.jobTitle || t("correspondenceJobNumber", { id: res.data.jobApplicationId }) }), "success");
} catch (error: any) {
toast(getApiErrorMessage(error, "Failed to move the Gmail thread."), "error");
toast(getApiErrorMessage(error, t("correspondenceMoveThreadFailed")), "error");
} finally {
setManageSaving(false);
}
@@ -477,11 +479,11 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
<Typography sx={{ whiteSpace: "pre-wrap", lineHeight: 1.35 }}>{m.content}</Typography>
{(m.externalThreadId || m.externalFrom || m.externalTo || m.externalLabelsJson || m.attachmentMetadataJson) ? (
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 1 }}>
{m.externalThreadId ? <Chip size="small" label={`Thread ${m.externalThreadId}`} variant="outlined" /> : null}
{m.externalFrom ? <Chip size="small" label={`From ${m.externalFrom}`} variant="outlined" /> : null}
{m.externalTo ? <Chip size="small" label={`To ${m.externalTo}`} variant="outlined" /> : null}
{m.externalLabelsJson ? <Chip size="small" label={`${JSON.parse(m.externalLabelsJson).length} Gmail label${JSON.parse(m.externalLabelsJson).length === 1 ? "" : "s"}`} variant="outlined" /> : null}
{m.attachmentMetadataJson ? <Chip size="small" label={`${JSON.parse(m.attachmentMetadataJson).length} attachment${JSON.parse(m.attachmentMetadataJson).length === 1 ? "" : "s"}`} variant="outlined" /> : null}
{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 }}>
@@ -501,16 +503,16 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
</Paper>
<Box sx={{ mt: 1.5, p: 1.5, borderRadius: 3, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", backgroundColor: "background.default" }}>
<Typography variant="overline">Linked Gmail thread continuity</Typography>
<Typography variant="overline">{t("correspondenceLinkedContinuity")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.25 }}>
Linked Gmail refresh only checks threads that are already tied to this job, so new correspondence can appear here without re-importing the whole thread.
{t("correspondenceLinkedContinuityHelp")}
</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Chip size="small" color={gmailStatus?.connected ? "success" : "default"} variant="outlined" label={gmailStatus?.connected ? "Gmail connected" : "Gmail not connected"} />
<Chip size="small" color={linkedThreadIds.length > 0 ? "success" : "default"} variant="outlined" label={linkedThreadIds.length > 0 ? `Linked threads: ${linkedThreadIds.length}` : "No linked threads yet"} />
<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)}>
Manage {threadId}
{t("correspondenceManageThread", { id: threadId })}
</Button>
))}
{gmailStatus?.lastSyncStatus ? (
@@ -519,8 +521,8 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
color={gmailStatus.lastSyncStatus === "success" ? "success" : "warning"}
variant="outlined"
label={gmailStatus.lastSyncStatus === "success"
? `Last Gmail sync ${gmailStatus.lastSyncMode || "sync"} ok`
: `Last Gmail sync ${gmailStatus.lastSyncMode || "sync"} failed`}
? t("correspondenceLastSyncOk", { mode: gmailStatus.lastSyncMode || t("correspondenceSync") })
: t("correspondenceLastSyncFailed", { mode: gmailStatus.lastSyncMode || t("correspondenceSync") })}
/>
) : null}
{linkedThreadRefresh ? (
@@ -528,16 +530,16 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
size="small"
variant="outlined"
label={linkedThreadRefresh.imported > 0
? `Last linked refresh imported ${linkedThreadRefresh.imported} new message${linkedThreadRefresh.imported === 1 ? "" : "s"}`
? t(linkedThreadRefresh.imported === 1 ? "correspondenceLastRefreshImportedMessage" : "correspondenceLastRefreshImportedMessages", { count: linkedThreadRefresh.imported })
: linkedThreadRefresh.hasLinkedThreads
? `Last linked refresh checked ${linkedThreadRefresh.threadsChecked} linked thread${linkedThreadRefresh.threadsChecked === 1 ? "" : "s"}`
: "No linked Gmail refresh history yet"}
? t(linkedThreadRefresh.threadsChecked === 1 ? "correspondenceLastRefreshCheckedThread" : "correspondenceLastRefreshCheckedThreads", { count: linkedThreadRefresh.threadsChecked })
: t("correspondenceNoRefreshHistory")}
/>
) : null}
</Box>
{gmailStatus?.lastSyncError ? (
<Typography variant="body2" sx={{ color: "warning.main", mt: 1 }}>
Latest Gmail sync issue: {gmailStatus.lastSyncError}
{t("correspondenceLatestSyncIssue", { error: gmailStatus.lastSyncError })}
</Typography>
) : null}
</Box>
@@ -556,19 +558,19 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
</Box>
<Dialog open={Boolean(manageThreadId)} onClose={() => setManageThreadId(null)} fullWidth maxWidth="sm">
<DialogTitle>Manage linked Gmail thread</DialogTitle>
<DialogTitle>{t("correspondenceManageLinkedThread")}</DialogTitle>
<DialogContent sx={{ display: "flex", flexDirection: "column", gap: 2, pt: 1 }}>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
Unlink this thread from the current job, or move it to another existing job.
{t("correspondenceManageLinkedThreadHelp")}
</Typography>
{manageThreadId ? <Chip label={`Thread ${manageThreadId}`} variant="outlined" sx={{ width: "fit-content" }} /> : null}
{manageThreadId ? <Chip label={t("correspondenceThread", { id: manageThreadId })} variant="outlined" sx={{ width: "fit-content" }} /> : null}
<TextField
label="Review note"
label={t("correspondenceReviewNote")}
value={manageNote}
onChange={(event) => setManageNote(event.target.value)}
multiline
minRows={2}
placeholder="Why this thread should stay in review or move to another job."
placeholder={t("correspondenceReviewNotePlaceholder")}
/>
<Autocomplete
options={availableJobs}
@@ -579,14 +581,14 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
}}
onChange={(_, value) => setManageTargetJobId(value?.id ?? jobId)}
isOptionEqualToValue={(option, value) => option.id === value.id}
getOptionLabel={(item) => `${item.companyName || "Unknown company"}${item.jobTitle}`}
renderInput={(params) => <TextField {...params} label="Move to job" placeholder="Search every job" />}
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}>Close</Button>
<Button color="warning" variant="outlined" onClick={() => void unlinkThread()} disabled={manageSaving || !manageThreadId}>Unlink from this job</Button>
<Button variant="contained" onClick={() => void relinkThread()} disabled={manageSaving || !manageThreadId || manageTargetJobId === jobId}>Move thread</Button>
<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>
@@ -613,7 +615,7 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
</Typography>
{jobContext.companyName || jobContext.jobTitle ? (
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.5 }}>
Matching against {jobContext.companyName || "this company"} / {jobContext.jobTitle || "this role"}
{t("correspondenceMatchingAgainst", { company: jobContext.companyName || t("correspondenceThisCompany"), role: jobContext.jobTitle || t("correspondenceThisRole") })}
</Typography>
) : null}
</Box>
@@ -622,7 +624,7 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
<>
{linkedThreadIds.length > 0 ? (
<Button variant="outlined" onClick={() => void refreshLinkedThreads("manual")} disabled={linkedThreadRefreshLoading}>
{linkedThreadRefreshLoading ? "Refreshing linked threads..." : "Refresh linked threads"}
{linkedThreadRefreshLoading ? t("correspondenceRefreshingLinkedThreads") : t("correspondenceRefreshLinkedThreads")}
</Button>
) : null}
<Button variant="outlined" onClick={() => void loadGmailMatches(gmailQuery)} disabled={gmailMatchesLoading}>{t("correspondenceRefresh")}</Button>
@@ -653,20 +655,20 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
</Box>
) : null}
{gmailStatus.lastSyncedAt ? <Chip label={t("correspondenceLastSynced", { date: new Date(gmailStatus.lastSyncedAt).toLocaleString() })} size="small" /> : null}
{gmailStatus.lastSyncAttemptedAt ? <Chip label={`Sync checked ${new Date(gmailStatus.lastSyncAttemptedAt).toLocaleString()}`} size="small" variant="outlined" /> : null}
{gmailStatus.lastSyncStatus === "error" && gmailStatus.lastSyncError ? <Chip label={`Sync issue: ${gmailStatus.lastSyncError}`} size="small" color="warning" variant="outlined" /> : null}
{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={`Linked threads: ${linkedThreadIds.length}`} />
<Chip size="small" color="success" variant="outlined" label={t("correspondenceLinkedThreads", { count: linkedThreadIds.length })} />
{linkedThreadRefresh ? (
<Chip
size="small"
variant="outlined"
label={linkedThreadRefresh.imported > 0
? `Last refresh imported ${linkedThreadRefresh.imported} new message${linkedThreadRefresh.imported === 1 ? "" : "s"}`
? t(linkedThreadRefresh.imported === 1 ? "correspondenceLastRefreshImportedMessage" : "correspondenceLastRefreshImportedMessages", { count: linkedThreadRefresh.imported })
: linkedThreadRefresh.hasLinkedThreads
? `Last refresh checked ${linkedThreadRefresh.threadsChecked} linked thread${linkedThreadRefresh.threadsChecked === 1 ? "" : "s"}`
: "No linked Gmail threads yet"}
? t(linkedThreadRefresh.threadsChecked === 1 ? "correspondenceLastRefreshCheckedThread" : "correspondenceLastRefreshCheckedThreads", { count: linkedThreadRefresh.threadsChecked })
: t("correspondenceNoLinkedThreadsForJob")}
/>
) : null}
</Box>
@@ -676,7 +678,7 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
<Box sx={{ py: 5, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box>
) : !gmailMatches || gmailMatches.threads.length === 0 ? (
<Typography sx={{ color: "text.secondary", p: 2 }}>
{gmailQuery.trim() ? "No Gmail matches for this job and search override yet." : t("correspondenceNoGmailMessages")}
{gmailQuery.trim() ? t("correspondenceNoMatchesForOverride") : t("correspondenceNoGmailMessages")}
</Typography>
) : (
<List disablePadding>
@@ -688,9 +690,9 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
<Box>
<Typography sx={{ fontWeight: 800 }}>{thread.subject || t("correspondenceNoSubject")}</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.75 }}>
<Chip size="small" label={`${formatConfidence(thread.confidence)} · score ${thread.score}`} color={thread.confidence === "high" ? "success" : thread.confidence === "medium" ? "warning" : "default"} />
<Chip size="small" label={`${thread.messageCount} message${thread.messageCount === 1 ? "" : "s"}`} variant="outlined" />
{thread.hasImportedMessages ? <Chip size="small" label="Already linked" variant="outlined" color="success" /> : null}
<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) => (
@@ -713,8 +715,8 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
<Typography component="span" variant="body2" sx={{ color: "text.primary", display: "block" }}>{t("correspondenceFromLabel", { value: message.from || t("correspondenceUnknown") })}</Typography>
<Typography component="span" variant="body2" sx={{ color: "text.secondary", mt: 0.25, display: "block" }}>{message.snippet}</Typography>
<Box component="span" sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 1 }}>
<Chip size="small" label={`${formatConfidence(message.confidence)} · score ${message.score}`} variant="outlined" />
{message.alreadyImported ? <Chip size="small" label="Already linked" color="success" variant="outlined" /> : null}
<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" />
))}