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
+1
View File
@@ -89,6 +89,7 @@ Updated: 2026-08-29
- Public landing focused verification: 2 suites, 3/3 passed, including live switching to Bokmål and preservation of protected-route sign-in intent; TypeScript passed. - Public landing focused verification: 2 suites, 3/3 passed, including live switching to Bokmål and preservation of protected-route sign-in intent; TypeScript passed.
- CV Builder focused verification: 12/12 passed; TypeScript passed after the final format-label pass. - CV Builder focused verification: 12/12 passed; TypeScript passed after the final format-label pass.
- Admin System focused verification: 3/3 passed, including Bokmål benchmark diagnostics that preserve runtime values; TypeScript passed. - Admin System focused verification: 3/3 passed, including Bokmål benchmark diagnostics that preserve runtime values; TypeScript passed.
- Job creation and Gmail workflow localization verification: 4 focused suites, 11/11 passed; TypeScript passed. The add-job stepper, Gmail continuity management, review queue, usage progress labels, errors and status copy now switch between English and Bokmål without translating user/job/email content.
- Backend matcher/intelligence focused verification: 35/35 passed, including detection of a manually created Norwegian advert with no saved translation. - Backend matcher/intelligence focused verification: 35/35 passed, including detection of a manually created Norwegian advert with no saved translation.
- Full backend: 712/712 tests passed on .NET 9. - Full backend: 712/712 tests passed on .NET 9.
- Next optimized production build and TypeScript: passed after the Job Workspace/checklist batch. - Next optimized production build and TypeScript: passed after the Job Workspace/checklist batch.
+24 -17
View File
@@ -357,7 +357,7 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr
try { try {
await api.post(`/jobapplications/${response.data.id}/generate-tailored-cv-draft`); await api.post(`/jobapplications/${response.data.id}/generate-tailored-cv-draft`);
} catch (error: any) { } 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 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 optionalStep = activeStep >= 2 && activeStep <= 5;
const setFilesForBucket = (bucket: AttachmentBucketKey, files: FileList | null) => { 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>)} {steps.map((label) => <Step key={label}><StepLabel>{label}</StepLabel></Step>)}
</Stepper> </Stepper>
<Typography variant="subtitle2" sx={{ display: { xs: "block", sm: "none" }, mb: 2 }}> <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> </Typography>
{activeStep === 0 ? <Box sx={{ display: "grid", gap: 2 }}> {activeStep === 0 ? <Box sx={{ display: "grid", gap: 2 }}>
<Box> <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 }}> <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> </Typography>
</Box> </Box>
<TextField label={t("addJobModalJobUrl")} value={jobUrl} onChange={(e) => setJobUrl(e.target.value)} fullWidth sx={FIELD_SX} /> <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()}> <Button variant="contained" onClick={() => void importFromUrl()} disabled={importing || !jobUrl.trim()}>
{importing ? t("addJobModalImporting") : t("addJobModalImportFromUrl")} {importing ? t("addJobModalImporting") : t("addJobModalImportFromUrl")}
</Button> </Button>
<Button onClick={() => setActiveStep(1)}>Enter details manually</Button> <Button onClick={() => setActiveStep(1)}>{t("addJobModalEnterManually")}</Button>
</Box> </Box>
</Box> : null} </Box> : null}
@@ -570,7 +577,7 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr
rows={6} rows={6}
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
helperText={`${description.length} characters`} helperText={t("characterCount", { count: description.length })}
sx={{ gridColumn: "1 / -1" }} sx={{ gridColumn: "1 / -1" }}
/> />
@@ -581,7 +588,7 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr
rows={6} rows={6}
value={translatedDescription} value={translatedDescription}
onChange={(e) => setTranslatedDescription(e.target.value)} onChange={(e) => setTranslatedDescription(e.target.value)}
helperText={`${translatedDescription.length} characters`} helperText={t("characterCount", { count: translatedDescription.length })}
sx={{ gridColumn: "1 / -1" }} sx={{ gridColumn: "1 / -1" }}
/> />
) : null} ) : null}
@@ -594,7 +601,7 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr
sx={{ gridColumn: "1 / -1" }} 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> </Box>
</> : null} </> : null}
@@ -603,15 +610,15 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr
<Box> <Box>
<Typography variant="h6" sx={{ fontWeight: 900 }}>{steps[activeStep]}</Typography> <Typography variant="h6" sx={{ fontWeight: 900 }}>{steps[activeStep]}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.5 }}> <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 === 2 ? t("addJobModalCvStepHelp") : null}
{activeStep === 3 ? "Attach a drafted cover letter now, or add/generate one from the application workspace later." : null} {activeStep === 3 ? t("addJobModalCoverLetterStepHelp") : null}
{activeStep === 4 ? "Add supporting portfolio material now, or select lightweight profile-linked projects later." : null} {activeStep === 4 ? t("addJobModalPortfolioStepHelp") : null}
{activeStep === 5 ? "Certificates, references, and other supporting documents are optional." : null} {activeStep === 5 ? t("addJobModalOtherFilesStepHelp") : null}
</Typography> </Typography>
</Box> </Box>
{activeStep === 2 ? <> {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"} /> <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 ? "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> <Typography variant="caption" sx={{ color: "text.secondary", mt: -1 }}>{canUseAi ? t("addJobModalTailoredCvHelp") : t("addJobModalNoAiHelp")}</Typography>
{uploadField("resume", t("addJobModalResume"), t("addJobModalResumeHelp"))} {uploadField("resume", t("addJobModalResume"), t("addJobModalResumeHelp"))}
</> : null} </> : null}
{activeStep === 3 ? uploadField("coverLetter", t("addJobModalCoverLetter"), t("addJobModalCoverLetterHelp")) : 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 }}> <DialogActions sx={{ px: 3, py: 2, justifyContent: "space-between", flexWrap: "wrap", gap: 1.5 }}>
<Button variant="outlined" onClick={onClose}>{t("close")}</Button> <Button variant="outlined" onClick={onClose}>{t("close")}</Button>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}> <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)}> {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> : <>
<Button variant="outlined" onClick={() => void createJob(true)} disabled={saving || !canSave}> <Button variant="outlined" onClick={() => void createJob(true)} disabled={saving || !canSave}>
{saving ? t("rulesSaving") : t("createAndAddAnother")} {saving ? t("rulesSaving") : t("createAndAddAnother")}
@@ -66,15 +66,15 @@ export default function AiUsageCard() {
</Stack> </Stack>
{usage.monthlyCallLimit === 0 ? <Alert severity="info" sx={{ mb: 2 }}>{t("settingsUsageNoAi")}</Alert> : <><Box sx={{ mb: 2 }}> {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> <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>
<Box> <Box>
<Typography variant="body2">{t("settingsUsageTokens", { used: usage.currentMonth.estimatedTokens.toLocaleString(), limit: usage.monthlyTokenLimit.toLocaleString() })}</Typography> <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></>}
<Box sx={{ mt: 2 }}> <Box sx={{ mt: 2 }}>
<Typography variant="body2">{t("settingsUsageStorage", { used: formatBytes(usage.storageUsedBytes), limit: formatBytes(usage.storageLimitBytes) })}</Typography> <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> </Box>
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 1.5 }}>{t("settingsUsageReset")}</Typography> <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} {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 }; 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 }; type JobChoice = { id: number; jobTitle: string; companyName: string };
export type CorrespondenceJobContext = { export type CorrespondenceJobContext = {
@@ -114,6 +89,12 @@ export type CorrespondenceJobContext = {
export function buildSuggestedCorrespondenceQueries( export function buildSuggestedCorrespondenceQueries(
context: CorrespondenceJobContext, context: CorrespondenceJobContext,
subjects: Array<string | null | undefined>, 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 uniqueSubjects = Array.from(new Set(subjects.filter(Boolean) as string[])).slice(0, 2);
const companyName = context.companyName?.trim(); const companyName = context.companyName?.trim();
@@ -121,18 +102,34 @@ export function buildSuggestedCorrespondenceQueries(
const jobTitle = context.jobTitle?.trim(); const jobTitle = context.jobTitle?.trim();
return [ return [
recruiterEmail ? { label: "Recruiter mailbox", value: `(from:${recruiterEmail} OR to:${recruiterEmail}) newer_than:365d` } : null, recruiterEmail ? { label: labels.recruiterMailbox, value: `(from:${recruiterEmail} OR to:${recruiterEmail}) newer_than:365d` } : null,
companyName && jobTitle ? { label: "Company + role", value: `"${companyName}" "${jobTitle}" newer_than:365d` } : null, companyName && jobTitle ? { label: labels.companyAndRole, value: `"${companyName}" "${jobTitle}" newer_than:365d` } : null,
companyName ? { label: "Company mail", value: `"${companyName}" (application OR interview OR recruiter) newer_than:365d` } : null, companyName ? { label: labels.companyMail, value: `"${companyName}" (application OR interview OR recruiter) newer_than:365d` } : null,
...uniqueSubjects.map((subject) => ({ label: `Subject: ${subject}`, value: `subject:"${subject}" newer_than:365d` })), ...uniqueSubjects.map((subject) => ({ label: `${labels.subject}: ${subject}`, value: `subject:"${subject}" newer_than:365d` })),
].filter(Boolean).slice(0, 6) as Array<{ label: string; value: string }>; ].filter(Boolean).slice(0, 6) as Array<{ label: string; value: string }>;
} }
export default function Correspondence({ jobId, jobContext }: { jobId: number; jobContext: CorrespondenceJobContext }) { export default function Correspondence({ jobId, jobContext }: { jobId: number; jobContext: CorrespondenceJobContext }) {
const theme = useTheme(); const theme = useTheme();
const { toast } = useToast(); const { toast } = useToast();
const { t } = useI18n(); const { language, t } = useI18n();
const { confirmAction } = useDialogActions(); 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 [messages, setMessages] = useState<CorrespondenceMessage[]>([]);
const [from, setFrom] = useState<"Me" | "Company">("Me"); const [from, setFrom] = useState<"Me" | "Company">("Me");
const [text, setText] = useState(""); const [text, setText] = useState("");
@@ -189,7 +186,7 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
}); });
setGmailMatches(res.data); setGmailMatches(res.data);
} catch (error: any) { } catch (error: any) {
toast(getApiErrorMessage(error, "Failed to load Gmail suggestions."), "error"); toast(getApiErrorMessage(error, t("correspondenceLoadSuggestionsFailed")), "error");
} finally { } finally {
setGmailMatchesLoading(false); setGmailMatchesLoading(false);
} }
@@ -227,26 +224,26 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
if (mode === "manual") { if (mode === "manual") {
if (res.data.imported > 0) { 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) { } else if (res.data.hasLinkedThreads) {
toast("Linked Gmail threads are already current.", "success"); toast(t("correspondenceLinkedThreadsCurrent"), "success");
} else { } else {
toast("This job does not have any linked Gmail threads yet.", "success"); toast(t("correspondenceNoLinkedThreadsForJob"), "success");
} }
} else if (res.data.imported > 0) { } 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; return res.data;
} catch (error: any) { } catch (error: any) {
if (mode === "manual") { if (mode === "manual") {
toast(getApiErrorMessage(error, "Failed to refresh linked Gmail threads."), "error"); toast(getApiErrorMessage(error, t("correspondenceRefreshLinkedFailed")), "error");
} }
return null; return null;
} finally { } finally {
setLinkedThreadRefreshLoading(false); 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(() => { useEffect(() => {
void load(); void load();
@@ -305,7 +302,12 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
const canSend = useMemo(() => text.trim().length > 0, [text]); 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 () => { const send = async () => {
if (!canSend) return; if (!canSend) return;
@@ -314,7 +316,7 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
setText(""); setText("");
await load(); await load();
} catch (error) { } 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) { if (res.data.imported > 0) {
toast(t("correspondenceImportEmail"), "success"); toast(t("correspondenceImportEmail"), "success");
} else { } else {
toast("This Gmail message is already linked to the job.", "success"); toast(t("correspondenceAlreadyLinkedToJob"), "success");
} }
} catch (error: any) { } catch (error: any) {
toast(getApiErrorMessage(error, t("correspondenceImportGmailFailed")), "error"); toast(getApiErrorMessage(error, t("correspondenceImportGmailFailed")), "error");
@@ -430,9 +432,9 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
await load(); await load();
await loadGmailMatches(gmailQuery); await loadGmailMatches(gmailQuery);
setManageThreadId(null); 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) { } catch (error: any) {
toast(getApiErrorMessage(error, "Failed to unlink the Gmail thread."), "error"); toast(getApiErrorMessage(error, t("correspondenceUnlinkThreadFailed")), "error");
} finally { } finally {
setManageSaving(false); setManageSaving(false);
} }
@@ -452,9 +454,9 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
await loadGmailMatches(gmailQuery); await loadGmailMatches(gmailQuery);
setManageThreadId(null); setManageThreadId(null);
const targetJob = availableJobs.find((item) => item.id === manageTargetJobId); 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) { } catch (error: any) {
toast(getApiErrorMessage(error, "Failed to move the Gmail thread."), "error"); toast(getApiErrorMessage(error, t("correspondenceMoveThreadFailed")), "error");
} finally { } finally {
setManageSaving(false); 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> <Typography sx={{ whiteSpace: "pre-wrap", lineHeight: 1.35 }}>{m.content}</Typography>
{(m.externalThreadId || m.externalFrom || m.externalTo || m.externalLabelsJson || m.attachmentMetadataJson) ? ( {(m.externalThreadId || m.externalFrom || m.externalTo || m.externalLabelsJson || m.attachmentMetadataJson) ? (
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 1 }}> <Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 1 }}>
{m.externalThreadId ? <Chip size="small" label={`Thread ${m.externalThreadId}`} variant="outlined" /> : null} {m.externalThreadId ? <Chip size="small" label={t("correspondenceThread", { id: m.externalThreadId })} variant="outlined" /> : null}
{m.externalFrom ? <Chip size="small" label={`From ${m.externalFrom}`} variant="outlined" /> : null} {m.externalFrom ? <Chip size="small" label={t("correspondenceFrom", { value: m.externalFrom })} variant="outlined" /> : null}
{m.externalTo ? <Chip size="small" label={`To ${m.externalTo}`} variant="outlined" /> : null} {m.externalTo ? <Chip size="small" label={t("correspondenceTo", { value: 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.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={`${JSON.parse(m.attachmentMetadataJson).length} attachment${JSON.parse(m.attachmentMetadataJson).length === 1 ? "" : "s"}`} 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> </Box>
) : null} ) : null}
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "flex-end", mt: 0.75 }}> <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> </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" }}> <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 }}> <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> </Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}> <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={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 ? `Linked threads: ${linkedThreadIds.length}` : "No linked threads yet"} /> <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) => ( {linkedThreadIds.slice(0, 6).map((threadId) => (
<Button key={threadId} size="small" variant="text" onClick={() => openManageThread(threadId)}> <Button key={threadId} size="small" variant="text" onClick={() => openManageThread(threadId)}>
Manage {threadId} {t("correspondenceManageThread", { id: threadId })}
</Button> </Button>
))} ))}
{gmailStatus?.lastSyncStatus ? ( {gmailStatus?.lastSyncStatus ? (
@@ -519,8 +521,8 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
color={gmailStatus.lastSyncStatus === "success" ? "success" : "warning"} color={gmailStatus.lastSyncStatus === "success" ? "success" : "warning"}
variant="outlined" variant="outlined"
label={gmailStatus.lastSyncStatus === "success" label={gmailStatus.lastSyncStatus === "success"
? `Last Gmail sync ${gmailStatus.lastSyncMode || "sync"} ok` ? t("correspondenceLastSyncOk", { mode: gmailStatus.lastSyncMode || t("correspondenceSync") })
: `Last Gmail sync ${gmailStatus.lastSyncMode || "sync"} failed`} : t("correspondenceLastSyncFailed", { mode: gmailStatus.lastSyncMode || t("correspondenceSync") })}
/> />
) : null} ) : null}
{linkedThreadRefresh ? ( {linkedThreadRefresh ? (
@@ -528,16 +530,16 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
size="small" size="small"
variant="outlined" variant="outlined"
label={linkedThreadRefresh.imported > 0 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 : linkedThreadRefresh.hasLinkedThreads
? `Last linked refresh checked ${linkedThreadRefresh.threadsChecked} linked thread${linkedThreadRefresh.threadsChecked === 1 ? "" : "s"}` ? t(linkedThreadRefresh.threadsChecked === 1 ? "correspondenceLastRefreshCheckedThread" : "correspondenceLastRefreshCheckedThreads", { count: linkedThreadRefresh.threadsChecked })
: "No linked Gmail refresh history yet"} : t("correspondenceNoRefreshHistory")}
/> />
) : null} ) : null}
</Box> </Box>
{gmailStatus?.lastSyncError ? ( {gmailStatus?.lastSyncError ? (
<Typography variant="body2" sx={{ color: "warning.main", mt: 1 }}> <Typography variant="body2" sx={{ color: "warning.main", mt: 1 }}>
Latest Gmail sync issue: {gmailStatus.lastSyncError} {t("correspondenceLatestSyncIssue", { error: gmailStatus.lastSyncError })}
</Typography> </Typography>
) : null} ) : null}
</Box> </Box>
@@ -556,19 +558,19 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
</Box> </Box>
<Dialog open={Boolean(manageThreadId)} onClose={() => setManageThreadId(null)} fullWidth maxWidth="sm"> <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 }}> <DialogContent sx={{ display: "flex", flexDirection: "column", gap: 2, pt: 1 }}>
<Typography variant="body2" sx={{ color: "text.secondary" }}> <Typography variant="body2" sx={{ color: "text.secondary" }}>
Unlink this thread from the current job, or move it to another existing job. {t("correspondenceManageLinkedThreadHelp")}
</Typography> </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 <TextField
label="Review note" label={t("correspondenceReviewNote")}
value={manageNote} value={manageNote}
onChange={(event) => setManageNote(event.target.value)} onChange={(event) => setManageNote(event.target.value)}
multiline multiline
minRows={2} minRows={2}
placeholder="Why this thread should stay in review or move to another job." placeholder={t("correspondenceReviewNotePlaceholder")}
/> />
<Autocomplete <Autocomplete
options={availableJobs} options={availableJobs}
@@ -579,14 +581,14 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
}} }}
onChange={(_, value) => setManageTargetJobId(value?.id ?? jobId)} onChange={(_, value) => setManageTargetJobId(value?.id ?? jobId)}
isOptionEqualToValue={(option, value) => option.id === value.id} isOptionEqualToValue={(option, value) => option.id === value.id}
getOptionLabel={(item) => `${item.companyName || "Unknown company"}${item.jobTitle}`} getOptionLabel={(item) => `${item.companyName || t("correspondenceUnknownCompany")}${item.jobTitle}`}
renderInput={(params) => <TextField {...params} label="Move to job" placeholder="Search every job" />} renderInput={(params) => <TextField {...params} label={t("correspondenceMoveToJob")} placeholder={t("correspondenceSearchEveryJob")} />}
/> />
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => setManageThreadId(null)} disabled={manageSaving}>Close</Button> <Button onClick={() => setManageThreadId(null)} disabled={manageSaving}>{t("close")}</Button>
<Button color="warning" variant="outlined" onClick={() => void unlinkThread()} disabled={manageSaving || !manageThreadId}>Unlink from this job</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}>Move thread</Button> <Button variant="contained" onClick={() => void relinkThread()} disabled={manageSaving || !manageThreadId || manageTargetJobId === jobId}>{t("correspondenceMoveThread")}</Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
@@ -613,7 +615,7 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
</Typography> </Typography>
{jobContext.companyName || jobContext.jobTitle ? ( {jobContext.companyName || jobContext.jobTitle ? (
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.5 }}> <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> </Typography>
) : null} ) : null}
</Box> </Box>
@@ -622,7 +624,7 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
<> <>
{linkedThreadIds.length > 0 ? ( {linkedThreadIds.length > 0 ? (
<Button variant="outlined" onClick={() => void refreshLinkedThreads("manual")} disabled={linkedThreadRefreshLoading}> <Button variant="outlined" onClick={() => void refreshLinkedThreads("manual")} disabled={linkedThreadRefreshLoading}>
{linkedThreadRefreshLoading ? "Refreshing linked threads..." : "Refresh linked threads"} {linkedThreadRefreshLoading ? t("correspondenceRefreshingLinkedThreads") : t("correspondenceRefreshLinkedThreads")}
</Button> </Button>
) : null} ) : null}
<Button variant="outlined" onClick={() => void loadGmailMatches(gmailQuery)} disabled={gmailMatchesLoading}>{t("correspondenceRefresh")}</Button> <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> </Box>
) : null} ) : null}
{gmailStatus.lastSyncedAt ? <Chip label={t("correspondenceLastSynced", { date: new Date(gmailStatus.lastSyncedAt).toLocaleString() })} size="small" /> : 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.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={`Sync issue: ${gmailStatus.lastSyncError}`} size="small" color="warning" 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 ? ( {linkedThreadIds.length > 0 ? (
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}> <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 ? ( {linkedThreadRefresh ? (
<Chip <Chip
size="small" size="small"
variant="outlined" variant="outlined"
label={linkedThreadRefresh.imported > 0 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 : linkedThreadRefresh.hasLinkedThreads
? `Last refresh checked ${linkedThreadRefresh.threadsChecked} linked thread${linkedThreadRefresh.threadsChecked === 1 ? "" : "s"}` ? t(linkedThreadRefresh.threadsChecked === 1 ? "correspondenceLastRefreshCheckedThread" : "correspondenceLastRefreshCheckedThreads", { count: linkedThreadRefresh.threadsChecked })
: "No linked Gmail threads yet"} : t("correspondenceNoLinkedThreadsForJob")}
/> />
) : null} ) : null}
</Box> </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> <Box sx={{ py: 5, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box>
) : !gmailMatches || gmailMatches.threads.length === 0 ? ( ) : !gmailMatches || gmailMatches.threads.length === 0 ? (
<Typography sx={{ color: "text.secondary", p: 2 }}> <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> </Typography>
) : ( ) : (
<List disablePadding> <List disablePadding>
@@ -688,9 +690,9 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
<Box> <Box>
<Typography sx={{ fontWeight: 800 }}>{thread.subject || t("correspondenceNoSubject")}</Typography> <Typography sx={{ fontWeight: 800 }}>{thread.subject || t("correspondenceNoSubject")}</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.75 }}> <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={t("correspondenceConfidenceScore", { confidence: 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" /> <Chip size="small" label={t(thread.messageCount === 1 ? "correspondenceMessageCount" : "correspondenceMessagesCount", { count: thread.messageCount })} variant="outlined" />
{thread.hasImportedMessages ? <Chip size="small" label="Already linked" variant="outlined" color="success" /> : null} {thread.hasImportedMessages ? <Chip size="small" label={t("correspondenceAlreadyLinked")} variant="outlined" color="success" /> : null}
</Box> </Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 1 }}> <Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 1 }}>
{thread.matchReasons.map((reason) => ( {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.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> <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 }}> <Box component="span" sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 1 }}>
<Chip size="small" label={`${formatConfidence(message.confidence)} · score ${message.score}`} variant="outlined" /> <Chip size="small" label={t("correspondenceConfidenceScore", { confidence: formatConfidence(message.confidence), score: message.score })} variant="outlined" />
{message.alreadyImported ? <Chip size="small" label="Already linked" color="success" variant="outlined" /> : null} {message.alreadyImported ? <Chip size="small" label={t("correspondenceAlreadyLinked")} color="success" variant="outlined" /> : null}
{message.matchReasons.map((reason) => ( {message.matchReasons.map((reason) => (
<Chip key={`${message.id}-${reason.label}-${reason.value}`} size="small" label={`${formatReasonLabel(reason.label)}: ${reason.value}`} variant="outlined" /> <Chip key={`${message.id}-${reason.label}-${reason.value}`} size="small" label={`${formatReasonLabel(reason.label)}: ${reason.value}`} variant="outlined" />
))} ))}
@@ -35,6 +35,7 @@ function renderPage() {
describe('GmailReviewPage', () => { describe('GmailReviewPage', () => {
beforeEach(() => { beforeEach(() => {
window.localStorage.clear();
mockedApi.get.mockImplementation((url: string) => { mockedApi.get.mockImplementation((url: string) => {
if (url === '/gmail/review-candidates') { if (url === '/gmail/review-candidates') {
return Promise.resolve({ return Promise.resolve({
@@ -102,4 +103,13 @@ describe('GmailReviewPage', () => {
}); });
}); });
}); });
test('renders the review workflow in Norwegian Bokmål', async () => {
window.localStorage.setItem('uiLanguage', 'nb');
renderPage();
expect(await screen.findByText('Kontrollkø for Gmail')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Knytt til beste jobbtreff' })).toBeInTheDocument();
expect(screen.getByLabelText('Kontrollnotater')).toBeInTheDocument();
});
}); });
+284
View File
@@ -882,6 +882,26 @@ export const translations = {
addJobModalJobCreatedUploadFailed: "Job created, but file upload failed.", addJobModalJobCreatedUploadFailed: "Job created, but file upload failed.",
addJobModalJobCreatedFilesNotAttached: "Job created. Files could not be attached automatically.", addJobModalJobCreatedFilesNotAttached: "Job created. Files could not be attached automatically.",
addJobModalFailedAddJob: "Failed to add job.", addJobModalFailedAddJob: "Failed to add job.",
addJobModalTailoredCvFailed: "Job created, but the tailored CV could not be generated.",
addJobModalStepAddJob: "Add job",
addJobModalStepReview: "Review details",
addJobModalStepAdditionalFiles: "Additional files",
addJobModalStepProgress: "Step {current} of {total}: {step}",
addJobModalStartWithAdvert: "Start with the job advert",
addJobModalStartWithAdvertBody: "Paste a job URL and Jobbjakt will try to fill the details. You can continue with manual entry whenever extraction is unavailable.",
addJobModalEnterManually: "Enter details manually",
addJobModalCvStepHelp: "Use your master CV later in Career Workspace, or attach the version prepared for this application now.",
addJobModalCoverLetterStepHelp: "Attach a drafted cover letter now, or add or generate one from the application workspace later.",
addJobModalPortfolioStepHelp: "Add supporting portfolio material now, or select lightweight profile-linked projects later.",
addJobModalOtherFilesStepHelp: "Certificates, references, and other supporting documents are optional.",
addJobModalGenerateTailoredCv: "Generate a tailored CV draft after creating this job",
addJobModalTailoredCvRequiresPro: "Tailored CV generation requires Pro",
addJobModalTailoredCvHelp: "Uses your reviewed Career Profile and keeps the result as an editable suggestion.",
addJobModalNoAiHelp: "Create and track the job normally; no AI operation will be started.",
characterCount: "{count} characters",
back: "Back",
continue: "Continue",
skipAndContinue: "Skip and continue",
statusSaved: "Saved", statusSaved: "Saved",
statusInterested: "Interested", statusInterested: "Interested",
statusPreparing: "Preparing", statusPreparing: "Preparing",
@@ -911,6 +931,9 @@ export const translations = {
settingsUsageGenerations: "{used} of {limit} generations this month", settingsUsageGenerations: "{used} of {limit} generations this month",
settingsUsageTokens: "{used} of {limit} estimated tokens", settingsUsageTokens: "{used} of {limit} estimated tokens",
settingsUsageStorage: "{used} of {limit} attachment storage", settingsUsageStorage: "{used} of {limit} attachment storage",
settingsUsageGenerationsProgress: "Monthly AI generations used",
settingsUsageTokensProgress: "Monthly AI tokens used",
settingsUsageStorageProgress: "Attachment storage used",
settingsUsageReset: "AI limits reset at the start of each calendar month.", settingsUsageReset: "AI limits reset at the start of each calendar month.",
settingsUsageNoAi: "The Free plan includes core job tracking without AI. Upgrade to Pro to use AI features.", settingsUsageNoAi: "The Free plan includes core job tracking without AI. Upgrade to Pro to use AI features.",
settingsBillingUpgrade: "Upgrade to Pro", settingsBillingUpgrade: "Upgrade to Pro",
@@ -1503,6 +1526,125 @@ export const translations = {
correspondenceImportThreadResult: "Imported {imported} messages{skippedText}.", correspondenceImportThreadResult: "Imported {imported} messages{skippedText}.",
correspondenceImportThreadSkipped: ", skipped {count} duplicates", correspondenceImportThreadSkipped: ", skipped {count} duplicates",
correspondenceImportThreadFailed: "Failed to import Gmail thread.", correspondenceImportThreadFailed: "Failed to import Gmail thread.",
correspondenceLoadSuggestionsFailed: "Failed to load Gmail suggestions.",
correspondenceImportedNewMessage: "Imported {count} new Gmail message.",
correspondenceImportedNewMessages: "Imported {count} new Gmail messages.",
correspondenceLinkedThreadsCurrent: "Linked Gmail threads are already current.",
correspondenceNoLinkedThreadsForJob: "This job does not have any linked Gmail threads yet.",
correspondenceLinkedImportedMessage: "Linked Gmail threads imported {count} new message.",
correspondenceLinkedImportedMessages: "Linked Gmail threads imported {count} new messages.",
correspondenceRefreshLinkedFailed: "Failed to refresh linked Gmail threads.",
correspondenceAddFailed: "Failed to add message.",
correspondenceAlreadyLinkedToJob: "This Gmail message is already linked to the job.",
correspondenceUnlinkedMessage: "Unlinked {count} message from this job.",
correspondenceUnlinkedMessages: "Unlinked {count} messages from this job.",
correspondenceUnlinkThreadFailed: "Failed to unlink the Gmail thread.",
correspondenceMovedThread: "Moved thread to {job}.",
correspondenceMoveThreadFailed: "Failed to move the Gmail thread.",
correspondenceJobNumber: "job {id}",
correspondenceRecruiterMailbox: "Recruiter mailbox",
correspondenceCompanyAndRole: "Company + role",
correspondenceCompanyMail: "Company mail",
correspondenceSubject: "Subject",
correspondenceConfidenceHigh: "High confidence",
correspondenceConfidenceMedium: "Medium confidence",
correspondenceConfidenceLow: "Low confidence",
correspondenceConfidenceUnknown: "Confidence unknown",
correspondenceRecruiterEmail: "Recruiter email",
correspondenceRecruiter: "Recruiter",
correspondenceJobTitle: "Job title",
correspondenceStatus: "Status",
correspondenceExistingSubject: "Existing subject",
correspondenceRecent: "Recent",
correspondenceThread: "Thread {id}",
correspondenceFrom: "From {value}",
correspondenceTo: "To {value}",
correspondenceGmailLabel: "{count} Gmail label",
correspondenceGmailLabels: "{count} Gmail labels",
correspondenceAttachment: "{count} attachment",
correspondenceAttachments: "{count} attachments",
correspondenceLinkedContinuity: "Linked Gmail thread continuity",
correspondenceLinkedContinuityHelp: "Linked Gmail refresh only checks threads already tied to this job, so new correspondence can appear here without re-importing the whole thread.",
correspondenceGmailConnected: "Gmail connected",
correspondenceGmailNotConnected: "Gmail not connected",
correspondenceLinkedThreads: "Linked threads: {count}",
correspondenceNoLinkedThreads: "No linked threads yet",
correspondenceManageThread: "Manage {id}",
correspondenceLastSyncOk: "Last Gmail sync {mode} succeeded",
correspondenceLastSyncFailed: "Last Gmail sync {mode} failed",
correspondenceSync: "sync",
correspondenceLastRefreshImportedMessage: "Last linked refresh imported {count} new message",
correspondenceLastRefreshImportedMessages: "Last linked refresh imported {count} new messages",
correspondenceLastRefreshCheckedThread: "Last linked refresh checked {count} linked thread",
correspondenceLastRefreshCheckedThreads: "Last linked refresh checked {count} linked threads",
correspondenceNoRefreshHistory: "No linked Gmail refresh history yet",
correspondenceLatestSyncIssue: "Latest Gmail sync issue: {error}",
correspondenceManageLinkedThread: "Manage linked Gmail thread",
correspondenceManageLinkedThreadHelp: "Unlink this thread from the current job, or move it to another existing job.",
correspondenceReviewNote: "Review note",
correspondenceReviewNotePlaceholder: "Why this thread should stay in review or move to another job.",
correspondenceUnknownCompany: "Unknown company",
correspondenceMoveToJob: "Move to job",
correspondenceSearchEveryJob: "Search every job",
correspondenceUnlinkFromJob: "Unlink from this job",
correspondenceMoveThread: "Move thread",
correspondenceMatchingAgainst: "Matching against {company} / {role}",
correspondenceThisCompany: "this company",
correspondenceThisRole: "this role",
correspondenceRefreshingLinkedThreads: "Refreshing linked threads...",
correspondenceRefreshLinkedThreads: "Refresh linked threads",
correspondenceSyncChecked: "Sync checked {date}",
correspondenceSyncIssue: "Sync issue: {error}",
correspondenceNoMatchesForOverride: "No Gmail matches for this job and search override yet.",
correspondenceConfidenceScore: "{confidence} · score {score}",
correspondenceMessageCount: "{count} message",
correspondenceMessagesCount: "{count} messages",
correspondenceAlreadyLinked: "Already linked",
gmailReviewLoadFailed: "Failed to load Gmail review candidates.",
gmailReviewLinked: "Thread linked and imported.",
gmailReviewRejected: "Thread rejected from review.",
gmailReviewKeptSuggested: "Thread kept as suggested job material.",
gmailReviewReturned: "Thread returned to review.",
gmailReviewSaveFailed: "Failed to save Gmail review decision.",
gmailReviewSyncFinished: "Manual Gmail sync finished: {linked} threads linked, {review} for review, {unmatched} unmatched.",
gmailReviewSyncFailed: "Failed to run Gmail manual sync.",
gmailReviewUnknownCompany: "Unknown company",
gmailReviewSuggestedRole: "Suggested role",
gmailReviewCreatedImportedMessage: "Created suggested job and imported {count} message.",
gmailReviewCreatedImportedMessages: "Created suggested job and imported {count} messages.",
gmailReviewCreateFailed: "Failed to create the suggested job.",
gmailReviewEmbeddedTitle: "Recruitment message review",
gmailReviewTitle: "Gmail review queue",
gmailReviewSubtitle: "Manual sync, high-confidence auto-linking, medium-confidence review, and suggested jobs from unmatched Gmail threads.",
gmailReviewSyncing: "Syncing...",
gmailReviewRunSync: "Run manual sync",
gmailReviewBackToInbox: "Back to inbox",
gmailReviewFilter: "Filter: {value}",
gmailReviewRouteAll: "All",
gmailReviewRouteAutoLink: "Auto-link",
gmailReviewRouteReview: "Review",
gmailReviewRouteUnmatched: "Unmatched",
gmailReviewRouteSuggested: "Suggested",
gmailReviewRouteLinked: "Linked",
gmailReviewRouteRejected: "Rejected",
gmailReviewCandidateThreads: "{count} candidate threads",
gmailReviewAutoLinkCount: "{count} auto-link",
gmailReviewReviewCount: "{count} review",
gmailReviewUnmatchedCount: "{count} unmatched",
gmailReviewSuggestedJobsCount: "{count} suggested jobs",
gmailReviewNoCandidates: "No Gmail review candidates match the current filter.",
gmailReviewThreadSummary: "{count} messages · {route}",
gmailReviewHasImported: "Has imported messages",
gmailReviewNotes: "Review notes",
gmailReviewNotesPlaceholder: "Why this should link, stay in review, or become a suggested job.",
gmailReviewUnknownRole: "Unknown role",
gmailReviewSuggestedJob: "Suggested job: {company} · {role}",
gmailReviewOpenTopJob: "Open top job",
gmailReviewLinkTopJob: "Link top job",
gmailReviewKeepInReview: "Keep in review",
gmailReviewSuggestedJobAction: "Suggested job",
gmailReviewCreating: "Creating...",
gmailReviewReject: "Reject",
attachmentsTitle: "Attachments ({count})", attachmentsTitle: "Attachments ({count})",
attachmentsSubtitle: "Upload resumes, cover letters, portfolios, and supporting files for this application.", attachmentsSubtitle: "Upload resumes, cover letters, portfolios, and supporting files for this application.",
attachmentsImages: "{count} images", attachmentsImages: "{count} images",
@@ -3011,6 +3153,26 @@ export const translations = {
addJobModalJobCreatedUploadFailed: "Jobben ble opprettet, men filopplasting mislyktes.", addJobModalJobCreatedUploadFailed: "Jobben ble opprettet, men filopplasting mislyktes.",
addJobModalJobCreatedFilesNotAttached: "Jobben ble opprettet. Filene kunne ikke knyttes automatisk.", addJobModalJobCreatedFilesNotAttached: "Jobben ble opprettet. Filene kunne ikke knyttes automatisk.",
addJobModalFailedAddJob: "Kunne ikke legge til jobb.", addJobModalFailedAddJob: "Kunne ikke legge til jobb.",
addJobModalTailoredCvFailed: "Jobben ble opprettet, men den tilpassede CV-en kunne ikke genereres.",
addJobModalStepAddJob: "Legg til jobb",
addJobModalStepReview: "Se gjennom detaljer",
addJobModalStepAdditionalFiles: "Flere filer",
addJobModalStepProgress: "Trinn {current} av {total}: {step}",
addJobModalStartWithAdvert: "Start med jobbannonsen",
addJobModalStartWithAdvertBody: "Lim inn en jobb-URL, så prøver Jobbjakt å fylle ut detaljene. Du kan fortsette med manuell registrering hvis uttrekk ikke er tilgjengelig.",
addJobModalEnterManually: "Skriv inn detaljer manuelt",
addJobModalCvStepHelp: "Bruk hoved-CV-en senere i Karriereområdet, eller legg ved versjonen som er klargjort for denne søknaden nå.",
addJobModalCoverLetterStepHelp: "Legg ved et utkast til søknadsbrev nå, eller legg til eller generer et fra søknadsområdet senere.",
addJobModalPortfolioStepHelp: "Legg til støttende porteføljemateriale nå, eller velg prosjekter fra profilen senere.",
addJobModalOtherFilesStepHelp: "Sertifikater, referanser og andre støttedokumenter er valgfrie.",
addJobModalGenerateTailoredCv: "Generer et tilpasset CV-utkast etter at jobben er opprettet",
addJobModalTailoredCvRequiresPro: "Tilpasset CV-generering krever Pro",
addJobModalTailoredCvHelp: "Bruker den gjennomgåtte Karriereprofilen og beholder resultatet som et redigerbart forslag.",
addJobModalNoAiHelp: "Opprett og spor jobben som vanlig; ingen KI-operasjon startes.",
characterCount: "{count} tegn",
back: "Tilbake",
continue: "Fortsett",
skipAndContinue: "Hopp over og fortsett",
statusSaved: "Lagret", statusSaved: "Lagret",
statusInterested: "Interessert", statusInterested: "Interessert",
statusPreparing: "Forbereder", statusPreparing: "Forbereder",
@@ -3040,6 +3202,9 @@ export const translations = {
settingsUsageGenerations: "{used} av {limit} genereringer denne måneden", settingsUsageGenerations: "{used} av {limit} genereringer denne måneden",
settingsUsageTokens: "{used} av {limit} estimerte tokener", settingsUsageTokens: "{used} av {limit} estimerte tokener",
settingsUsageStorage: "{used} av {limit} vedleggslagring", settingsUsageStorage: "{used} av {limit} vedleggslagring",
settingsUsageGenerationsProgress: "Brukte KI-genereringer denne måneden",
settingsUsageTokensProgress: "Brukte KI-tokener denne måneden",
settingsUsageStorageProgress: "Brukt vedleggslagring",
settingsUsageReset: "KI-grensene nullstilles ved starten av hver kalendermåned.", settingsUsageReset: "KI-grensene nullstilles ved starten av hver kalendermåned.",
settingsUsageNoAi: "Free-abonnementet inkluderer grunnleggende jobbsporing uten KI. Oppgrader til Pro for KI-funksjoner.", settingsUsageNoAi: "Free-abonnementet inkluderer grunnleggende jobbsporing uten KI. Oppgrader til Pro for KI-funksjoner.",
settingsBillingUpgrade: "Oppgrader til Pro", settingsBillingUpgrade: "Oppgrader til Pro",
@@ -3632,6 +3797,125 @@ export const translations = {
correspondenceImportThreadResult: "Importerte {imported} meldinger{skippedText}.", correspondenceImportThreadResult: "Importerte {imported} meldinger{skippedText}.",
correspondenceImportThreadSkipped: ", hoppet over {count} duplikater", correspondenceImportThreadSkipped: ", hoppet over {count} duplikater",
correspondenceImportThreadFailed: "Kunne ikke importere Gmail-tråd.", correspondenceImportThreadFailed: "Kunne ikke importere Gmail-tråd.",
correspondenceLoadSuggestionsFailed: "Kunne ikke laste Gmail-forslag.",
correspondenceImportedNewMessage: "Importerte {count} ny Gmail-melding.",
correspondenceImportedNewMessages: "Importerte {count} nye Gmail-meldinger.",
correspondenceLinkedThreadsCurrent: "Tilknyttede Gmail-tråder er allerede oppdatert.",
correspondenceNoLinkedThreadsForJob: "Denne jobben har ingen tilknyttede Gmail-tråder ennå.",
correspondenceLinkedImportedMessage: "Tilknyttede Gmail-tråder importerte {count} ny melding.",
correspondenceLinkedImportedMessages: "Tilknyttede Gmail-tråder importerte {count} nye meldinger.",
correspondenceRefreshLinkedFailed: "Kunne ikke oppdatere tilknyttede Gmail-tråder.",
correspondenceAddFailed: "Kunne ikke legge til melding.",
correspondenceAlreadyLinkedToJob: "Denne Gmail-meldingen er allerede knyttet til jobben.",
correspondenceUnlinkedMessage: "Fjernet tilknytningen til {count} melding fra denne jobben.",
correspondenceUnlinkedMessages: "Fjernet tilknytningen til {count} meldinger fra denne jobben.",
correspondenceUnlinkThreadFailed: "Kunne ikke fjerne tilknytningen til Gmail-tråden.",
correspondenceMovedThread: "Flyttet tråden til {job}.",
correspondenceMoveThreadFailed: "Kunne ikke flytte Gmail-tråden.",
correspondenceJobNumber: "jobb {id}",
correspondenceRecruiterMailbox: "Rekruttererens innboks",
correspondenceCompanyAndRole: "Selskap + rolle",
correspondenceCompanyMail: "E-post fra selskapet",
correspondenceSubject: "Emne",
correspondenceConfidenceHigh: "Høy sikkerhet",
correspondenceConfidenceMedium: "Middels sikkerhet",
correspondenceConfidenceLow: "Lav sikkerhet",
correspondenceConfidenceUnknown: "Ukjent sikkerhet",
correspondenceRecruiterEmail: "Rekruttererens e-post",
correspondenceRecruiter: "Rekrutterer",
correspondenceJobTitle: "Jobbtittel",
correspondenceStatus: "Status",
correspondenceExistingSubject: "Eksisterende emne",
correspondenceRecent: "Nylig",
correspondenceThread: "Tråd {id}",
correspondenceFrom: "Fra {value}",
correspondenceTo: "Til {value}",
correspondenceGmailLabel: "{count} Gmail-etikett",
correspondenceGmailLabels: "{count} Gmail-etiketter",
correspondenceAttachment: "{count} vedlegg",
correspondenceAttachments: "{count} vedlegg",
correspondenceLinkedContinuity: "Kontinuitet i tilknyttede Gmail-tråder",
correspondenceLinkedContinuityHelp: "Gmail-oppdateringen sjekker bare tråder som allerede er knyttet til denne jobben, slik at ny korrespondanse kan vises her uten å importere hele tråden på nytt.",
correspondenceGmailConnected: "Gmail tilkoblet",
correspondenceGmailNotConnected: "Gmail ikke tilkoblet",
correspondenceLinkedThreads: "Tilknyttede tråder: {count}",
correspondenceNoLinkedThreads: "Ingen tilknyttede tråder ennå",
correspondenceManageThread: "Administrer {id}",
correspondenceLastSyncOk: "Siste Gmail-synkronisering ({mode}) var vellykket",
correspondenceLastSyncFailed: "Siste Gmail-synkronisering ({mode}) mislyktes",
correspondenceSync: "synkronisering",
correspondenceLastRefreshImportedMessage: "Siste oppdatering importerte {count} ny melding",
correspondenceLastRefreshImportedMessages: "Siste oppdatering importerte {count} nye meldinger",
correspondenceLastRefreshCheckedThread: "Siste oppdatering sjekket {count} tilknyttet tråd",
correspondenceLastRefreshCheckedThreads: "Siste oppdatering sjekket {count} tilknyttede tråder",
correspondenceNoRefreshHistory: "Ingen historikk for oppdatering av tilknyttede Gmail-tråder ennå",
correspondenceLatestSyncIssue: "Siste problem med Gmail-synkronisering: {error}",
correspondenceManageLinkedThread: "Administrer tilknyttet Gmail-tråd",
correspondenceManageLinkedThreadHelp: "Fjern trådens tilknytning til gjeldende jobb, eller flytt den til en annen eksisterende jobb.",
correspondenceReviewNote: "Kontrollnotat",
correspondenceReviewNotePlaceholder: "Hvorfor denne tråden bør forbli til kontroll eller flyttes til en annen jobb.",
correspondenceUnknownCompany: "Ukjent selskap",
correspondenceMoveToJob: "Flytt til jobb",
correspondenceSearchEveryJob: "Søk i alle jobber",
correspondenceUnlinkFromJob: "Fjern fra denne jobben",
correspondenceMoveThread: "Flytt tråd",
correspondenceMatchingAgainst: "Sammenligner med {company} / {role}",
correspondenceThisCompany: "dette selskapet",
correspondenceThisRole: "denne rollen",
correspondenceRefreshingLinkedThreads: "Oppdaterer tilknyttede tråder...",
correspondenceRefreshLinkedThreads: "Oppdater tilknyttede tråder",
correspondenceSyncChecked: "Synkronisering sjekket {date}",
correspondenceSyncIssue: "Synkroniseringsproblem: {error}",
correspondenceNoMatchesForOverride: "Ingen Gmail-treff for denne jobben og det egendefinerte søket ennå.",
correspondenceConfidenceScore: "{confidence} · poeng {score}",
correspondenceMessageCount: "{count} melding",
correspondenceMessagesCount: "{count} meldinger",
correspondenceAlreadyLinked: "Allerede tilknyttet",
gmailReviewLoadFailed: "Kunne ikke laste kandidater til Gmail-kontroll.",
gmailReviewLinked: "Tråden ble koblet til og importert.",
gmailReviewRejected: "Tråden ble avvist fra kontrollen.",
gmailReviewKeptSuggested: "Tråden ble beholdt som materiale til en foreslått jobb.",
gmailReviewReturned: "Tråden ble sendt tilbake til kontroll.",
gmailReviewSaveFailed: "Kunne ikke lagre avgjørelsen for Gmail-kontrollen.",
gmailReviewSyncFinished: "Manuell Gmail-synkronisering fullført: {linked} tråder tilknyttet, {review} til kontroll, {unmatched} uten treff.",
gmailReviewSyncFailed: "Kunne ikke kjøre manuell Gmail-synkronisering.",
gmailReviewUnknownCompany: "Ukjent selskap",
gmailReviewSuggestedRole: "Foreslått rolle",
gmailReviewCreatedImportedMessage: "Opprettet foreslått jobb og importerte {count} melding.",
gmailReviewCreatedImportedMessages: "Opprettet foreslått jobb og importerte {count} meldinger.",
gmailReviewCreateFailed: "Kunne ikke opprette den foreslåtte jobben.",
gmailReviewEmbeddedTitle: "Kontroll av rekrutteringsmeldinger",
gmailReviewTitle: "Kontrollkø for Gmail",
gmailReviewSubtitle: "Manuell synkronisering, automatisk tilknytning med høy sikkerhet, kontroll ved middels sikkerhet og foreslåtte jobber fra Gmail-tråder uten treff.",
gmailReviewSyncing: "Synkroniserer...",
gmailReviewRunSync: "Kjør manuell synkronisering",
gmailReviewBackToInbox: "Tilbake til innboksen",
gmailReviewFilter: "Filter: {value}",
gmailReviewRouteAll: "Alle",
gmailReviewRouteAutoLink: "Automatisk tilknytning",
gmailReviewRouteReview: "Til kontroll",
gmailReviewRouteUnmatched: "Uten treff",
gmailReviewRouteSuggested: "Foreslått",
gmailReviewRouteLinked: "Tilknyttet",
gmailReviewRouteRejected: "Avvist",
gmailReviewCandidateThreads: "{count} kandidattråder",
gmailReviewAutoLinkCount: "{count} automatisk tilknytning",
gmailReviewReviewCount: "{count} til kontroll",
gmailReviewUnmatchedCount: "{count} uten treff",
gmailReviewSuggestedJobsCount: "{count} foreslåtte jobber",
gmailReviewNoCandidates: "Ingen kandidater til Gmail-kontroll samsvarer med gjeldende filter.",
gmailReviewThreadSummary: "{count} meldinger · {route}",
gmailReviewHasImported: "Har importerte meldinger",
gmailReviewNotes: "Kontrollnotater",
gmailReviewNotesPlaceholder: "Hvorfor dette bør knyttes til, beholdes til kontroll eller bli en foreslått jobb.",
gmailReviewUnknownRole: "Ukjent rolle",
gmailReviewSuggestedJob: "Foreslått jobb: {company} · {role}",
gmailReviewOpenTopJob: "Åpne beste jobbtreff",
gmailReviewLinkTopJob: "Knytt til beste jobbtreff",
gmailReviewKeepInReview: "Behold til kontroll",
gmailReviewSuggestedJobAction: "Foreslått jobb",
gmailReviewCreating: "Oppretter...",
gmailReviewReject: "Avvis",
attachmentsTitle: "Vedlegg ({count})", attachmentsTitle: "Vedlegg ({count})",
attachmentsSubtitle: "Last opp CV-er, søknadsbrev, porteføljer og støttedokumenter for denne søknaden.", attachmentsSubtitle: "Last opp CV-er, søknadsbrev, porteføljer og støttedokumenter for denne søknaden.",
attachmentsImages: "{count} bilder", attachmentsImages: "{count} bilder",
+10
View File
@@ -40,6 +40,7 @@ function renderModal(initialUrl?: string) {
} }
beforeEach(() => { beforeEach(() => {
window.localStorage.clear();
mockedApi.get.mockResolvedValue({ data: [] } as any); mockedApi.get.mockResolvedValue({ data: [] } as any);
mockedApi.post.mockImplementation((url: string) => { mockedApi.post.mockImplementation((url: string) => {
if (url === '/jobimport/preview') { if (url === '/jobimport/preview') {
@@ -69,6 +70,15 @@ test('does not auto-import when no initialUrl is given', async () => {
expect(mockedApi.post).not.toHaveBeenCalledWith('/jobimport/preview', expect.anything()); expect(mockedApi.post).not.toHaveBeenCalledWith('/jobimport/preview', expect.anything());
}); });
test('localizes the job creation workflow in Norwegian Bokmål', async () => {
window.localStorage.setItem('uiLanguage', 'nb');
renderModal();
expect(await screen.findByText('Start med jobbannonsen')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Skriv inn detaljer manuelt' })).toBeInTheDocument();
expect(screen.getByText('Trinn 1 av 6: Legg til jobb')).toBeInTheDocument();
});
test('can generate a tailored CV after creating a job', async () => { test('can generate a tailored CV after creating a job', async () => {
mockedApi.post.mockImplementation((url: string) => { mockedApi.post.mockImplementation((url: string) => {
+51 -40
View File
@@ -4,9 +4,11 @@ import { api, getApiErrorMessage } from "../api";
import { CreatedSuggestedGmailJobResult, GmailManualSyncResult, GmailReviewQueueResponse, GmailSuggestedJobsResponse } from "../types"; import { CreatedSuggestedGmailJobResult, GmailManualSyncResult, GmailReviewQueueResponse, GmailSuggestedJobsResponse } from "../types";
import { useToast } from "../toast"; import { useToast } from "../toast";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useI18n } from "../i18n/I18nProvider";
export default function GmailReviewPage({ embedded = false }: { embedded?: boolean }) { export default function GmailReviewPage({ embedded = false }: { embedded?: boolean }) {
const { toast } = useToast(); const { toast } = useToast();
const { t } = useI18n();
const navigate = useNavigate(); const navigate = useNavigate();
const [data, setData] = useState<GmailReviewQueueResponse | null>(null); const [data, setData] = useState<GmailReviewQueueResponse | null>(null);
const [suggestions, setSuggestions] = useState<GmailSuggestedJobsResponse | null>(null); const [suggestions, setSuggestions] = useState<GmailSuggestedJobsResponse | null>(null);
@@ -16,6 +18,15 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
const [creatingThreadId, setCreatingThreadId] = useState<string | null>(null); const [creatingThreadId, setCreatingThreadId] = useState<string | null>(null);
const [routingFilter, setRoutingFilter] = useState<"all" | "auto-link" | "review" | "unmatched" | "suggested" | "linked" | "rejected">("all"); const [routingFilter, setRoutingFilter] = useState<"all" | "auto-link" | "review" | "unmatched" | "suggested" | "linked" | "rejected">("all");
const [notes, setNotes] = useState<Record<string, string>>({}); const [notes, setNotes] = useState<Record<string, string>>({});
const routeLabel = (value: string) => {
if (value === "auto-link") return t("gmailReviewRouteAutoLink");
if (value === "review") return t("gmailReviewRouteReview");
if (value === "unmatched") return t("gmailReviewRouteUnmatched");
if (value === "suggested") return t("gmailReviewRouteSuggested");
if (value === "linked") return t("gmailReviewRouteLinked");
if (value === "rejected") return t("gmailReviewRouteRejected");
return t("gmailReviewRouteAll");
};
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -34,11 +45,11 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
return next; return next;
}); });
} catch (error) { } catch (error) {
toast(getApiErrorMessage(error, "Failed to load Gmail review candidates."), "error"); toast(getApiErrorMessage(error, t("gmailReviewLoadFailed")), "error");
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [toast]); }, [t, toast]);
useEffect(() => { useEffect(() => {
void load(); void load();
@@ -56,20 +67,20 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
await load(); await load();
toast( toast(
decision === "linked" decision === "linked"
? "Thread linked and imported." ? t("gmailReviewLinked")
: decision === "rejected" : decision === "rejected"
? "Thread rejected from review." ? t("gmailReviewRejected")
: decision === "suggested" : decision === "suggested"
? "Thread kept as suggested job material." ? t("gmailReviewKeptSuggested")
: "Thread returned to review.", : t("gmailReviewReturned"),
"success", "success",
); );
} catch (error) { } catch (error) {
toast(getApiErrorMessage(error, "Failed to save Gmail review decision."), "error"); toast(getApiErrorMessage(error, t("gmailReviewSaveFailed")), "error");
} finally { } finally {
setSavingThreadId(null); setSavingThreadId(null);
} }
}, [load, notes, toast]); }, [load, notes, t, toast]);
const runManualSync = useCallback(async () => { const runManualSync = useCallback(async () => {
setSyncing(true); setSyncing(true);
@@ -82,15 +93,15 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
}); });
await load(); await load();
toast( toast(
`Manual Gmail sync finished: ${res.data.importedThreads} threads linked, ${res.data.reviewThreadCount} review, ${res.data.unmatchedThreadCount} unmatched.`, t("gmailReviewSyncFinished", { linked: res.data.importedThreads, review: res.data.reviewThreadCount, unmatched: res.data.unmatchedThreadCount }),
"success", "success",
); );
} catch (error) { } catch (error) {
toast(getApiErrorMessage(error, "Failed to run Gmail manual sync."), "error"); toast(getApiErrorMessage(error, t("gmailReviewSyncFailed")), "error");
} finally { } finally {
setSyncing(false); setSyncing(false);
} }
}, [load, toast]); }, [load, t, toast]);
const createSuggestedJob = useCallback(async (threadId: string) => { const createSuggestedJob = useCallback(async (threadId: string) => {
const suggestion = suggestions?.items.find((item) => item.threadId === threadId); const suggestion = suggestions?.items.find((item) => item.threadId === threadId);
@@ -100,22 +111,22 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
try { try {
const res = await api.post<CreatedSuggestedGmailJobResult>("/gmail/create-suggested-job", { const res = await api.post<CreatedSuggestedGmailJobResult>("/gmail/create-suggested-job", {
threadId, threadId,
companyName: suggestion.companyName || "Unknown company", companyName: suggestion.companyName || t("gmailReviewUnknownCompany"),
jobTitle: suggestion.suggestedJobTitle || suggestion.subject || "Suggested role", jobTitle: suggestion.suggestedJobTitle || suggestion.subject || t("gmailReviewSuggestedRole"),
recruiterName: suggestion.recruiterName || null, recruiterName: suggestion.recruiterName || null,
recruiterEmail: suggestion.recruiterEmail || null, recruiterEmail: suggestion.recruiterEmail || null,
notes: notes[threadId]?.trim() || suggestion.preview || null, notes: notes[threadId]?.trim() || suggestion.preview || null,
status: "Applied", status: "Applied",
}); });
await load(); await load();
toast(`Created suggested job and imported ${res.data.imported} message${res.data.imported === 1 ? "" : "s"}.`, "success"); toast(t(res.data.imported === 1 ? "gmailReviewCreatedImportedMessage" : "gmailReviewCreatedImportedMessages", { count: res.data.imported }), "success");
navigate(`/jobs/${res.data.jobApplicationId}?section=communication`); navigate(`/jobs/${res.data.jobApplicationId}?section=communication`);
} catch (error) { } catch (error) {
toast(getApiErrorMessage(error, "Failed to create the suggested job."), "error"); toast(getApiErrorMessage(error, t("gmailReviewCreateFailed")), "error");
} finally { } finally {
setCreatingThreadId(null); setCreatingThreadId(null);
} }
}, [load, navigate, notes, suggestions?.items, toast]); }, [load, navigate, notes, suggestions?.items, t, toast]);
const filteredThreads = useMemo(() => { const filteredThreads = useMemo(() => {
const threads = data?.threads ?? []; const threads = data?.threads ?? [];
@@ -134,43 +145,43 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
> >
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap", mb: 2 }}> <Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap", mb: 2 }}>
<Box> <Box>
<Typography component={embedded ? "h2" : "h1"} variant="h5" sx={{ fontWeight: 900 }}>{embedded ? "Recruitment message review" : "Gmail review queue"}</Typography> <Typography component={embedded ? "h2" : "h1"} variant="h5" sx={{ fontWeight: 900 }}>{embedded ? t("gmailReviewEmbeddedTitle") : t("gmailReviewTitle")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}> <Typography variant="body2" sx={{ color: "text.secondary" }}>
Manual sync, high-confidence auto-linking, medium-confidence review, and suggested jobs from unmatched Gmail threads. {t("gmailReviewSubtitle")}
</Typography> </Typography>
</Box> </Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}> <Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Button variant="contained" onClick={() => void runManualSync()} disabled={syncing}> <Button variant="contained" onClick={() => void runManualSync()} disabled={syncing}>
{syncing ? "Syncing..." : "Run manual sync"} {syncing ? t("gmailReviewSyncing") : t("gmailReviewRunSync")}
</Button> </Button>
<Button variant="outlined" onClick={() => void load()} disabled={loading || syncing}> <Button variant="outlined" onClick={() => void load()} disabled={loading || syncing}>
{loading ? "Loading..." : "Refresh"} {loading ? t("loading") : t("correspondenceRefresh")}
</Button> </Button>
{!embedded ? <Button variant="text" onClick={() => navigate("/correspondence")}>Back to inbox</Button> : null} {!embedded ? <Button variant="text" onClick={() => navigate("/correspondence")}>{t("gmailReviewBackToInbox")}</Button> : null}
</Box> </Box>
</Box> </Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 2 }}> <Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 2 }}>
<Chip label={`Filter: ${routingFilter}`} variant="outlined" /> <Chip label={t("gmailReviewFilter", { value: routeLabel(routingFilter) })} variant="outlined" />
{(["all", "auto-link", "review", "unmatched", "suggested", "linked", "rejected"] as const).map((value) => ( {(["all", "auto-link", "review", "unmatched", "suggested", "linked", "rejected"] as const).map((value) => (
<Button key={value} size="small" variant={routingFilter === value ? "contained" : "text"} onClick={() => setRoutingFilter(value)}> <Button key={value} size="small" variant={routingFilter === value ? "contained" : "text"} onClick={() => setRoutingFilter(value)}>
{value} {routeLabel(value)}
</Button> </Button>
))} ))}
</Box> </Box>
{data ? ( {data ? (
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 2 }}> <Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 2 }}>
<Chip label={`${data.candidateThreadCount} candidate threads`} variant="outlined" /> <Chip label={t("gmailReviewCandidateThreads", { count: data.candidateThreadCount })} variant="outlined" />
<Chip label={`${data.autoLinkThreadCount} auto-link`} color="success" variant="outlined" /> <Chip label={t("gmailReviewAutoLinkCount", { count: data.autoLinkThreadCount })} color="success" variant="outlined" />
<Chip label={`${data.reviewThreadCount} review`} color="warning" variant="outlined" /> <Chip label={t("gmailReviewReviewCount", { count: data.reviewThreadCount })} color="warning" variant="outlined" />
<Chip label={`${data.unmatchedThreadCount} unmatched`} variant="outlined" /> <Chip label={t("gmailReviewUnmatchedCount", { count: data.unmatchedThreadCount })} variant="outlined" />
{suggestions?.count ? <Chip label={`${suggestions.count} suggested jobs`} color="secondary" variant="outlined" /> : null} {suggestions?.count ? <Chip label={t("gmailReviewSuggestedJobsCount", { count: suggestions.count })} color="secondary" variant="outlined" /> : null}
</Box> </Box>
) : null} ) : null}
{loading ? <Box sx={{ py: 6, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : null} {loading ? <Box sx={{ py: 6, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : null}
{!loading && data && filteredThreads.length === 0 ? <Typography sx={{ color: "text.secondary" }}>No Gmail review candidates match the current filter.</Typography> : null} {!loading && data && filteredThreads.length === 0 ? <Typography sx={{ color: "text.secondary" }}>{t("gmailReviewNoCandidates")}</Typography> : null}
<Stack spacing={1.25}> <Stack spacing={1.25}>
{filteredThreads.map((thread) => { {filteredThreads.map((thread) => {
@@ -181,16 +192,16 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
<Box sx={{ minWidth: 0, flex: "1 1 420px" }}> <Box sx={{ minWidth: 0, flex: "1 1 420px" }}>
<Typography sx={{ fontWeight: 800, overflowWrap: "anywhere" }}>{thread.subject}</Typography> <Typography sx={{ fontWeight: 800, overflowWrap: "anywhere" }}>{thread.subject}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}> <Typography variant="body2" sx={{ color: "text.secondary" }}>
{thread.messageCount} messages · {thread.routing} {t("gmailReviewThreadSummary", { count: thread.messageCount, route: routeLabel(thread.routing) })}
</Typography> </Typography>
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.75 }}> <Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.75 }}>
{thread.matchedQueries.slice(0, 3).map((query) => ( {thread.matchedQueries.slice(0, 3).map((query) => (
<Chip key={query} size="small" label={query} variant="outlined" /> <Chip key={query} size="small" label={query} variant="outlined" />
))} ))}
{thread.hasImportedMessages ? <Chip size="small" label="Has imported messages" color="success" variant="outlined" /> : null} {thread.hasImportedMessages ? <Chip size="small" label={t("gmailReviewHasImported")} color="success" variant="outlined" /> : null}
</Box> </Box>
<TextField <TextField
label="Review notes" label={t("gmailReviewNotes")}
value={notes[thread.threadId] ?? ""} value={notes[thread.threadId] ?? ""}
onChange={(event) => setNotes((prev) => ({ ...prev, [thread.threadId]: event.target.value }))} onChange={(event) => setNotes((prev) => ({ ...prev, [thread.threadId]: event.target.value }))}
size="small" size="small"
@@ -198,11 +209,11 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
multiline multiline
minRows={2} minRows={2}
sx={{ mt: 1.25 }} sx={{ mt: 1.25 }}
placeholder="Why this should link, stay in review, or become a suggested job." placeholder={t("gmailReviewNotesPlaceholder")}
/> />
{suggestion ? ( {suggestion ? (
<Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}> <Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>
Suggested job: {suggestion.companyName || "Unknown company"} · {suggestion.suggestedJobTitle || "Unknown role"} {t("gmailReviewSuggestedJob", { company: suggestion.companyName || t("gmailReviewUnknownCompany"), role: suggestion.suggestedJobTitle || t("gmailReviewUnknownRole") })}
</Typography> </Typography>
) : null} ) : null}
</Box> </Box>
@@ -217,7 +228,7 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
))} ))}
{thread.jobCandidates[0] ? ( {thread.jobCandidates[0] ? (
<Button size="small" variant="text" onClick={() => navigate(`/jobs/${thread.jobCandidates[0].jobApplicationId}?section=communication`)}> <Button size="small" variant="text" onClick={() => navigate(`/jobs/${thread.jobCandidates[0].jobApplicationId}?section=communication`)}>
Open top job {t("gmailReviewOpenTopJob")}
</Button> </Button>
) : null} ) : null}
{thread.jobCandidates[0] ? ( {thread.jobCandidates[0] ? (
@@ -227,7 +238,7 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
disabled={savingThreadId === thread.threadId} disabled={savingThreadId === thread.threadId}
onClick={() => void saveDecision(thread.threadId, "linked", thread.jobCandidates[0].jobApplicationId)} onClick={() => void saveDecision(thread.threadId, "linked", thread.jobCandidates[0].jobApplicationId)}
> >
Link top job {t("gmailReviewLinkTopJob")}
</Button> </Button>
) : null} ) : null}
<Button <Button
@@ -237,7 +248,7 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
disabled={savingThreadId === thread.threadId} disabled={savingThreadId === thread.threadId}
onClick={() => void saveDecision(thread.threadId, "review")} onClick={() => void saveDecision(thread.threadId, "review")}
> >
Keep in review {t("gmailReviewKeepInReview")}
</Button> </Button>
<Button <Button
size="small" size="small"
@@ -246,7 +257,7 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
disabled={savingThreadId === thread.threadId} disabled={savingThreadId === thread.threadId}
onClick={() => void saveDecision(thread.threadId, "suggested")} onClick={() => void saveDecision(thread.threadId, "suggested")}
> >
Suggested job {t("gmailReviewSuggestedJobAction")}
</Button> </Button>
{suggestion ? ( {suggestion ? (
<Button <Button
@@ -255,7 +266,7 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
disabled={creatingThreadId === thread.threadId} disabled={creatingThreadId === thread.threadId}
onClick={() => void createSuggestedJob(thread.threadId)} onClick={() => void createSuggestedJob(thread.threadId)}
> >
{creatingThreadId === thread.threadId ? "Creating..." : "Create job"} {creatingThreadId === thread.threadId ? t("gmailReviewCreating") : t("createJob")}
</Button> </Button>
) : null} ) : null}
<Button <Button
@@ -265,7 +276,7 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
disabled={savingThreadId === thread.threadId} disabled={savingThreadId === thread.threadId}
onClick={() => void saveDecision(thread.threadId, "rejected")} onClick={() => void saveDecision(thread.threadId, "rejected")}
> >
Reject {t("gmailReviewReject")}
</Button> </Button>
</Box> </Box>
</Box> </Box>