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) => {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 - setGenerationMode(e.target.value as GenerationMode)}> + {t("jobDetailsGenerationDefault")} + {t("jobDetailsGenerationConcise")} + {t("jobDetailsGenerationAts")} + {t("jobDetailsGenerationAchievement")} + {t("jobDetailsGenerationInterview")} + }}>{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({ > setName(e.target.value)} fullWidth @@ -116,14 +119,14 @@ export default function SavedViewsMenu({ {!hasAny && ( - No saved views yet. + {t("savedViewsEmpty")} )} {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") } />