diff --git a/docs/jobbjakt-cleanup-tracker.md b/docs/jobbjakt-cleanup-tracker.md
index 6c401f6..24ab09c 100644
--- a/docs/jobbjakt-cleanup-tracker.md
+++ b/docs/jobbjakt-cleanup-tracker.md
@@ -63,6 +63,7 @@ Last updated: 2026-03-23
- [x] Expand translation infrastructure with interpolation support
- [x] Move major app shell/settings/profile/admin/create-job views onto translation system
- [x] Translate major table/kanban/edit flows further
+- [x] Localize job details, saved views, edit-job, attachments, and audit screens
- [ ] Finish remaining translation coverage in lingering dialogs/components
- [ ] Review English/Norwegian phrasing consistency across updated UI
diff --git a/job-tracker-ui/src/components/EditJobDialog.tsx b/job-tracker-ui/src/components/EditJobDialog.tsx
index 7b73eb7..cc80ab8 100644
--- a/job-tracker-ui/src/components/EditJobDialog.tsx
+++ b/job-tracker-ui/src/components/EditJobDialog.tsx
@@ -150,7 +150,7 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
onSaved();
onClose();
} catch {
- toast("Save failed.", "error");
+ toast(t("editJobSaveFailed"), "error");
} finally {
setLoading(false);
}
@@ -162,62 +162,62 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
- Update job details, timeline status, documents, and notes from one editing workspace.
+ {t("editJobIntro")}
- Application details
+ {t("editJobApplicationDetails")}
- c.name} value={company} onChange={(_, v) => setCompany(v)} renderInput={(params) => } />
- setJobTitle(e.target.value)} />
- setDateApplied(e.target.value)} InputLabelProps={{ shrink: true }} />
- setJobUrl(e.target.value)} />
+ c.name} value={company} onChange={(_, v) => setCompany(v)} renderInput={(params) => } />
+ setJobTitle(e.target.value)} />
+ setDateApplied(e.target.value)} InputLabelProps={{ shrink: true }} />
+ setJobUrl(e.target.value)} />
- Status update
+ {t("editJobStatusUpdate")}
- setStatus(e.target.value)}>
+ setStatus(e.target.value)}>
{STATUS_OPTIONS.map((s) => )}
- setStatusChangedAt(e.target.value)} InputLabelProps={{ shrink: true }} helperText={status === initialStatus ? "Only used when you change the status." : "This date will be recorded in the timeline."} />
- setResponseReceived(e.target.checked)} />} label="Reply received" />
- setResponseDate(e.target.value)} InputLabelProps={{ shrink: true }} />
- setNextAction(e.target.value)} />
- setFollowUpAt(e.target.value)} InputLabelProps={{ shrink: true }} />
+ setStatusChangedAt(e.target.value)} InputLabelProps={{ shrink: true }} helperText={status === initialStatus ? t("editJobStatusChangedHelpIdle") : t("editJobStatusChangedHelpActive")} />
+ setResponseReceived(e.target.checked)} />} label={t("editJobReplyReceived")} />
+ setResponseDate(e.target.value)} InputLabelProps={{ shrink: true }} />
+ setNextAction(e.target.value)} />
+ setFollowUpAt(e.target.value)} InputLabelProps={{ shrink: true }} />
- Role details
+ {t("editJobRoleDetails")}
- setLocation(e.target.value)} />
- setSalary(e.target.value)} />
- setDeadline(e.target.value)} InputLabelProps={{ shrink: true }} />
- setDescriptionLanguage(e.target.value)} />
+ setLocation(e.target.value)} />
+ setSalary(e.target.value)} />
+ setDeadline(e.target.value)} InputLabelProps={{ shrink: true }} />
+ setDescriptionLanguage(e.target.value)} />
- setNotes(e.target.value)} multiline rows={4} helperText={`${notes.length} characters`} sx={{ gridColumn: "1 / -1" }} />
- setDescription(e.target.value)} multiline rows={6} helperText={`${description.length} characters`} sx={{ gridColumn: "1 / -1" }} />
- setTranslatedDescription(e.target.value)} multiline rows={6} helperText={`${translatedDescription.length} characters`} sx={{ gridColumn: "1 / -1" }} />
- setCoverLetterText(e.target.value)} multiline rows={6} helperText={`${coverLetterText.length} characters`} sx={{ gridColumn: "1 / -1" }} />
+ setNotes(e.target.value)} multiline rows={4} helperText={t("correspondenceCharacters", { count: notes.length })} sx={{ gridColumn: "1 / -1" }} />
+ setDescription(e.target.value)} multiline rows={6} helperText={t("correspondenceCharacters", { count: description.length })} sx={{ gridColumn: "1 / -1" }} />
+ setTranslatedDescription(e.target.value)} multiline rows={6} helperText={t("correspondenceCharacters", { count: translatedDescription.length })} sx={{ gridColumn: "1 / -1" }} />
+ setCoverLetterText(e.target.value)} multiline rows={6} helperText={t("correspondenceCharacters", { count: coverLetterText.length })} sx={{ gridColumn: "1 / -1" }} />
- Attachments checklist
+ {t("editJobAttachmentsChecklist")}
-
-
-
+
+
+
- setHasResume(e.target.checked)} />} label="Resume" />
- setHasCoverLetter(e.target.checked)} />} label="Cover letter" />
- setHasPortfolio(e.target.checked)} />} label="Portfolio" />
- setHasOtherAttachment(e.target.checked)} />} label="Other attachment" />
+ setHasResume(e.target.checked)} />} label={t("editJobResume")} />
+ setHasCoverLetter(e.target.checked)} />} label={t("editJobCoverLetter")} />
+ setHasPortfolio(e.target.checked)} />} label={t("editJobPortfolio")} />
+ setHasOtherAttachment(e.target.checked)} />} label={t("editJobOtherAttachment")} />
diff --git a/job-tracker-ui/src/components/JobDetailsDialog.tsx b/job-tracker-ui/src/components/JobDetailsDialog.tsx
index b530588..13b923d 100644
--- a/job-tracker-ui/src/components/JobDetailsDialog.tsx
+++ b/job-tracker-ui/src/components/JobDetailsDialog.tsx
@@ -155,8 +155,13 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
})();
const title = job ? `${job.company?.name ?? ""} - ${job.jobTitle}` : t("addJobApplication");
- const checklist = [job?.hasResume ? "Resume" : null, job?.hasCoverLetter ? "Cover letter" : null, job?.hasPortfolio ? "Portfolio" : null, job?.hasOtherAttachment ? "Other" : null].filter(Boolean).join(", ") || "";
- const summaryFirstText = job?.fullSummary ?? job?.shortSummary ?? "No summary yet.";
+ const checklist = [
+ job?.hasResume ? t("jobDetailsResume") : null,
+ job?.hasCoverLetter ? t("jobDetailsCoverLetter") : null,
+ job?.hasPortfolio ? t("jobDetailsPortfolio") : null,
+ job?.hasOtherAttachment ? t("jobDetailsOther") : null,
+ ].filter(Boolean).join(", ") || t("jobDetailsNotAvailable");
+ const summaryFirstText = job?.fullSummary ?? job?.shortSummary ?? t("jobTableNoSummaryYet");
const translatedDescriptionText = job?.translatedDescription?.trim() || "";
const originalDescriptionText = job?.description?.trim() || "";
const showTranslatedText = translatedDescriptionText.length > 0;
@@ -182,61 +187,61 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
setTab(v)} sx={{ mb: 2 }} variant="scrollable" allowScrollButtonsMobile>
-
-
-
+
+
+
-
-
+
+
- {isAdmin ? : null}
+ {isAdmin ? : null}
{tab === 0 && (
- Date Applied{job ? new Date(job.dateApplied).toLocaleDateString() : ""}
- Days Since{job?.daysSince ?? ""}
- Location{job?.location ?? ""}
- Salary{job?.salary ?? ""}
- Next Action{job?.nextAction ?? ""}
- Follow Up{job?.followUpAt ? new Date(job.followUpAt).toLocaleDateString() : ""}
- Deadline{job?.deadline ? new Date(job.deadline).toLocaleDateString() : ""}
- Tags{tags.length === 0 ? - : tags.map((t) => )}
- Attachment Types{checklist}
- Job URL{job?.jobUrl ? {job.jobUrl} : ""}
+ {t("jobDetailsDateApplied")}{job ? new Date(job.dateApplied).toLocaleDateString() : ""}
+ {t("jobDetailsDaysSince")}{job?.daysSince ?? ""}
+ {t("jobTableLocation")}{job?.location ?? ""}
+ {t("jobDetailsSalary")}{job?.salary ?? ""}
+ {t("jobDetailsNextAction")}{job?.nextAction ?? ""}
+ {t("jobDetailsFollowUp")}{job?.followUpAt ? new Date(job.followUpAt).toLocaleDateString() : ""}
+ {t("jobDetailsDeadline")}{job?.deadline ? new Date(job.deadline).toLocaleDateString() : ""}
+ {t("jobDetailsTags")}{tags.length === 0 ? - : tags.map((t) => )}
+ {t("jobDetailsAttachmentTypes")}{checklist}
+ {t("jobDetailsJobUrl")}{job?.jobUrl ? {job.jobUrl} : ""}
- Summary and skills
+ {t("jobDetailsSummaryAndSkills")}
+ }}>{refreshingAi ? t("jobDetailsRefreshing") : t("jobDetailsRefreshAi")}
{summaryFirstText}
{showTranslatedText ? (
- Translated role text
+ {t("jobDetailsTranslatedRoleText")}
{translatedDescriptionText}
) : null}
{showOriginalText ? (
- Original role text
+ {t("jobDetailsOriginalRoleText")}
{originalDescriptionText}
) : null}
- Notes{job?.notes ?? ""}
+ {t("editJobNotes")}{job?.notes ?? ""}
)}
@@ -247,27 +252,27 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
- Tailored CV for this role
+ {t("jobDetailsTabTailoredCv")}
- Generation mode
-
+ }}>{t("jobDetailsStartFromMasterCv")}
-
-
+ }}>{generatingPackage ? t("jobDetailsGeneratingPackage") : t("jobDetailsGeneratePackage")}
+
+
+ }}>{savingTailoredCv ? t("jobDetailsSaving") : t("jobDetailsSaveTailoredCv")}
- Generate a full application package, then edit and save the tailored resume you actually want to use for this role.
- setTailoredCvText(e.target.value)} multiline minRows={14} fullWidth placeholder="Paste or rewrite the version of your CV you want to use for this role." />
- Last updated: {job?.tailoredCvUpdatedAt ? new Date(job.tailoredCvUpdatedAt).toLocaleString() : "Not saved yet"}
+ {t("jobDetailsTailoredCvIntro")}
+ setTailoredCvText(e.target.value)} multiline minRows={14} fullWidth placeholder={t("jobDetailsTailoredCvPlaceholder")} />
+ {t("jobDetailsLastUpdated", { value: job?.tailoredCvUpdatedAt ? new Date(job.tailoredCvUpdatedAt).toLocaleString() : t("jobDetailsNotSavedYet") })}
{applicationPackage ? (
- {
+ {
if (!jobId) return;
setSavingApplicationDrafts(true);
try {
await api.put(`/jobapplications/${jobId}/application-drafts`, { coverLetterText: content });
setJob((prev) => prev ? { ...prev, coverLetterText: content } : prev);
setReadiness(null);
- toast("Cover letter saved to this job.", "success");
+ toast(t("jobDetailsCoverLetterSaved"), "success");
} catch (error: any) {
- toast(getApiErrorMessage(error, "Failed to save cover letter."), "error");
+ toast(getApiErrorMessage(error, t("jobDetailsCoverLetterSaveFailed")), "error");
} finally {
setSavingApplicationDrafts(false);
}
}} saving={savingApplicationDrafts} />
- {
+ {
if (!jobId) return;
setSavingApplicationDrafts(true);
try {
await api.put(`/jobapplications/${jobId}/application-drafts`, { notes: `Application answer draft:\n${content}` });
setReadiness(null);
- toast("Application answer saved to notes.", "success");
+ toast(t("jobDetailsApplicationAnswerSaved"), "success");
} catch (error: any) {
- toast(getApiErrorMessage(error, "Failed to save application answer."), "error");
+ toast(getApiErrorMessage(error, t("jobDetailsApplicationAnswerSaveFailed")), "error");
} finally {
setSavingApplicationDrafts(false);
}
}} saving={savingApplicationDrafts} />
- {
+ {
if (!jobId) return;
setSavingApplicationDrafts(true);
try {
await api.put(`/jobapplications/${jobId}/application-drafts`, { recruiterMessageDraft: content });
setJob((prev) => prev ? { ...prev, recruiterMessageDraft: content } : prev);
- toast("Recruiter message saved to this job.", "success");
+ toast(t("jobDetailsRecruiterMessageSaved"), "success");
} catch (error: any) {
- toast(getApiErrorMessage(error, "Failed to save recruiter message."), "error");
+ toast(getApiErrorMessage(error, t("jobDetailsRecruiterMessageSaveFailed")), "error");
} finally {
setSavingApplicationDrafts(false);
}
}} saving={savingApplicationDrafts} />
-
+
) : null}
@@ -358,13 +363,13 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
{loadingDraft ? : followUpDraft ? (
- Reason{followUpDraft.reason}
- Suggested send date{new Date(followUpDraft.suggestedSendOn).toLocaleDateString()}
- setDraftRecipient(e.target.value)} helperText="Defaults to the company recruiter email when available." />
- setDraftSubject(e.target.value)} />
- setDraftBody(e.target.value)} />
+ {t("jobDetailsReason")}{followUpDraft.reason}
+ {t("jobDetailsSuggestedSendDate")}{new Date(followUpDraft.suggestedSendOn).toLocaleDateString()}
+ setDraftRecipient(e.target.value)} helperText={t("jobDetailsRecipientHelp")} />
+ setDraftSubject(e.target.value)} />
+ setDraftBody(e.target.value)} />
-
+
+ }}>{sendingDraft ? t("jobDetailsSending") : t("jobDetailsSendAndLogEmail")}
- ) : No draft available.}
+ ) : {t("jobDetailsNoDraftAvailable")}}
)}
@@ -390,25 +395,25 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
{loadingCandidateFit ? : candidateFit ? (
- How you match{candidateFit.matchSummary}
+ {t("jobDetailsHowYouMatch")}{candidateFit.matchSummary}
- = 75 ? "success" : candidateFit.matchScore >= 55 ? "warning" : "default"} size="small" />
+ = 75 ? "success" : candidateFit.matchScore >= 55 ? "warning" : "default"} size="small" />
{fitLevel ? : null}
-
-
-
-
-
-
-
+
+
+
+
+
+
+
-
-
+
+
- ) : Add your profile CV text on the Profile page to generate a candidate fit analysis for this role.}
+ ) : {t("jobDetailsCandidateFitEmpty")}}
)}
@@ -416,11 +421,11 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
{loadingInterviewPrep ? : interviewPrep ? (
-
-
-
+
+
+
- ) : No interview prep available yet.}
+ ) : {t("jobDetailsNoInterviewPrep")}}
)}
@@ -429,22 +434,22 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
{loadingReadiness ? : readiness ? (
- Application readiness
+ {t("jobDetailsApplicationReadiness")}
- = 80 ? "success" : readiness.score >= 60 ? "warning" : "default"} />
+ = 80 ? "success" : readiness.score >= 60 ? "warning" : "default"} />
-
-
+
+
- ) : No readiness analysis available yet.}
+ ) : {t("jobDetailsNoReadiness")}}
)}
{tab === 8 && isAdmin && (
- {history.length === 0 ? No history yet. : history.map((entry) => )}
+ {history.length === 0 ? {t("jobDetailsNoHistory")} : history.map((entry) => )}
)}
@@ -453,14 +458,16 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
}
function SectionChips({ title, items, color, outlined }: { title: string; items: string[]; color: "success" | "warning"; outlined?: boolean }) {
+ const { t } = useI18n();
+
return (
{title}
-
+
- {items.length ? items.map((item) => ) : Nothing highlighted yet.}
+ {items.length ? items.map((item) => ) : {t("jobDetailsNothingHighlighted")}}
);
@@ -476,20 +483,23 @@ function TwoColumnSection({ leftTitle, leftItems, rightTitle, rightItems }: { le
}
function ListCard({ title, items }: { title: string; items: string[] }) {
+ const { t } = useI18n();
+
return (
{title}
-
+
- {items.length ? items.map((item, index) => • {item}) : Nothing highlighted yet.}
+ {items.length ? items.map((item, index) => • {item}) : {t("jobDetailsNothingHighlighted")}}
);
}
function DraftCard({ title, content, onSave, saving }: { title: string; content: string; onSave?: (content: string) => Promise | void; saving?: boolean }) {
+ const { t } = useI18n();
const [value, setValue] = React.useState(content);
React.useEffect(() => {
@@ -501,8 +511,8 @@ function DraftCard({ title, content, onSave, saving }: { title: string; content:
{title}
-
- {onSave ? : null}
+
+ {onSave ? : null}
setValue(e.target.value)} multiline minRows={6} fullWidth />
diff --git a/job-tracker-ui/src/components/SavedViewsMenu.tsx b/job-tracker-ui/src/components/SavedViewsMenu.tsx
index caf47cf..c2fd23d 100644
--- a/job-tracker-ui/src/components/SavedViewsMenu.tsx
+++ b/job-tracker-ui/src/components/SavedViewsMenu.tsx
@@ -14,6 +14,8 @@ import {
import BookmarkBorderIcon from "@mui/icons-material/BookmarkBorder";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
+import { useI18n } from "../i18n/I18nProvider";
+
export type SavedViewParams = {
q?: string;
status?: string;
@@ -54,6 +56,7 @@ export default function SavedViewsMenu({
current: SavedViewParams;
onApply: (p: SavedViewParams) => void;
}) {
+ const { t } = useI18n();
const [anchor, setAnchor] = useState(null);
const [name, setName] = useState("");
const [views, setViews] = useState(() => loadViews());
@@ -86,7 +89,7 @@ export default function SavedViewsMenu({
return (
<>
-
+
setAnchor(e.currentTarget)}>
@@ -100,15 +103,15 @@ export default function SavedViewsMenu({
>
{!hasAny && (
-
+
)}
{views.map((v) => (
@@ -139,12 +142,12 @@ export default function SavedViewsMenu({
primary={v.name}
secondary={
[
- v.params.status ? `Status: ${v.params.status}` : null,
- v.params.location ? `Loc: ${v.params.location}` : null,
- v.params.needsFollowUp ? "Needs follow-up" : null,
+ v.params.status ? t("savedViewsStatusLabel", { value: v.params.status }) : null,
+ v.params.location ? t("savedViewsLocationLabel", { value: v.params.location }) : null,
+ v.params.needsFollowUp ? t("savedViewsNeedsFollowUp") : null,
]
.filter(Boolean)
- .join(" · ") || "No filters"
+ .join(" · ") || t("savedViewsNoFilters")
}
/>