Translate job details and saved views flows

This commit is contained in:
cesnimda
2026-03-23 20:56:33 +01:00
parent 7f59a46cc6
commit 9661a321da
5 changed files with 435 additions and 137 deletions
+1
View File
@@ -63,6 +63,7 @@ Last updated: 2026-03-23
- [x] Expand translation infrastructure with interpolation support - [x] Expand translation infrastructure with interpolation support
- [x] Move major app shell/settings/profile/admin/create-job views onto translation system - [x] Move major app shell/settings/profile/admin/create-job views onto translation system
- [x] Translate major table/kanban/edit flows further - [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 - [ ] Finish remaining translation coverage in lingering dialogs/components
- [ ] Review English/Norwegian phrasing consistency across updated UI - [ ] Review English/Norwegian phrasing consistency across updated UI
+31 -31
View File
@@ -150,7 +150,7 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
onSaved(); onSaved();
onClose(); onClose();
} catch { } catch {
toast("Save failed.", "error"); toast(t("editJobSaveFailed"), "error");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -162,62 +162,62 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
<DialogContent> <DialogContent>
<Box sx={{ mt: 1, mb: 2, p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}> <Box sx={{ mt: 1, mb: 2, p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
<Typography variant="body2" sx={{ color: "text.secondary" }}> <Typography variant="body2" sx={{ color: "text.secondary" }}>
Update job details, timeline status, documents, and notes from one editing workspace. {t("editJobIntro")}
</Typography> </Typography>
</Box> </Box>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2, mt: 1 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 2, mt: 1 }}>
<Paper variant="outlined" sx={{ p: 2 }}> <Paper variant="outlined" sx={{ p: 2 }}>
<Typography variant="overline" sx={{ color: "text.secondary" }}>Application details</Typography> <Typography variant="overline" sx={{ color: "text.secondary" }}>{t("editJobApplicationDetails")}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2, mt: 1 }}> <Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2, mt: 1 }}>
<Autocomplete options={companies} getOptionLabel={(c) => c.name} value={company} onChange={(_, v) => setCompany(v)} renderInput={(params) => <TextField {...params} label="Company" />} /> <Autocomplete options={companies} getOptionLabel={(c) => c.name} value={company} onChange={(_, v) => setCompany(v)} renderInput={(params) => <TextField {...params} label={t("company")} />} />
<TextField label="Job title" value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} /> <TextField label={t("editJobJobTitle")} value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} />
<TextField label="Applied on" type="date" value={dateApplied} onChange={(e) => setDateApplied(e.target.value)} InputLabelProps={{ shrink: true }} /> <TextField label={t("editJobAppliedOn")} type="date" value={dateApplied} onChange={(e) => setDateApplied(e.target.value)} InputLabelProps={{ shrink: true }} />
<TextField label="Job URL" value={jobUrl} onChange={(e) => setJobUrl(e.target.value)} /> <TextField label={t("addJobModalJobUrl")} value={jobUrl} onChange={(e) => setJobUrl(e.target.value)} />
</Box> </Box>
</Paper> </Paper>
<Paper variant="outlined" sx={{ p: 2 }}> <Paper variant="outlined" sx={{ p: 2 }}>
<Typography variant="overline" sx={{ color: "text.secondary" }}>Status update</Typography> <Typography variant="overline" sx={{ color: "text.secondary" }}>{t("editJobStatusUpdate")}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr" }, gap: 2, mt: 1 }}> <Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr" }, gap: 2, mt: 1 }}>
<TextField select label="Current status" value={status} onChange={(e) => setStatus(e.target.value)}> <TextField select label={t("editJobCurrentStatus")} value={status} onChange={(e) => setStatus(e.target.value)}>
{STATUS_OPTIONS.map((s) => <MenuItem key={s} value={s}>{s}</MenuItem>)} {STATUS_OPTIONS.map((s) => <MenuItem key={s} value={s}>{s}</MenuItem>)}
</TextField> </TextField>
<TextField label="Status changed on" type="date" value={statusChangedAt} onChange={(e) => 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."} /> <TextField label={t("editJobStatusChangedOn")} type="date" value={statusChangedAt} onChange={(e) => setStatusChangedAt(e.target.value)} InputLabelProps={{ shrink: true }} helperText={status === initialStatus ? t("editJobStatusChangedHelpIdle") : t("editJobStatusChangedHelpActive")} />
<Box sx={{ display: "flex", alignItems: "center" }}><FormControlLabel control={<Checkbox checked={responseReceived} onChange={(e) => setResponseReceived(e.target.checked)} />} label="Reply received" /></Box> <Box sx={{ display: "flex", alignItems: "center" }}><FormControlLabel control={<Checkbox checked={responseReceived} onChange={(e) => setResponseReceived(e.target.checked)} />} label={t("editJobReplyReceived")} /></Box>
<TextField label="Reply received on" type="date" disabled={!responseReceived} value={responseDate} onChange={(e) => setResponseDate(e.target.value)} InputLabelProps={{ shrink: true }} /> <TextField label={t("editJobReplyReceivedOn")} type="date" disabled={!responseReceived} value={responseDate} onChange={(e) => setResponseDate(e.target.value)} InputLabelProps={{ shrink: true }} />
<TextField label="Next action" value={nextAction} onChange={(e) => setNextAction(e.target.value)} /> <TextField label={t("editJobNextAction")} value={nextAction} onChange={(e) => setNextAction(e.target.value)} />
<TextField label="Follow up on" type="date" value={followUpAt} onChange={(e) => setFollowUpAt(e.target.value)} InputLabelProps={{ shrink: true }} /> <TextField label={t("editJobFollowUpOn")} type="date" value={followUpAt} onChange={(e) => setFollowUpAt(e.target.value)} InputLabelProps={{ shrink: true }} />
</Box> </Box>
</Paper> </Paper>
<Paper variant="outlined" sx={{ p: 2 }}> <Paper variant="outlined" sx={{ p: 2 }}>
<Typography variant="overline" sx={{ color: "text.secondary" }}>Role details</Typography> <Typography variant="overline" sx={{ color: "text.secondary" }}>{t("editJobRoleDetails")}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2, mt: 1 }}> <Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2, mt: 1 }}>
<TextField label="Location" value={location} onChange={(e) => setLocation(e.target.value)} /> <TextField label={t("location")} value={location} onChange={(e) => setLocation(e.target.value)} />
<TextField label="Salary" value={salary} onChange={(e) => setSalary(e.target.value)} /> <TextField label={t("addJobModalSalary")} value={salary} onChange={(e) => setSalary(e.target.value)} />
<TextField label="Deadline" type="date" value={deadline} onChange={(e) => setDeadline(e.target.value)} InputLabelProps={{ shrink: true }} /> <TextField label={t("editJobDeadline")} type="date" value={deadline} onChange={(e) => setDeadline(e.target.value)} InputLabelProps={{ shrink: true }} />
<TextField label="Description language" value={descriptionLanguage} onChange={(e) => setDescriptionLanguage(e.target.value)} /> <TextField label={t("editJobDescriptionLanguage")} value={descriptionLanguage} onChange={(e) => setDescriptionLanguage(e.target.value)} />
<Box sx={{ gridColumn: "1 / -1" }}><TagsInput value={tags} onChange={setTags} /></Box> <Box sx={{ gridColumn: "1 / -1" }}><TagsInput value={tags} onChange={setTags} /></Box>
<TextField label="Notes" value={notes} onChange={(e) => setNotes(e.target.value)} multiline rows={4} helperText={`${notes.length} characters`} sx={{ gridColumn: "1 / -1" }} /> <TextField label={t("editJobNotes")} value={notes} onChange={(e) => setNotes(e.target.value)} multiline rows={4} helperText={t("correspondenceCharacters", { count: notes.length })} sx={{ gridColumn: "1 / -1" }} />
<TextField label="Description (original)" value={description} onChange={(e) => setDescription(e.target.value)} multiline rows={6} helperText={`${description.length} characters`} sx={{ gridColumn: "1 / -1" }} /> <TextField label={t("editJobDescriptionOriginal")} value={description} onChange={(e) => setDescription(e.target.value)} multiline rows={6} helperText={t("correspondenceCharacters", { count: description.length })} sx={{ gridColumn: "1 / -1" }} />
<TextField label="Translated description" value={translatedDescription} onChange={(e) => setTranslatedDescription(e.target.value)} multiline rows={6} helperText={`${translatedDescription.length} characters`} sx={{ gridColumn: "1 / -1" }} /> <TextField label={t("editJobTranslatedDescription")} value={translatedDescription} onChange={(e) => setTranslatedDescription(e.target.value)} multiline rows={6} helperText={t("correspondenceCharacters", { count: translatedDescription.length })} sx={{ gridColumn: "1 / -1" }} />
<TextField label="Cover letter" value={coverLetterText} onChange={(e) => setCoverLetterText(e.target.value)} multiline rows={6} helperText={`${coverLetterText.length} characters`} sx={{ gridColumn: "1 / -1" }} /> <TextField label={t("editJobCoverLetter")} value={coverLetterText} onChange={(e) => setCoverLetterText(e.target.value)} multiline rows={6} helperText={t("correspondenceCharacters", { count: coverLetterText.length })} sx={{ gridColumn: "1 / -1" }} />
</Box> </Box>
</Paper> </Paper>
<Paper variant="outlined" sx={{ p: 2 }}> <Paper variant="outlined" sx={{ p: 2 }}>
<Typography variant="overline" sx={{ color: "text.secondary" }}>Attachments checklist</Typography> <Typography variant="overline" sx={{ color: "text.secondary" }}>{t("editJobAttachmentsChecklist")}</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 1, mb: 1.5 }}> <Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 1, mb: 1.5 }}>
<Chip size="small" label={hasResume ? "Resume ready" : "Resume missing"} color={hasResume ? "success" : "default"} variant={hasResume ? "filled" : "outlined"} /> <Chip size="small" label={hasResume ? t("editJobResumeReady") : t("editJobResumeMissing")} color={hasResume ? "success" : "default"} variant={hasResume ? "filled" : "outlined"} />
<Chip size="small" label={hasCoverLetter ? "Cover letter ready" : "Cover letter missing"} color={hasCoverLetter ? "success" : "default"} variant={hasCoverLetter ? "filled" : "outlined"} /> <Chip size="small" label={hasCoverLetter ? t("editJobCoverLetterReady") : t("editJobCoverLetterMissing")} color={hasCoverLetter ? "success" : "default"} variant={hasCoverLetter ? "filled" : "outlined"} />
<Chip size="small" label={hasPortfolio ? "Portfolio ready" : "Portfolio optional"} color={hasPortfolio ? "success" : "default"} variant={hasPortfolio ? "filled" : "outlined"} /> <Chip size="small" label={hasPortfolio ? t("editJobPortfolioReady") : t("editJobPortfolioOptional")} color={hasPortfolio ? "success" : "default"} variant={hasPortfolio ? "filled" : "outlined"} />
</Box> </Box>
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap", mt: 1 }}> <Box sx={{ display: "flex", gap: 2, flexWrap: "wrap", mt: 1 }}>
<FormControlLabel control={<Checkbox checked={hasResume} onChange={(e) => setHasResume(e.target.checked)} />} label="Resume" /> <FormControlLabel control={<Checkbox checked={hasResume} onChange={(e) => setHasResume(e.target.checked)} />} label={t("editJobResume")} />
<FormControlLabel control={<Checkbox checked={hasCoverLetter} onChange={(e) => setHasCoverLetter(e.target.checked)} />} label="Cover letter" /> <FormControlLabel control={<Checkbox checked={hasCoverLetter} onChange={(e) => setHasCoverLetter(e.target.checked)} />} label={t("editJobCoverLetter")} />
<FormControlLabel control={<Checkbox checked={hasPortfolio} onChange={(e) => setHasPortfolio(e.target.checked)} />} label="Portfolio" /> <FormControlLabel control={<Checkbox checked={hasPortfolio} onChange={(e) => setHasPortfolio(e.target.checked)} />} label={t("editJobPortfolio")} />
<FormControlLabel control={<Checkbox checked={hasOtherAttachment} onChange={(e) => setHasOtherAttachment(e.target.checked)} />} label="Other attachment" /> <FormControlLabel control={<Checkbox checked={hasOtherAttachment} onChange={(e) => setHasOtherAttachment(e.target.checked)} />} label={t("editJobOtherAttachment")} />
</Box> </Box>
</Paper> </Paper>
</Box> </Box>
@@ -155,8 +155,13 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
})(); })();
const title = job ? `${job.company?.name ?? ""} - ${job.jobTitle}` : t("addJobApplication"); 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 checklist = [
const summaryFirstText = job?.fullSummary ?? job?.shortSummary ?? "No summary yet."; 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 translatedDescriptionText = job?.translatedDescription?.trim() || "";
const originalDescriptionText = job?.description?.trim() || ""; const originalDescriptionText = job?.description?.trim() || "";
const showTranslatedText = translatedDescriptionText.length > 0; const showTranslatedText = translatedDescriptionText.length > 0;
@@ -182,61 +187,61 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
</Box> </Box>
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }} variant="scrollable" allowScrollButtonsMobile> <Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }} variant="scrollable" allowScrollButtonsMobile>
<Tab label={t("jobTableOverview")} /> <Tab label={t("jobTableOverview")} />
<Tab label="Correspondence" /> <Tab label={t("jobDetailsTabCorrespondence")} />
<Tab label="Attachments" /> <Tab label={t("jobDetailsTabAttachments")} />
<Tab label="Tailored CV" /> <Tab label={t("jobDetailsTabTailoredCv")} />
<Tab label={t("jobTableFollowUp")} /> <Tab label={t("jobTableFollowUp")} />
<Tab label="Candidate fit" /> <Tab label={t("jobDetailsTabCandidateFit")} />
<Tab label="Interview prep" /> <Tab label={t("jobDetailsTabInterviewPrep")} />
<Tab label={t("jobTableReadiness")} /> <Tab label={t("jobTableReadiness")} />
{isAdmin ? <Tab label="History" /> : null} {isAdmin ? <Tab label={t("jobDetailsTabHistory")} /> : null}
</Tabs> </Tabs>
{tab === 0 && ( {tab === 0 && (
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 2 }}> <Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 2 }}>
<Box><Typography variant="overline">Date Applied</Typography><Typography>{job ? new Date(job.dateApplied).toLocaleDateString() : ""}</Typography></Box> <Box><Typography variant="overline">{t("jobDetailsDateApplied")}</Typography><Typography>{job ? new Date(job.dateApplied).toLocaleDateString() : ""}</Typography></Box>
<Box><Typography variant="overline">Days Since</Typography><Typography>{job?.daysSince ?? ""}</Typography></Box> <Box><Typography variant="overline">{t("jobDetailsDaysSince")}</Typography><Typography>{job?.daysSince ?? ""}</Typography></Box>
<Box><Typography variant="overline">Location</Typography><Typography>{job?.location ?? ""}</Typography></Box> <Box><Typography variant="overline">{t("jobTableLocation")}</Typography><Typography>{job?.location ?? ""}</Typography></Box>
<Box><Typography variant="overline">Salary</Typography><Typography>{job?.salary ?? ""}</Typography></Box> <Box><Typography variant="overline">{t("jobDetailsSalary")}</Typography><Typography>{job?.salary ?? ""}</Typography></Box>
<Box><Typography variant="overline">Next Action</Typography><Typography>{job?.nextAction ?? ""}</Typography></Box> <Box><Typography variant="overline">{t("jobDetailsNextAction")}</Typography><Typography>{job?.nextAction ?? ""}</Typography></Box>
<Box><Typography variant="overline">Follow Up</Typography><Typography>{job?.followUpAt ? new Date(job.followUpAt).toLocaleDateString() : ""}</Typography></Box> <Box><Typography variant="overline">{t("jobDetailsFollowUp")}</Typography><Typography>{job?.followUpAt ? new Date(job.followUpAt).toLocaleDateString() : ""}</Typography></Box>
<Box><Typography variant="overline">Deadline</Typography><Typography>{job?.deadline ? new Date(job.deadline).toLocaleDateString() : ""}</Typography></Box> <Box><Typography variant="overline">{t("jobDetailsDeadline")}</Typography><Typography>{job?.deadline ? new Date(job.deadline).toLocaleDateString() : ""}</Typography></Box>
<Box><Typography variant="overline">Tags</Typography><Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.5 }}>{tags.length === 0 ? <Typography sx={{ color: "text.secondary" }}>-</Typography> : tags.map((t) => <Chip key={t} label={t} size="small" />)}</Box></Box> <Box><Typography variant="overline">{t("jobDetailsTags")}</Typography><Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.5 }}>{tags.length === 0 ? <Typography sx={{ color: "text.secondary" }}>-</Typography> : tags.map((t) => <Chip key={t} label={t} size="small" />)}</Box></Box>
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">Attachment Types</Typography><Typography>{checklist}</Typography></Box> <Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">{t("jobDetailsAttachmentTypes")}</Typography><Typography>{checklist}</Typography></Box>
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">Job URL</Typography><Typography>{job?.jobUrl ? <a href={job.jobUrl} target="_blank" rel="noreferrer">{job.jobUrl}</a> : ""}</Typography></Box> <Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">{t("jobDetailsJobUrl")}</Typography><Typography>{job?.jobUrl ? <a href={job.jobUrl} target="_blank" rel="noreferrer">{job.jobUrl}</a> : ""}</Typography></Box>
<Box sx={{ gridColumn: "1 / -1", mt: 1 }}> <Box sx={{ gridColumn: "1 / -1", mt: 1 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", flexWrap: "wrap", mb: 0.5 }}> <Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", flexWrap: "wrap", mb: 0.5 }}>
<Typography variant="overline">Summary and skills</Typography> <Typography variant="overline">{t("jobDetailsSummaryAndSkills")}</Typography>
<Button size="small" variant="outlined" disabled={refreshingAi} onClick={async () => { <Button size="small" variant="outlined" disabled={refreshingAi} onClick={async () => {
if (!jobId) return; if (!jobId) return;
if (!(await confirmAction("Overwrite the current summary and skills with a freshly generated version?", { title: "Refresh AI summary", confirmLabel: "Refresh" }))) return; if (!(await confirmAction(t("jobDetailsRefreshAiConfirm"), { title: t("jobDetailsRefreshAiTitle"), confirmLabel: t("jobDetailsRefreshAi") }))) return;
setRefreshingAi(true); setRefreshingAi(true);
try { try {
const res = await api.post<JobApplication>(`/jobapplications/${jobId}/refresh-ai`); const res = await api.post<JobApplication>(`/jobapplications/${jobId}/refresh-ai`);
setJob(res.data); setJob(res.data);
toast("Summary and skills refreshed.", "success"); toast(t("jobDetailsSummaryRefreshed"), "success");
} catch (error: any) { } catch (error: any) {
toast(getApiErrorMessage(error, "Failed to refresh summary and skills."), "error"); toast(getApiErrorMessage(error, t("jobDetailsSummaryRefreshFailed")), "error");
} finally { } finally {
setRefreshingAi(false); setRefreshingAi(false);
} }
}}>{refreshingAi ? "Refreshing..." : "Refresh summary and skills"}</Button> }}>{refreshingAi ? t("jobDetailsRefreshing") : t("jobDetailsRefreshAi")}</Button>
</Box> </Box>
<Typography sx={{ whiteSpace: "pre-wrap" }}>{summaryFirstText}</Typography> <Typography sx={{ whiteSpace: "pre-wrap" }}>{summaryFirstText}</Typography>
</Box> </Box>
{showTranslatedText ? ( {showTranslatedText ? (
<Box sx={{ gridColumn: "1 / -1" }}> <Box sx={{ gridColumn: "1 / -1" }}>
<Typography variant="overline">Translated role text</Typography> <Typography variant="overline">{t("jobDetailsTranslatedRoleText")}</Typography>
<Typography sx={{ whiteSpace: "pre-wrap" }}>{translatedDescriptionText}</Typography> <Typography sx={{ whiteSpace: "pre-wrap" }}>{translatedDescriptionText}</Typography>
</Box> </Box>
) : null} ) : null}
{showOriginalText ? ( {showOriginalText ? (
<Box sx={{ gridColumn: "1 / -1" }}> <Box sx={{ gridColumn: "1 / -1" }}>
<Typography variant="overline">Original role text</Typography> <Typography variant="overline">{t("jobDetailsOriginalRoleText")}</Typography>
<Typography sx={{ whiteSpace: "pre-wrap", color: "text.secondary" }}>{originalDescriptionText}</Typography> <Typography sx={{ whiteSpace: "pre-wrap", color: "text.secondary" }}>{originalDescriptionText}</Typography>
</Box> </Box>
) : null} ) : null}
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">Notes</Typography><Typography sx={{ whiteSpace: "pre-wrap" }}>{job?.notes ?? ""}</Typography></Box> <Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">{t("editJobNotes")}</Typography><Typography sx={{ whiteSpace: "pre-wrap" }}>{job?.notes ?? ""}</Typography></Box>
</Box> </Box>
)} )}
@@ -247,27 +252,27 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}> <Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}> <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
<Typography variant="overline">Tailored CV for this role</Typography> <Typography variant="overline">{t("jobDetailsTabTailoredCv")}</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}> <Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
<FormControl size="small" sx={{ minWidth: 180 }}> <FormControl size="small" sx={{ minWidth: 180 }}>
<InputLabel>Generation mode</InputLabel> <InputLabel>{t("jobDetailsTailoredCvMode")}</InputLabel>
<Select value={generationMode} label="Generation mode" onChange={(e) => setGenerationMode(e.target.value as GenerationMode)}> <Select value={generationMode} label={t("jobDetailsTailoredCvMode")} onChange={(e) => setGenerationMode(e.target.value as GenerationMode)}>
<MenuItem value="default">Balanced</MenuItem> <MenuItem value="default">{t("jobDetailsGenerationDefault")}</MenuItem>
<MenuItem value="concise">Concise</MenuItem> <MenuItem value="concise">{t("jobDetailsGenerationConcise")}</MenuItem>
<MenuItem value="ats">ATS focused</MenuItem> <MenuItem value="ats">{t("jobDetailsGenerationAts")}</MenuItem>
<MenuItem value="achievement">Achievement focused</MenuItem> <MenuItem value="achievement">{t("jobDetailsGenerationAchievement")}</MenuItem>
<MenuItem value="interview">Interview focused</MenuItem> <MenuItem value="interview">{t("jobDetailsGenerationInterview")}</MenuItem>
</Select> </Select>
</FormControl> </FormControl>
<Button size="small" variant="outlined" onClick={async () => { <Button size="small" variant="outlined" onClick={async () => {
try { try {
const me = await api.get<{ profileCvText?: string | null }>("/auth/me"); const me = await api.get<{ profileCvText?: string | null }>("/auth/me");
setTailoredCvText(me.data?.profileCvText ?? ""); setTailoredCvText(me.data?.profileCvText ?? "");
toast("Loaded your master CV into the tailored editor.", "success"); toast(t("jobDetailsLoadedMasterCv"), "success");
} catch { } catch {
toast("Failed to load your master CV.", "error"); toast(t("jobDetailsLoadMasterCvFailed"), "error");
} }
}}>Start from master CV</Button> }}>{t("jobDetailsStartFromMasterCv")}</Button>
<Button size="small" variant="outlined" disabled={generatingPackage} onClick={async () => { <Button size="small" variant="outlined" disabled={generatingPackage} onClick={async () => {
if (!jobId) return; if (!jobId) return;
setGeneratingPackage(true); setGeneratingPackage(true);
@@ -275,15 +280,15 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
const res = await api.post<ApplicationPackageResponse>(`/jobapplications/${jobId}/generate-application-package`, null, { params: { mode: generationMode } }); const res = await api.post<ApplicationPackageResponse>(`/jobapplications/${jobId}/generate-application-package`, null, { params: { mode: generationMode } });
setApplicationPackage(res.data); setApplicationPackage(res.data);
setTailoredCvText(res.data.tailoredCvText ?? ""); setTailoredCvText(res.data.tailoredCvText ?? "");
toast("Application package generated.", "success"); toast(t("jobDetailsPackageGenerated"), "success");
} catch (error: any) { } catch (error: any) {
toast(getApiErrorMessage(error, "Failed to generate application package."), "error"); toast(getApiErrorMessage(error, t("jobDetailsPackageGenerationFailed")), "error");
} finally { } finally {
setGeneratingPackage(false); setGeneratingPackage(false);
} }
}}>{generatingPackage ? "Generating..." : "Generate application package"}</Button> }}>{generatingPackage ? t("jobDetailsGeneratingPackage") : t("jobDetailsGeneratePackage")}</Button>
<Button size="small" variant="outlined" onClick={() => setTailoredCvText("")}>Clear</Button> <Button size="small" variant="outlined" onClick={() => setTailoredCvText("")}>{t("jobDetailsClear")}</Button>
<Button size="small" variant="outlined" onClick={() => navigator.clipboard.writeText(tailoredCvText)}>Copy</Button> <Button size="small" variant="outlined" onClick={() => navigator.clipboard.writeText(tailoredCvText)}>{t("jobDetailsCopy")}</Button>
<Button size="small" variant="contained" disabled={savingTailoredCv} onClick={async () => { <Button size="small" variant="contained" disabled={savingTailoredCv} onClick={async () => {
if (!jobId) return; if (!jobId) return;
setSavingTailoredCv(true); setSavingTailoredCv(true);
@@ -292,63 +297,63 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
setJob((prev) => prev ? { ...prev, tailoredCvText, tailoredCvUpdatedAt: new Date().toISOString() } : prev); setJob((prev) => prev ? { ...prev, tailoredCvText, tailoredCvUpdatedAt: new Date().toISOString() } : prev);
setReadiness(null); setReadiness(null);
setInterviewPrep(null); setInterviewPrep(null);
toast("Tailored CV saved.", "success"); toast(t("jobDetailsTailoredCvSaved"), "success");
} catch (error: any) { } catch (error: any) {
toast(getApiErrorMessage(error, "Failed to save tailored CV."), "error"); toast(getApiErrorMessage(error, t("jobDetailsTailoredCvSaveFailed")), "error");
} finally { } finally {
setSavingTailoredCv(false); setSavingTailoredCv(false);
} }
}}>{savingTailoredCv ? "Saving..." : "Save tailored CV"}</Button> }}>{savingTailoredCv ? t("jobDetailsSaving") : t("jobDetailsSaveTailoredCv")}</Button>
</Box> </Box>
</Box> </Box>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>Generate a full application package, then edit and save the tailored resume you actually want to use for this role.</Typography> <Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("jobDetailsTailoredCvIntro")}</Typography>
<TextField value={tailoredCvText} onChange={(e) => setTailoredCvText(e.target.value)} multiline minRows={14} fullWidth placeholder="Paste or rewrite the version of your CV you want to use for this role." /> <TextField value={tailoredCvText} onChange={(e) => setTailoredCvText(e.target.value)} multiline minRows={14} fullWidth placeholder={t("jobDetailsTailoredCvPlaceholder")} />
<Typography variant="caption" sx={{ color: "text.secondary", mt: 1, display: "block" }}>Last updated: {job?.tailoredCvUpdatedAt ? new Date(job.tailoredCvUpdatedAt).toLocaleString() : "Not saved yet"}</Typography> <Typography variant="caption" sx={{ color: "text.secondary", mt: 1, display: "block" }}>{t("jobDetailsLastUpdated", { value: job?.tailoredCvUpdatedAt ? new Date(job.tailoredCvUpdatedAt).toLocaleString() : t("jobDetailsNotSavedYet") })}</Typography>
</Box> </Box>
{applicationPackage ? ( {applicationPackage ? (
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}> <Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
<DraftCard title="Cover letter draft" content={applicationPackage.coverLetterDraft ?? "No draft available."} onSave={async (content) => { <DraftCard title={t("jobDetailsCoverLetterDraft")} content={applicationPackage.coverLetterDraft ?? t("jobDetailsNoDraftAvailable")} onSave={async (content) => {
if (!jobId) return; if (!jobId) return;
setSavingApplicationDrafts(true); setSavingApplicationDrafts(true);
try { try {
await api.put(`/jobapplications/${jobId}/application-drafts`, { coverLetterText: content }); await api.put(`/jobapplications/${jobId}/application-drafts`, { coverLetterText: content });
setJob((prev) => prev ? { ...prev, coverLetterText: content } : prev); setJob((prev) => prev ? { ...prev, coverLetterText: content } : prev);
setReadiness(null); setReadiness(null);
toast("Cover letter saved to this job.", "success"); toast(t("jobDetailsCoverLetterSaved"), "success");
} catch (error: any) { } catch (error: any) {
toast(getApiErrorMessage(error, "Failed to save cover letter."), "error"); toast(getApiErrorMessage(error, t("jobDetailsCoverLetterSaveFailed")), "error");
} finally { } finally {
setSavingApplicationDrafts(false); setSavingApplicationDrafts(false);
} }
}} saving={savingApplicationDrafts} /> }} saving={savingApplicationDrafts} />
<DraftCard title="Short application answer" content={applicationPackage.applicationAnswerDraft ?? "No draft available."} onSave={async (content) => { <DraftCard title={t("jobDetailsShortApplicationAnswer")} content={applicationPackage.applicationAnswerDraft ?? t("jobDetailsNoDraftAvailable")} onSave={async (content) => {
if (!jobId) return; if (!jobId) return;
setSavingApplicationDrafts(true); setSavingApplicationDrafts(true);
try { try {
await api.put(`/jobapplications/${jobId}/application-drafts`, { notes: `Application answer draft:\n${content}` }); await api.put(`/jobapplications/${jobId}/application-drafts`, { notes: `Application answer draft:\n${content}` });
setReadiness(null); setReadiness(null);
toast("Application answer saved to notes.", "success"); toast(t("jobDetailsApplicationAnswerSaved"), "success");
} catch (error: any) { } catch (error: any) {
toast(getApiErrorMessage(error, "Failed to save application answer."), "error"); toast(getApiErrorMessage(error, t("jobDetailsApplicationAnswerSaveFailed")), "error");
} finally { } finally {
setSavingApplicationDrafts(false); setSavingApplicationDrafts(false);
} }
}} saving={savingApplicationDrafts} /> }} saving={savingApplicationDrafts} />
<DraftCard title="Recruiter message draft" content={applicationPackage.recruiterMessageDraft ?? "No draft available."} onSave={async (content) => { <DraftCard title={t("jobDetailsRecruiterMessageDraft")} content={applicationPackage.recruiterMessageDraft ?? t("jobDetailsNoDraftAvailable")} onSave={async (content) => {
if (!jobId) return; if (!jobId) return;
setSavingApplicationDrafts(true); setSavingApplicationDrafts(true);
try { try {
await api.put(`/jobapplications/${jobId}/application-drafts`, { recruiterMessageDraft: content }); await api.put(`/jobapplications/${jobId}/application-drafts`, { recruiterMessageDraft: content });
setJob((prev) => prev ? { ...prev, recruiterMessageDraft: content } : prev); setJob((prev) => prev ? { ...prev, recruiterMessageDraft: content } : prev);
toast("Recruiter message saved to this job.", "success"); toast(t("jobDetailsRecruiterMessageSaved"), "success");
} catch (error: any) { } catch (error: any) {
toast(getApiErrorMessage(error, "Failed to save recruiter message."), "error"); toast(getApiErrorMessage(error, t("jobDetailsRecruiterMessageSaveFailed")), "error");
} finally { } finally {
setSavingApplicationDrafts(false); setSavingApplicationDrafts(false);
} }
}} saving={savingApplicationDrafts} /> }} saving={savingApplicationDrafts} />
<ListCard title="Key points to emphasize" items={applicationPackage.keyPoints} /> <ListCard title={t("jobDetailsKeyPoints")} items={applicationPackage.keyPoints} />
</Box> </Box>
) : null} ) : null}
</Box> </Box>
@@ -358,13 +363,13 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
<Box> <Box>
{loadingDraft ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : followUpDraft ? ( {loadingDraft ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : followUpDraft ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Box><Typography variant="overline">Reason</Typography><Typography>{followUpDraft.reason}</Typography></Box> <Box><Typography variant="overline">{t("jobDetailsReason")}</Typography><Typography>{followUpDraft.reason}</Typography></Box>
<Box><Typography variant="overline">Suggested send date</Typography><Typography>{new Date(followUpDraft.suggestedSendOn).toLocaleDateString()}</Typography></Box> <Box><Typography variant="overline">{t("jobDetailsSuggestedSendDate")}</Typography><Typography>{new Date(followUpDraft.suggestedSendOn).toLocaleDateString()}</Typography></Box>
<TextField label="Recipient" value={draftRecipient} onChange={(e) => setDraftRecipient(e.target.value)} helperText="Defaults to the company recruiter email when available." /> <TextField label={t("jobDetailsRecipient")} value={draftRecipient} onChange={(e) => setDraftRecipient(e.target.value)} helperText={t("jobDetailsRecipientHelp")} />
<TextField label="Subject" value={draftSubject} onChange={(e) => setDraftSubject(e.target.value)} /> <TextField label={t("jobDetailsSubject")} value={draftSubject} onChange={(e) => setDraftSubject(e.target.value)} />
<TextField label="Draft" multiline minRows={8} value={draftBody} onChange={(e) => setDraftBody(e.target.value)} /> <TextField label={t("jobDetailsDraft")} multiline minRows={8} value={draftBody} onChange={(e) => setDraftBody(e.target.value)} />
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}> <Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
<Button variant="outlined" onClick={() => navigator.clipboard.writeText(`${draftSubject}\n\n${draftBody}`)}>Copy draft</Button> <Button variant="outlined" onClick={() => navigator.clipboard.writeText(`${draftSubject}\n\n${draftBody}`)}>{t("jobDetailsCopyDraft")}</Button>
<Button variant="contained" disabled={sendingDraft || !draftSubject.trim() || !draftBody.trim()} onClick={async () => { <Button variant="contained" disabled={sendingDraft || !draftSubject.trim() || !draftBody.trim()} onClick={async () => {
if (!jobId) return; if (!jobId) return;
setSendingDraft(true); setSendingDraft(true);
@@ -372,16 +377,16 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
await api.post(`/jobapplications/${jobId}/send-followup`, { toEmail: draftRecipient || null, subject: draftSubject, body: draftBody, nextFollowUpAt: followUpDraft.suggestedSendOn || null }); await api.post(`/jobapplications/${jobId}/send-followup`, { toEmail: draftRecipient || null, subject: draftSubject, body: draftBody, nextFollowUpAt: followUpDraft.suggestedSendOn || null });
setJob((prev) => prev ? { ...prev, followUpAt: followUpDraft.suggestedSendOn } : prev); setJob((prev) => prev ? { ...prev, followUpAt: followUpDraft.suggestedSendOn } : prev);
setReadiness(null); setReadiness(null);
toast("Follow-up sent and logged.", "success"); toast(t("jobDetailsFollowUpSent"), "success");
} catch (error: any) { } catch (error: any) {
toast(getApiErrorMessage(error, "Failed to send follow-up."), "error"); toast(getApiErrorMessage(error, t("jobDetailsFollowUpSendFailed")), "error");
} finally { } finally {
setSendingDraft(false); setSendingDraft(false);
} }
}}>{sendingDraft ? "Sending..." : "Send and log email"}</Button> }}>{sendingDraft ? t("jobDetailsSending") : t("jobDetailsSendAndLogEmail")}</Button>
</Box> </Box>
</Box> </Box>
) : <Typography sx={{ color: "text.secondary" }}>No draft available.</Typography>} ) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNoDraftAvailable")}</Typography>}
</Box> </Box>
)} )}
@@ -390,25 +395,25 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
{loadingCandidateFit ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : candidateFit ? ( {loadingCandidateFit ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : candidateFit ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2.5 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 2.5 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}> <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
<Box><Typography variant="overline">How you match</Typography><Typography sx={{ whiteSpace: "pre-wrap" }}>{candidateFit.matchSummary}</Typography></Box> <Box><Typography variant="overline">{t("jobDetailsHowYouMatch")}</Typography><Typography sx={{ whiteSpace: "pre-wrap" }}>{candidateFit.matchSummary}</Typography></Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}> <Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
<Chip label={`${candidateFit.matchScore}% match`} color={candidateFit.matchScore >= 75 ? "success" : candidateFit.matchScore >= 55 ? "warning" : "default"} size="small" /> <Chip label={t("jobDetailsMatchPercent", { count: candidateFit.matchScore })} color={candidateFit.matchScore >= 75 ? "success" : candidateFit.matchScore >= 55 ? "warning" : "default"} size="small" />
{fitLevel ? <Chip label={fitLevel.label} color={fitLevel.color} size="small" /> : null} {fitLevel ? <Chip label={fitLevel.label} color={fitLevel.color} size="small" /> : null}
</Box> </Box>
</Box> </Box>
<DraftCard title="Tailored pitch" content={candidateFit.tailoredPitch} /> <DraftCard title={t("jobDetailsTailoredPitch")} content={candidateFit.tailoredPitch} />
<SectionChips title="Strong matches" items={candidateFit.strengths} color="success" /> <SectionChips title={t("jobDetailsStrongMatches")} items={candidateFit.strengths} color="success" />
<SectionChips title="Possible gaps" items={candidateFit.gaps} color="warning" outlined /> <SectionChips title={t("jobDetailsPossibleGaps")} items={candidateFit.gaps} color="warning" outlined />
<TwoColumnSection leftTitle="What to mention" leftItems={candidateFit.mention} rightTitle="What not to overstate" rightItems={candidateFit.avoid} /> <TwoColumnSection leftTitle={t("jobDetailsWhatToMention")} leftItems={candidateFit.mention} rightTitle={t("jobDetailsWhatNotToOverstate")} rightItems={candidateFit.avoid} />
<TwoColumnSection leftTitle="Improve your CV for this role" leftItems={candidateFit.cvImprovements} rightTitle="Missing keywords to consider" rightItems={candidateFit.missingKeywords} /> <TwoColumnSection leftTitle={t("jobDetailsImproveCv")} leftItems={candidateFit.cvImprovements} rightTitle={t("jobDetailsMissingKeywords")} rightItems={candidateFit.missingKeywords} />
<TwoColumnSection leftTitle="Interview prep" leftItems={candidateFit.interviewPrep} rightTitle="CV guidance" rightItems={candidateFit.guidance.cv} /> <TwoColumnSection leftTitle={t("jobDetailsTabInterviewPrep")} leftItems={candidateFit.interviewPrep} rightTitle={t("jobDetailsCvGuidance")} rightItems={candidateFit.guidance.cv} />
<TwoColumnSection leftTitle="Cover letter guidance" leftItems={candidateFit.guidance.coverLetter} rightTitle="Recruiter message guidance" rightItems={candidateFit.guidance.recruiterMessage} /> <TwoColumnSection leftTitle={t("jobDetailsCoverLetterGuidance")} leftItems={candidateFit.guidance.coverLetter} rightTitle={t("jobDetailsRecruiterMessageGuidance")} rightItems={candidateFit.guidance.recruiterMessage} />
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}> <Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
<DraftCard title="Cover letter draft" content={candidateFit.coverLetterDraft ?? "No draft available yet."} /> <DraftCard title={t("jobDetailsCoverLetterDraft")} content={candidateFit.coverLetterDraft ?? t("jobDetailsNoDraftAvailableYet")} />
<DraftCard title="Recruiter message draft" content={candidateFit.recruiterMessageDraft ?? "No draft available yet."} /> <DraftCard title={t("jobDetailsRecruiterMessageDraft")} content={candidateFit.recruiterMessageDraft ?? t("jobDetailsNoDraftAvailableYet")} />
</Box> </Box>
</Box> </Box>
) : <Typography sx={{ color: "text.secondary" }}>Add your profile CV text on the Profile page to generate a candidate fit analysis for this role.</Typography>} ) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsCandidateFitEmpty")}</Typography>}
</Box> </Box>
)} )}
@@ -416,11 +421,11 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
<Box> <Box>
{loadingInterviewPrep ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : interviewPrep ? ( {loadingInterviewPrep ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : interviewPrep ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<DraftCard title="Interview prep brief" content={interviewPrep.summary} /> <DraftCard title={t("jobDetailsInterviewPrepBrief")} content={interviewPrep.summary} />
<TwoColumnSection leftTitle="Talking points" leftItems={interviewPrep.talkingPoints} rightTitle="Likely questions" rightItems={interviewPrep.likelyQuestions} /> <TwoColumnSection leftTitle={t("jobDetailsTalkingPoints")} leftItems={interviewPrep.talkingPoints} rightTitle={t("jobDetailsLikelyQuestions")} rightItems={interviewPrep.likelyQuestions} />
<ListCard title="Weak spots to prepare for" items={interviewPrep.weakSpots} /> <ListCard title={t("jobDetailsWeakSpots")} items={interviewPrep.weakSpots} />
</Box> </Box>
) : <Typography sx={{ color: "text.secondary" }}>No interview prep available yet.</Typography>} ) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNoInterviewPrep")}</Typography>}
</Box> </Box>
)} )}
@@ -429,22 +434,22 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
{loadingReadiness ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : readiness ? ( {loadingReadiness ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : readiness ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}> <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
<Typography variant="h6">Application readiness</Typography> <Typography variant="h6">{t("jobDetailsApplicationReadiness")}</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}> <Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Chip label={`${readiness.score}% ready`} color={readiness.score >= 80 ? "success" : readiness.score >= 60 ? "warning" : "default"} /> <Chip label={t("jobDetailsReadyPercent", { count: readiness.score })} color={readiness.score >= 80 ? "success" : readiness.score >= 60 ? "warning" : "default"} />
<Chip label={readiness.level} variant="outlined" /> <Chip label={readiness.level} variant="outlined" />
</Box> </Box>
</Box> </Box>
<TwoColumnSection leftTitle="Completed" leftItems={readiness.completed} rightTitle="Still missing" rightItems={readiness.missing} /> <TwoColumnSection leftTitle={t("jobDetailsCompleted")} leftItems={readiness.completed} rightTitle={t("jobDetailsStillMissing")} rightItems={readiness.missing} />
<ListCard title="Smart reminders" items={readiness.reminders} /> <ListCard title={t("jobDetailsSmartReminders")} items={readiness.reminders} />
</Box> </Box>
) : <Typography sx={{ color: "text.secondary" }}>No readiness analysis available yet.</Typography>} ) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNoReadiness")}</Typography>}
</Box> </Box>
)} )}
{tab === 8 && isAdmin && ( {tab === 8 && isAdmin && (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
{history.length === 0 ? <Typography sx={{ color: "text.secondary" }}>No history yet.</Typography> : history.map((entry) => <PaperRow key={entry.id} type={entry.type} oldValue={entry.oldValue} newValue={entry.newValue} at={entry.at} note={entry.note} />)} {history.length === 0 ? <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNoHistory")}</Typography> : history.map((entry) => <PaperRow key={entry.id} type={entry.type} oldValue={entry.oldValue} newValue={entry.newValue} at={entry.at} note={entry.note} />)}
</Box> </Box>
)} )}
</DialogContent> </DialogContent>
@@ -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 }) { function SectionChips({ title, items, color, outlined }: { title: string; items: string[]; color: "success" | "warning"; outlined?: boolean }) {
const { t } = useI18n();
return ( return (
<Box> <Box>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}> <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
<Typography variant="overline">{title}</Typography> <Typography variant="overline">{title}</Typography>
<Button size="small" variant="outlined" onClick={() => copyLines(items)}>Copy</Button> <Button size="small" variant="outlined" onClick={() => copyLines(items)}>{t("jobDetailsCopy")}</Button>
</Box> </Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.5 }}> <Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.5 }}>
{items.length ? items.map((item) => <Chip key={item} label={item} color={color} variant={outlined ? "outlined" : "filled"} size="small" />) : <Typography sx={{ color: "text.secondary" }}>Nothing highlighted yet.</Typography>} {items.length ? items.map((item) => <Chip key={item} label={item} color={color} variant={outlined ? "outlined" : "filled"} size="small" />) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNothingHighlighted")}</Typography>}
</Box> </Box>
</Box> </Box>
); );
@@ -476,20 +483,23 @@ function TwoColumnSection({ leftTitle, leftItems, rightTitle, rightItems }: { le
} }
function ListCard({ title, items }: { title: string; items: string[] }) { function ListCard({ title, items }: { title: string; items: string[] }) {
const { t } = useI18n();
return ( return (
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}> <Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}> <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
<Typography variant="overline">{title}</Typography> <Typography variant="overline">{title}</Typography>
<Button size="small" variant="outlined" onClick={() => copyLines(items)}>Copy</Button> <Button size="small" variant="outlined" onClick={() => copyLines(items)}>{t("jobDetailsCopy")}</Button>
</Box> </Box>
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 0.75 }}>
{items.length ? items.map((item, index) => <Typography key={`${title}-${index}-${item}`} sx={{ color: "text.primary" }}> {item}</Typography>) : <Typography sx={{ color: "text.secondary" }}>Nothing highlighted yet.</Typography>} {items.length ? items.map((item, index) => <Typography key={`${title}-${index}-${item}`} sx={{ color: "text.primary" }}> {item}</Typography>) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNothingHighlighted")}</Typography>}
</Box> </Box>
</Box> </Box>
); );
} }
function DraftCard({ title, content, onSave, saving }: { title: string; content: string; onSave?: (content: string) => Promise<void> | void; saving?: boolean }) { function DraftCard({ title, content, onSave, saving }: { title: string; content: string; onSave?: (content: string) => Promise<void> | void; saving?: boolean }) {
const { t } = useI18n();
const [value, setValue] = React.useState(content); const [value, setValue] = React.useState(content);
React.useEffect(() => { React.useEffect(() => {
@@ -501,8 +511,8 @@ function DraftCard({ title, content, onSave, saving }: { title: string; content:
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}> <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
<Typography variant="overline">{title}</Typography> <Typography variant="overline">{title}</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}> <Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Button size="small" variant="outlined" onClick={() => navigator.clipboard.writeText(value)}>Copy</Button> <Button size="small" variant="outlined" onClick={() => navigator.clipboard.writeText(value)}>{t("jobDetailsCopy")}</Button>
{onSave ? <Button size="small" variant="contained" disabled={saving} onClick={() => onSave(value)}>{saving ? "Saving..." : "Save"}</Button> : null} {onSave ? <Button size="small" variant="contained" disabled={saving} onClick={() => onSave(value)}>{saving ? t("jobDetailsSaving") : t("save")}</Button> : null}
</Box> </Box>
</Box> </Box>
<TextField value={value} onChange={(e) => setValue(e.target.value)} multiline minRows={6} fullWidth /> <TextField value={value} onChange={(e) => setValue(e.target.value)} multiline minRows={6} fullWidth />
@@ -14,6 +14,8 @@ import {
import BookmarkBorderIcon from "@mui/icons-material/BookmarkBorder"; import BookmarkBorderIcon from "@mui/icons-material/BookmarkBorder";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import { useI18n } from "../i18n/I18nProvider";
export type SavedViewParams = { export type SavedViewParams = {
q?: string; q?: string;
status?: string; status?: string;
@@ -54,6 +56,7 @@ export default function SavedViewsMenu({
current: SavedViewParams; current: SavedViewParams;
onApply: (p: SavedViewParams) => void; onApply: (p: SavedViewParams) => void;
}) { }) {
const { t } = useI18n();
const [anchor, setAnchor] = useState<HTMLElement | null>(null); const [anchor, setAnchor] = useState<HTMLElement | null>(null);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [views, setViews] = useState<SavedView[]>(() => loadViews()); const [views, setViews] = useState<SavedView[]>(() => loadViews());
@@ -86,7 +89,7 @@ export default function SavedViewsMenu({
return ( return (
<> <>
<Tooltip title="Saved views"> <Tooltip title={t("savedViewsTooltip")}>
<IconButton size="small" onClick={(e) => setAnchor(e.currentTarget)}> <IconButton size="small" onClick={(e) => setAnchor(e.currentTarget)}>
<BookmarkBorderIcon fontSize="small" /> <BookmarkBorderIcon fontSize="small" />
</IconButton> </IconButton>
@@ -100,15 +103,15 @@ export default function SavedViewsMenu({
> >
<MenuItem disableRipple sx={{ cursor: "default", alignItems: "flex-start" }}> <MenuItem disableRipple sx={{ cursor: "default", alignItems: "flex-start" }}>
<ListItemText <ListItemText
primary="Saved views" primary={t("savedViewsTitle")}
secondary="Save the current filters as a 1-click view." secondary={t("savedViewsSubtitle")}
/> />
</MenuItem> </MenuItem>
<MenuItem disableRipple sx={{ cursor: "default" }}> <MenuItem disableRipple sx={{ cursor: "default" }}>
<TextField <TextField
size="small" size="small"
label="Name" label={t("savedViewsName")}
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
fullWidth fullWidth
@@ -116,14 +119,14 @@ export default function SavedViewsMenu({
</MenuItem> </MenuItem>
<MenuItem disableRipple sx={{ cursor: "default", justifyContent: "flex-end" }}> <MenuItem disableRipple sx={{ cursor: "default", justifyContent: "flex-end" }}>
<Button variant="contained" size="small" onClick={add} disabled={!canSave}> <Button variant="contained" size="small" onClick={add} disabled={!canSave}>
Save current {t("savedViewsSaveCurrent")}
</Button> </Button>
</MenuItem> </MenuItem>
<Divider /> <Divider />
{!hasAny && ( {!hasAny && (
<MenuItem disabled>No saved views yet.</MenuItem> <MenuItem disabled>{t("savedViewsEmpty")}</MenuItem>
)} )}
{views.map((v) => ( {views.map((v) => (
@@ -139,12 +142,12 @@ export default function SavedViewsMenu({
primary={v.name} primary={v.name}
secondary={ secondary={
[ [
v.params.status ? `Status: ${v.params.status}` : null, v.params.status ? t("savedViewsStatusLabel", { value: v.params.status }) : null,
v.params.location ? `Loc: ${v.params.location}` : null, v.params.location ? t("savedViewsLocationLabel", { value: v.params.location }) : null,
v.params.needsFollowUp ? "Needs follow-up" : null, v.params.needsFollowUp ? t("savedViewsNeedsFollowUp") : null,
] ]
.filter(Boolean) .filter(Boolean)
.join(" · ") || "No filters" .join(" · ") || t("savedViewsNoFilters")
} }
/> />
<IconButton <IconButton
+284
View File
@@ -524,6 +524,148 @@ export const translations = {
jobTableNoJobsFound: "No jobs found.", jobTableNoJobsFound: "No jobs found.",
jobTableSetStatus: "Set {status}", jobTableSetStatus: "Set {status}",
editJobTitle: "Edit job", editJobTitle: "Edit job",
editJobIntro: "Update job details, timeline status, documents, and notes from one editing workspace.",
editJobApplicationDetails: "Application details",
editJobJobTitle: "Job title",
editJobAppliedOn: "Applied on",
editJobStatusUpdate: "Status update",
editJobCurrentStatus: "Current status",
editJobStatusChangedOn: "Status changed on",
editJobStatusChangedHelpIdle: "Only used when you change the status.",
editJobStatusChangedHelpActive: "This date will be recorded in the timeline.",
editJobReplyReceived: "Reply received",
editJobReplyReceivedOn: "Reply received on",
editJobNextAction: "Next action",
editJobFollowUpOn: "Follow up on",
editJobRoleDetails: "Role details",
editJobDeadline: "Deadline",
editJobDescriptionLanguage: "Description language",
editJobNotes: "Notes",
editJobDescriptionOriginal: "Description (original)",
editJobTranslatedDescription: "Translated description",
editJobCoverLetter: "Cover letter",
editJobAttachmentsChecklist: "Attachments checklist",
editJobResumeReady: "Resume ready",
editJobResumeMissing: "Resume missing",
editJobCoverLetterReady: "Cover letter ready",
editJobCoverLetterMissing: "Cover letter missing",
editJobPortfolioReady: "Portfolio ready",
editJobPortfolioOptional: "Portfolio optional",
editJobResume: "Resume",
editJobPortfolio: "Portfolio",
editJobOtherAttachment: "Other attachment",
editJobSaveFailed: "Save failed.",
savedViewsTooltip: "Saved views",
savedViewsTitle: "Saved views",
savedViewsSubtitle: "Save the current filters as a 1-click view.",
savedViewsName: "Name",
savedViewsSaveCurrent: "Save current",
savedViewsEmpty: "No saved views yet.",
savedViewsStatusLabel: "Status: {value}",
savedViewsLocationLabel: "Location: {value}",
savedViewsNeedsFollowUp: "Needs follow-up",
savedViewsNoFilters: "No filters",
jobDetailsTabCorrespondence: "Correspondence",
jobDetailsTabAttachments: "Attachments",
jobDetailsTabTailoredCv: "Tailored CV",
jobDetailsTabCandidateFit: "Candidate fit",
jobDetailsTabInterviewPrep: "Interview prep",
jobDetailsTabHistory: "History",
jobDetailsTailoredCvMode: "Generation mode",
jobDetailsGenerationDefault: "Balanced",
jobDetailsGenerationConcise: "Concise",
jobDetailsGenerationAts: "ATS focused",
jobDetailsGenerationAchievement: "Achievement focused",
jobDetailsGenerationInterview: "Interview focused",
jobDetailsResume: "Resume",
jobDetailsCoverLetter: "Cover letter",
jobDetailsPortfolio: "Portfolio",
jobDetailsOther: "Other",
jobDetailsNotAvailable: "—",
jobDetailsDateApplied: "Date applied",
jobDetailsDaysSince: "Days since",
jobDetailsSalary: "Salary",
jobDetailsNextAction: "Next action",
jobDetailsFollowUp: "Follow up",
jobDetailsDeadline: "Deadline",
jobDetailsTags: "Tags",
jobDetailsAttachmentTypes: "Attachment types",
jobDetailsJobUrl: "Job URL",
jobDetailsSummaryAndSkills: "Summary and skills",
jobDetailsRefreshAiTitle: "Refresh AI summary",
jobDetailsRefreshAiConfirm: "Overwrite the current summary and skills with a freshly generated version?",
jobDetailsRefreshAi: "Refresh summary and skills",
jobDetailsRefreshing: "Refreshing...",
jobDetailsSummaryRefreshed: "Summary and skills refreshed.",
jobDetailsSummaryRefreshFailed: "Failed to refresh summary and skills.",
jobDetailsTranslatedRoleText: "Translated role text",
jobDetailsOriginalRoleText: "Original role text",
jobDetailsStartFromMasterCv: "Start from master CV",
jobDetailsLoadedMasterCv: "Loaded your master CV into the tailored editor.",
jobDetailsLoadMasterCvFailed: "Failed to load your master CV.",
jobDetailsGeneratePackage: "Generate application package",
jobDetailsGeneratingPackage: "Generating...",
jobDetailsPackageGenerated: "Application package generated.",
jobDetailsPackageGenerationFailed: "Failed to generate application package.",
jobDetailsClear: "Clear",
jobDetailsCopy: "Copy",
jobDetailsSaveTailoredCv: "Save tailored CV",
jobDetailsSaving: "Saving...",
jobDetailsTailoredCvSaved: "Tailored CV saved.",
jobDetailsTailoredCvSaveFailed: "Failed to save tailored CV.",
jobDetailsTailoredCvIntro: "Generate a full application package, then edit and save the tailored resume you actually want to use for this role.",
jobDetailsTailoredCvPlaceholder: "Paste or rewrite the version of your CV you want to use for this role.",
jobDetailsLastUpdated: "Last updated: {value}",
jobDetailsNotSavedYet: "Not saved yet",
jobDetailsCoverLetterDraft: "Cover letter draft",
jobDetailsShortApplicationAnswer: "Short application answer",
jobDetailsRecruiterMessageDraft: "Recruiter message draft",
jobDetailsNoDraftAvailable: "No draft available.",
jobDetailsCoverLetterSaved: "Cover letter saved to this job.",
jobDetailsCoverLetterSaveFailed: "Failed to save cover letter.",
jobDetailsApplicationAnswerSaved: "Application answer saved to notes.",
jobDetailsApplicationAnswerSaveFailed: "Failed to save application answer.",
jobDetailsRecruiterMessageSaved: "Recruiter message saved to this job.",
jobDetailsRecruiterMessageSaveFailed: "Failed to save recruiter message.",
jobDetailsKeyPoints: "Key points to emphasize",
jobDetailsReason: "Reason",
jobDetailsSuggestedSendDate: "Suggested send date",
jobDetailsRecipient: "Recipient",
jobDetailsRecipientHelp: "Defaults to the company recruiter email when available.",
jobDetailsSubject: "Subject",
jobDetailsDraft: "Draft",
jobDetailsCopyDraft: "Copy draft",
jobDetailsSendAndLogEmail: "Send and log email",
jobDetailsSending: "Sending...",
jobDetailsFollowUpSent: "Follow-up sent and logged.",
jobDetailsFollowUpSendFailed: "Failed to send follow-up.",
jobDetailsHowYouMatch: "How you match",
jobDetailsMatchPercent: "{count}% match",
jobDetailsTailoredPitch: "Tailored pitch",
jobDetailsStrongMatches: "Strong matches",
jobDetailsPossibleGaps: "Possible gaps",
jobDetailsWhatToMention: "What to mention",
jobDetailsWhatNotToOverstate: "What not to overstate",
jobDetailsImproveCv: "Improve your CV for this role",
jobDetailsMissingKeywords: "Missing keywords to consider",
jobDetailsCvGuidance: "CV guidance",
jobDetailsCoverLetterGuidance: "Cover letter guidance",
jobDetailsRecruiterMessageGuidance: "Recruiter message guidance",
jobDetailsNoDraftAvailableYet: "No draft available yet.",
jobDetailsCandidateFitEmpty: "Add your profile CV text on the Profile page to generate a candidate fit analysis for this role.",
jobDetailsInterviewPrepBrief: "Interview prep brief",
jobDetailsTalkingPoints: "Talking points",
jobDetailsLikelyQuestions: "Likely questions",
jobDetailsWeakSpots: "Weak spots to prepare for",
jobDetailsNoInterviewPrep: "No interview prep available yet.",
jobDetailsApplicationReadiness: "Application readiness",
jobDetailsReadyPercent: "{count}% ready",
jobDetailsCompleted: "Completed",
jobDetailsStillMissing: "Still missing",
jobDetailsSmartReminders: "Smart reminders",
jobDetailsNoReadiness: "No readiness analysis available yet.",
jobDetailsNoHistory: "No history yet.",
jobDetailsNothingHighlighted: "Nothing highlighted yet.",
rulesTitle: "Follow-up + Ghosting Rules", rulesTitle: "Follow-up + Ghosting Rules",
rulesBody: "Jobs get a “Follow up” flag based on these thresholds. Ghosting is automatic.", rulesBody: "Jobs get a “Follow up” flag based on these thresholds. Ghosting is automatic.",
rulesAppliedFollowUpDays: "Applied: follow-up days", rulesAppliedFollowUpDays: "Applied: follow-up days",
@@ -1058,6 +1200,148 @@ export const translations = {
jobTableNoJobsFound: "Ingen jobber funnet.", jobTableNoJobsFound: "Ingen jobber funnet.",
jobTableSetStatus: "Sett {status}", jobTableSetStatus: "Sett {status}",
editJobTitle: "Rediger jobb", editJobTitle: "Rediger jobb",
editJobIntro: "Oppdater jobbdetaljer, status i tidslinjen, dokumenter og notater fra ett redigeringsområde.",
editJobApplicationDetails: "Søknadsdetaljer",
editJobJobTitle: "Stillingstittel",
editJobAppliedOn: "Søkt dato",
editJobStatusUpdate: "Statusoppdatering",
editJobCurrentStatus: "Gjeldende status",
editJobStatusChangedOn: "Status endret",
editJobStatusChangedHelpIdle: "Brukes bare når du endrer statusen.",
editJobStatusChangedHelpActive: "Denne datoen blir lagret i tidslinjen.",
editJobReplyReceived: "Svar mottatt",
editJobReplyReceivedOn: "Svar mottatt dato",
editJobNextAction: "Neste handling",
editJobFollowUpOn: "Følg opp den",
editJobRoleDetails: "Rolledetaljer",
editJobDeadline: "Frist",
editJobDescriptionLanguage: "Språk i beskrivelse",
editJobNotes: "Notater",
editJobDescriptionOriginal: "Beskrivelse (original)",
editJobTranslatedDescription: "Oversatt beskrivelse",
editJobCoverLetter: "Søknadsbrev",
editJobAttachmentsChecklist: "Vedleggssjekkliste",
editJobResumeReady: "CV klar",
editJobResumeMissing: "CV mangler",
editJobCoverLetterReady: "Søknadsbrev klart",
editJobCoverLetterMissing: "Søknadsbrev mangler",
editJobPortfolioReady: "Portefølje klar",
editJobPortfolioOptional: "Portefølje valgfri",
editJobResume: "CV",
editJobPortfolio: "Portefølje",
editJobOtherAttachment: "Annet vedlegg",
editJobSaveFailed: "Lagring mislyktes.",
savedViewsTooltip: "Lagrede visninger",
savedViewsTitle: "Lagrede visninger",
savedViewsSubtitle: "Lagre gjeldende filtre som en visning med ett klikk.",
savedViewsName: "Navn",
savedViewsSaveCurrent: "Lagre gjeldende",
savedViewsEmpty: "Ingen lagrede visninger ennå.",
savedViewsStatusLabel: "Status: {value}",
savedViewsLocationLabel: "Sted: {value}",
savedViewsNeedsFollowUp: "Trenger oppfølging",
savedViewsNoFilters: "Ingen filtre",
jobDetailsTabCorrespondence: "Korrespondanse",
jobDetailsTabAttachments: "Vedlegg",
jobDetailsTabTailoredCv: "Tilpasset CV",
jobDetailsTabCandidateFit: "Kandidatmatch",
jobDetailsTabInterviewPrep: "Intervjuforberedelse",
jobDetailsTabHistory: "Historikk",
jobDetailsTailoredCvMode: "Genereringsmodus",
jobDetailsGenerationDefault: "Balansert",
jobDetailsGenerationConcise: "Kortfattet",
jobDetailsGenerationAts: "ATS-fokusert",
jobDetailsGenerationAchievement: "Prestasjonfokusert",
jobDetailsGenerationInterview: "Intervjufokusert",
jobDetailsResume: "CV",
jobDetailsCoverLetter: "Søknadsbrev",
jobDetailsPortfolio: "Portefølje",
jobDetailsOther: "Annet",
jobDetailsNotAvailable: "—",
jobDetailsDateApplied: "Søkt dato",
jobDetailsDaysSince: "Dager siden",
jobDetailsSalary: "Lønn",
jobDetailsNextAction: "Neste handling",
jobDetailsFollowUp: "Oppfølging",
jobDetailsDeadline: "Frist",
jobDetailsTags: "Tagger",
jobDetailsAttachmentTypes: "Vedleggstyper",
jobDetailsJobUrl: "Jobb-URL",
jobDetailsSummaryAndSkills: "Oppsummering og ferdigheter",
jobDetailsRefreshAiTitle: "Oppdater AI-oppsummering",
jobDetailsRefreshAiConfirm: "Overskrive gjeldende oppsummering og ferdigheter med en ny generert versjon?",
jobDetailsRefreshAi: "Oppdater oppsummering og ferdigheter",
jobDetailsRefreshing: "Oppdaterer...",
jobDetailsSummaryRefreshed: "Oppsummering og ferdigheter oppdatert.",
jobDetailsSummaryRefreshFailed: "Kunne ikke oppdatere oppsummering og ferdigheter.",
jobDetailsTranslatedRoleText: "Oversatt rolletekst",
jobDetailsOriginalRoleText: "Original rolletekst",
jobDetailsStartFromMasterCv: "Start med hoved-CV",
jobDetailsLoadedMasterCv: "Lastet inn hoved-CV-en din i editoren.",
jobDetailsLoadMasterCvFailed: "Kunne ikke laste inn hoved-CV-en din.",
jobDetailsGeneratePackage: "Generer søknadspakke",
jobDetailsGeneratingPackage: "Genererer...",
jobDetailsPackageGenerated: "Søknadspakke generert.",
jobDetailsPackageGenerationFailed: "Kunne ikke generere søknadspakke.",
jobDetailsClear: "Tøm",
jobDetailsCopy: "Kopier",
jobDetailsSaveTailoredCv: "Lagre tilpasset CV",
jobDetailsSaving: "Lagrer...",
jobDetailsTailoredCvSaved: "Tilpasset CV lagret.",
jobDetailsTailoredCvSaveFailed: "Kunne ikke lagre tilpasset CV.",
jobDetailsTailoredCvIntro: "Generer en full søknadspakke, og rediger og lagre deretter den tilpassede CV-en du faktisk vil bruke for denne rollen.",
jobDetailsTailoredCvPlaceholder: "Lim inn eller skriv om versjonen av CV-en du vil bruke for denne rollen.",
jobDetailsLastUpdated: "Sist oppdatert: {value}",
jobDetailsNotSavedYet: "Ikke lagret ennå",
jobDetailsCoverLetterDraft: "Utkast til søknadsbrev",
jobDetailsShortApplicationAnswer: "Kort søknadssvar",
jobDetailsRecruiterMessageDraft: "Utkast til melding til rekrutterer",
jobDetailsNoDraftAvailable: "Ingen utkast tilgjengelig.",
jobDetailsCoverLetterSaved: "Søknadsbrev lagret på denne jobben.",
jobDetailsCoverLetterSaveFailed: "Kunne ikke lagre søknadsbrev.",
jobDetailsApplicationAnswerSaved: "Søknadssvar lagret i notater.",
jobDetailsApplicationAnswerSaveFailed: "Kunne ikke lagre søknadssvar.",
jobDetailsRecruiterMessageSaved: "Melding til rekrutterer lagret på denne jobben.",
jobDetailsRecruiterMessageSaveFailed: "Kunne ikke lagre melding til rekrutterer.",
jobDetailsKeyPoints: "Nøkkelpunkter å fremheve",
jobDetailsReason: "Årsak",
jobDetailsSuggestedSendDate: "Foreslått sendingsdato",
jobDetailsRecipient: "Mottaker",
jobDetailsRecipientHelp: "Bruker selskapets rekrutterer-e-post som standard når den finnes.",
jobDetailsSubject: "Emne",
jobDetailsDraft: "Utkast",
jobDetailsCopyDraft: "Kopier utkast",
jobDetailsSendAndLogEmail: "Send og loggfør e-post",
jobDetailsSending: "Sender...",
jobDetailsFollowUpSent: "Oppfølging sendt og loggført.",
jobDetailsFollowUpSendFailed: "Kunne ikke sende oppfølging.",
jobDetailsHowYouMatch: "Slik matcher du",
jobDetailsMatchPercent: "{count}% match",
jobDetailsTailoredPitch: "Tilpasset pitch",
jobDetailsStrongMatches: "Sterke matcher",
jobDetailsPossibleGaps: "Mulige hull",
jobDetailsWhatToMention: "Dette bør nevnes",
jobDetailsWhatNotToOverstate: "Dette bør ikke overdrives",
jobDetailsImproveCv: "Forbedre CV-en din for denne rollen",
jobDetailsMissingKeywords: "Manglende nøkkelord å vurdere",
jobDetailsCvGuidance: "CV-veiledning",
jobDetailsCoverLetterGuidance: "Veiledning for søknadsbrev",
jobDetailsRecruiterMessageGuidance: "Veiledning for rekrutterermelding",
jobDetailsNoDraftAvailableYet: "Ingen utkast tilgjengelig ennå.",
jobDetailsCandidateFitEmpty: "Legg til CV-teksten din på profilsiden for å generere en kandidatmatchanalyse for denne rollen.",
jobDetailsInterviewPrepBrief: "Kort intervjuforberedelse",
jobDetailsTalkingPoints: "Samtalepunkter",
jobDetailsLikelyQuestions: "Sannsynlige spørsmål",
jobDetailsWeakSpots: "Svake punkter å forberede seg på",
jobDetailsNoInterviewPrep: "Ingen intervjuforberedelse tilgjengelig ennå.",
jobDetailsApplicationReadiness: "Søknadsberedskap",
jobDetailsReadyPercent: "{count}% klar",
jobDetailsCompleted: "Fullført",
jobDetailsStillMissing: "Mangler fortsatt",
jobDetailsSmartReminders: "Smarte påminnelser",
jobDetailsNoReadiness: "Ingen beredskapsanalyse tilgjengelig ennå.",
jobDetailsNoHistory: "Ingen historikk ennå.",
jobDetailsNothingHighlighted: "Ingenting fremhevet ennå.",
rulesTitle: "Regler for oppfølging og ghosting", rulesTitle: "Regler for oppfølging og ghosting",
rulesBody: "Jobber får et «Følg opp»-flagg basert på disse tersklene. Ghosting skjer automatisk.", rulesBody: "Jobber får et «Følg opp»-flagg basert på disse tersklene. Ghosting skjer automatisk.",
rulesAppliedFollowUpDays: "Søkt: oppfølgingsdager", rulesAppliedFollowUpDays: "Søkt: oppfølgingsdager",