feat(i18n): localise application workspace

This commit is contained in:
cesnimda
2026-08-28 20:04:11 +02:00
parent cc01540446
commit a57b2fe58c
5 changed files with 372 additions and 124 deletions
@@ -149,6 +149,7 @@ function renderTable(path = "/jobs") {
} }
beforeEach(() => { beforeEach(() => {
window.localStorage.removeItem("uiLanguage");
mockedApi.get.mockImplementation((url: string) => { mockedApi.get.mockImplementation((url: string) => {
if (url === "/companies") return Promise.resolve({ data: [{ id: 1, name: "Acme" }] } as any); if (url === "/companies") return Promise.resolve({ data: [{ id: 1, name: "Acme" }] } as any);
if (url === "/jobapplications") return Promise.resolve({ data: { items: [job], total: 1, page: 1, pageSize: 15 } } as any); if (url === "/jobapplications") return Promise.resolve({ data: { items: [job], total: 1, page: 1, pageSize: 15 } } as any);
@@ -158,7 +159,10 @@ beforeEach(() => {
}); });
}); });
afterEach(() => jest.clearAllMocks()); afterEach(() => {
window.localStorage.removeItem("uiLanguage");
jest.clearAllMocks();
});
test("opens the dedicated workspace from the whole row and preserves list state on return", async () => { test("opens the dedicated workspace from the whole row and preserves list state on return", async () => {
renderTable(); renderTable();
@@ -227,6 +231,17 @@ test("warns before section navigation would discard application edits", async ()
await waitFor(() => expect(screen.getByTestId("location")).toHaveTextContent("/jobs/42?section=analysis")); await waitFor(() => expect(screen.getByTestId("location")).toHaveTextContent("/jobs/42?section=analysis"));
}); });
test("renders shared workspace navigation and progress in Norwegian Bokmål", async () => {
window.localStorage.setItem("uiLanguage", "nb");
renderTable("/jobs/42");
expect(await screen.findByRole("tab", { name: "Oversikt" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("tab", { name: "Analyse" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Søknadsbrev" })).toBeInTheDocument();
expect(await screen.findByLabelText(/søknadsprogresjon/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /tilbake til søknader/i })).toBeInTheDocument();
});
test("hydrates list filters, sort and page from a shareable URL", async () => { test("hydrates list filters, sort and page from a shareable URL", async () => {
renderTable("/jobs?q=backend&status=Interview&companyId=1&location=Oslo&needsFollowUp=1&readiness=interview&includeDeleted=1&sortBy=company&sortDir=asc&page=2"); renderTable("/jobs?q=backend&status=Interview&companyId=1&location=Oslo&needsFollowUp=1&readiness=interview&includeDeleted=1&sortBy=company&sortDir=asc&page=2");
@@ -85,6 +85,7 @@ function Shell({ title, subtitle, loading, error, children }: {
// ---------- CV ---------- // ---------- CV ----------
export function ApplicationCvSection({ jobId }: { jobId: number }) { export function ApplicationCvSection({ jobId }: { jobId: number }) {
const { t } = useI18n();
const { data, error, loading, setData, setError } = useAsset<ApplicationCv>( const { data, error, loading, setData, setError } = useAsset<ApplicationCv>(
() => applicationAssetsApi.cv(jobId), () => applicationAssetsApi.cv(jobId),
[jobId], [jobId],
@@ -122,29 +123,28 @@ export function ApplicationCvSection({ jobId }: { jobId: number }) {
return ( return (
<Stack spacing={2}> <Stack spacing={2}>
<Shell <Shell
title="CV" title={t("workspaceCv")}
subtitle="Which CV variant this application uses. Variants are lenses over your master career profile." subtitle={t("assetsCvSubtitle")}
loading={loading} loading={loading}
error={error} error={error}
> >
<Stack spacing={2}> <Stack spacing={2}>
{(data?.availableVariants.length ?? 0) === 0 ? ( {(data?.availableVariants.length ?? 0) === 0 ? (
<Alert severity="info" sx={{ borderRadius: 2 }}> <Alert severity="info" sx={{ borderRadius: 2 }}>
No CV variants yet. Build one in the CV builder it starts from your master career {t("assetsNoCvVariants")}
profile, so you never retype your history.
</Alert> </Alert>
) : ( ) : (
<TextField <TextField
select select
size="small" size="small"
fullWidth fullWidth
label="Attached CV variant" label={t("assetsAttachedCv")}
value={attached} value={attached}
disabled={busy} disabled={busy}
onChange={(e) => attach(e.target.value === "" ? null : Number(e.target.value))} onChange={(e) => attach(e.target.value === "" ? null : Number(e.target.value))}
helperText="Changing this only re-points the application. The variant itself is untouched." helperText={t("assetsAttachedCvHelp")}
> >
<MenuItem value="">None</MenuItem> <MenuItem value="">{t("assetsNone")}</MenuItem>
{(data?.availableVariants ?? []).map((v) => ( {(data?.availableVariants ?? []).map((v) => (
<MenuItem key={v.id} value={v.id}> <MenuItem key={v.id} value={v.id}>
{v.name} · {v.themeId} · v{v.version} {v.name} · {v.themeId} · v{v.version}
@@ -159,8 +159,8 @@ export function ApplicationCvSection({ jobId }: { jobId: number }) {
<Box> <Box>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{data.attachedVariantName}</Typography> <Typography variant="body2" sx={{ fontWeight: 700 }}>{data.attachedVariantName}</Typography>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
Theme {data.attachedThemeId} · version {data.attachedVersion} {t("assetsThemeVersion", { theme: data.attachedThemeId ?? "—", version: data.attachedVersion ?? "—" })}
{data.attachedIsPublic ? " · public" : ""} {data.attachedIsPublic ? ` · ${t("assetsPublic")}` : ""}
</Typography> </Typography>
</Box> </Box>
<Stack direction="row" spacing={1}> <Stack direction="row" spacing={1}>
@@ -172,23 +172,23 @@ export function ApplicationCvSection({ jobId }: { jobId: number }) {
// server-side, so there is no second CV loading path here. // server-side, so there is no second CV loading path here.
href={`/career/builder/${data.attachedVariantId}`} href={`/career/builder/${data.attachedVariantId}`}
> >
Edit, preview and export {t("assetsEditPreviewExport")}
</Button> </Button>
<Button size="small" variant="contained" disabled={busy} onClick={() => void duplicateForJob()}> <Button size="small" variant="contained" disabled={busy} onClick={() => void duplicateForJob()}>
Duplicate for this job {t("assetsDuplicateForJob")}
</Button> </Button>
</Stack> </Stack>
</Stack> </Stack>
</Paper> </Paper>
) : ( ) : (
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
No CV attached to this application yet. {t("assetsNoCvAttached")}
</Typography> </Typography>
)} )}
{data?.hasTailoredCvText && ( {data?.hasTailoredCvText && (
<Alert severity="info" sx={{ borderRadius: 2 }}> <Alert severity="info" sx={{ borderRadius: 2 }}>
This application also has legacy tailored CV text saved on it. A CV variant supersedes it. {t("assetsLegacyCvText")}
</Alert> </Alert>
)} )}
</Stack> </Stack>
@@ -202,6 +202,7 @@ export function ApplicationCvSection({ jobId }: { jobId: number }) {
// ---------- Tailoring ---------- // ---------- Tailoring ----------
export function ApplicationTailoringSection({ jobId }: { jobId: number }) { export function ApplicationTailoringSection({ jobId }: { jobId: number }) {
const { t } = useI18n();
const { data, error, loading } = useAsset<TailoringPlan>( const { data, error, loading } = useAsset<TailoringPlan>(
() => applicationAssetsApi.tailoring(jobId), () => applicationAssetsApi.tailoring(jobId),
[jobId], [jobId],
@@ -209,26 +210,26 @@ export function ApplicationTailoringSection({ jobId }: { jobId: number }) {
return ( return (
<Shell <Shell
title="Tailoring" title={t("assetsTailoring")}
subtitle="What to emphasise for this advert. Suggestions only — nothing here edits your profile or your CV." subtitle={t("assetsTailoringSubtitle")}
loading={loading} loading={loading}
error={error} error={error}
> >
<Stack spacing={2}> <Stack spacing={2}>
{data && !data.hasCareerProfile && ( {data && !data.hasCareerProfile && (
<Alert severity="info" sx={{ borderRadius: 2 }}> <Alert severity="info" sx={{ borderRadius: 2 }}>
Build your career profile to get experience and project suggestions. {t("assetsBuildProfile")}
</Alert> </Alert>
)} )}
{data && !data.hasJobDescription && ( {data && !data.hasJobDescription && (
<Alert severity="info" sx={{ borderRadius: 2 }}> <Alert severity="info" sx={{ borderRadius: 2 }}>
Paste the advert text to get keyword and requirement suggestions. {t("assetsAddAdvert")}
</Alert> </Alert>
)} )}
{(data?.suggestions.length ?? 0) === 0 ? ( {(data?.suggestions.length ?? 0) === 0 ? (
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
Nothing to suggest yet. {t("assetsNoSuggestions")}
</Typography> </Typography>
) : ( ) : (
(data?.suggestions ?? []).map((s) => ( (data?.suggestions ?? []).map((s) => (
@@ -266,6 +267,7 @@ Kind regards,
[Your name]`; [Your name]`;
export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId: number; onDirtyChange?: (dirty: boolean) => void }) { export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId: number; onDirtyChange?: (dirty: boolean) => void }) {
const { t } = useI18n();
const { data, error, loading, setData, setError } = useAsset<CoverLetter>( const { data, error, loading, setData, setError } = useAsset<CoverLetter>(
() => applicationAssetsApi.coverLetter(jobId), () => applicationAssetsApi.coverLetter(jobId),
[jobId], [jobId],
@@ -315,36 +317,36 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
return ( return (
<Stack spacing={2}> <Stack spacing={2}>
<Shell <Shell
title="Cover letter" title={t("workspaceCoverLetter")}
subtitle="Every save keeps the previous text, so nothing you write is ever lost." subtitle={t("assetsCoverSubtitle")}
loading={loading} loading={loading}
error={error} error={error}
> >
<Stack spacing={2}> <Stack spacing={2}>
<RichTextField <RichTextField
minRows={12} minRows={12}
label="Cover letter" label={t("jobDetailsCoverLetter")}
value={text} value={text}
disabled={busy} disabled={busy}
onChange={setDraft} onChange={setDraft}
placeholder="Write it yourself, start from the template, or generate a tailored draft." placeholder={t("assetsCoverPlaceholder")}
/> />
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap> <Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
<Button variant="contained" disabled={busy || !dirty} onClick={() => save(text, draftAiAction ? "ai" : "manual", draftAiAction ?? undefined)}> <Button variant="contained" disabled={busy || !dirty} onClick={() => save(text, draftAiAction ? "ai" : "manual", draftAiAction ?? undefined)}>
Save {t("save")}
</Button> </Button>
<Button disabled={busy || !dirty} onClick={() => { setDraft(null); setDraftAiAction(null); }}> <Button disabled={busy || !dirty} onClick={() => { setDraft(null); setDraftAiAction(null); }}>
Discard changes {t("assetsDiscardChanges")}
</Button> </Button>
<Button <Button
disabled={busy || text.trim().length > 0} disabled={busy || text.trim().length > 0}
onClick={() => setDraft(TEMPLATE)} onClick={() => setDraft(TEMPLATE)}
> >
Start from template {t("assetsStartTemplate")}
</Button> </Button>
{dirty && ( {dirty && (
<Chip size="small" color="warning" variant="outlined" label="Unsaved changes" /> <Chip size="small" color="warning" variant="outlined" label={t("assetsUnsavedChanges")} />
)} )}
</Stack> </Stack>
</Stack> </Stack>
@@ -356,10 +358,10 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
onApply={(value, aiAction) => { setDraft(value); setDraftAiAction(aiAction); }} onApply={(value, aiAction) => { setDraft(value); setDraftAiAction(aiAction); }}
/> />
<Shell title="Version history" loading={loading} error={null}> <Shell title={t("assetsVersionHistory")} loading={loading} error={null}>
{(data?.versions.length ?? 0) === 0 ? ( {(data?.versions.length ?? 0) === 0 ? (
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
No versions yet. The first save starts the history. {t("assetsNoVersions")}
</Typography> </Typography>
) : ( ) : (
<Stack spacing={0.5}> <Stack spacing={0.5}>
@@ -375,19 +377,19 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap"> <Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap">
<Typography variant="body2" sx={{ fontWeight: 600 }}>v{v.version}</Typography> <Typography variant="body2" sx={{ fontWeight: 600 }}>v{v.version}</Typography>
<Chip size="small" variant="outlined" label={v.aiAction ? `${v.source} · ${v.aiAction}` : v.source} /> <Chip size="small" variant="outlined" label={v.aiAction ? `${v.source} · ${v.aiAction}` : v.source} />
{v.isCurrent && <Chip size="small" color="primary" variant="outlined" label="Current" />} {v.isCurrent && <Chip size="small" color="primary" variant="outlined" label={t("assetsCurrent")} />}
</Stack> </Stack>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
{new Date(v.createdAtUtc).toLocaleString()} · {v.length} characters {new Date(v.createdAtUtc).toLocaleString()} · {t("assetsCharacterCount", { count: v.length })}
</Typography> </Typography>
</Box> </Box>
{!v.isCurrent && ( {!v.isCurrent && (
<Tooltip title="Restore this version"> <Tooltip title={t("assetsRestoreVersion")}>
<span> <span>
<IconButton <IconButton
size="small" size="small"
disabled={busy} disabled={busy}
aria-label={`Restore version ${v.version}`} aria-label={t("assetsRestoreVersionNumber", { version: v.version })}
onClick={() => restore(v.version)} onClick={() => restore(v.version)}
> >
<RestoreIcon fontSize="small" /> <RestoreIcon fontSize="small" />
@@ -405,15 +407,15 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
} }
const COVER_LETTER_ACTIONS = [ const COVER_LETTER_ACTIONS = [
{ key: "generate", label: "Generate" }, { key: "generate", labelKey: "coverActionGenerate" },
{ key: "regenerate", label: "Fresh alternative" }, { key: "regenerate", labelKey: "coverActionRegenerate" },
{ key: "improve", label: "Improve" }, { key: "improve", labelKey: "coverActionImprove" },
{ key: "shorten", label: "Shorten" }, { key: "shorten", labelKey: "coverActionShorten" },
{ key: "expand", label: "Add detail" }, { key: "expand", labelKey: "coverActionExpand" },
{ key: "professional", label: "More professional" }, { key: "professional", labelKey: "coverActionProfessional" },
{ key: "natural", label: "More natural" }, { key: "natural", labelKey: "coverActionNatural" },
{ key: "grammar", label: "Fix grammar" }, { key: "grammar", labelKey: "coverActionGrammar" },
{ key: "tailor", label: "Tailor more closely" }, { key: "tailor", labelKey: "coverActionTailor" },
] as const; ] as const;
function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number; currentText: string; onApply: (text: string, action: string) => void }) { function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number; currentText: string; onApply: (text: string, action: string) => void }) {
@@ -466,17 +468,18 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number
)} )}
<Stack direction={{ xs: "column", sm: "row" }} spacing={1.5}> <Stack direction={{ xs: "column", sm: "row" }} spacing={1.5}>
<FormControl size="small" sx={{ minWidth: 180 }}> <FormControl size="small" sx={{ minWidth: 180 }}>
<InputLabel>Action</InputLabel> <InputLabel>{t("coverAiAction")}</InputLabel>
<Select label="Action" value={action} onChange={(event) => setAction(event.target.value)}> <Select label={t("coverAiAction")} value={action} onChange={(event) => setAction(event.target.value)}>
{COVER_LETTER_ACTIONS.map((item) => <MenuItem key={item.key} value={item.key}>{item.label}</MenuItem>)} {COVER_LETTER_ACTIONS.map((item) => <MenuItem key={item.key} value={item.key}>{t(item.labelKey)}</MenuItem>)}
</Select> </Select>
</FormControl> </FormControl>
<FormControl size="small" sx={{ minWidth: 160 }}> <FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel>Tone</InputLabel> <InputLabel>{t("coverAiTone")}</InputLabel>
<Select label="Tone" value={mode} onChange={(event) => setMode(event.target.value)}> <Select label={t("coverAiTone")} value={mode} onChange={(event) => setMode(event.target.value)}>
{[ {[
"professional", "friendly", "short", "detailed", "modern", "traditional", ["professional", "coverToneProfessional"], ["friendly", "coverToneFriendly"], ["short", "coverToneShort"],
].map((item) => <MenuItem key={item} value={item}>{item[0].toUpperCase() + item.slice(1)}</MenuItem>)} ["detailed", "coverToneDetailed"], ["modern", "coverToneModern"], ["traditional", "coverToneTraditional"],
].map(([value, key]) => <MenuItem key={value} value={value}>{t(key as any)}</MenuItem>)}
</Select> </Select>
</FormControl> </FormControl>
<FormControl size="small" sx={{ minWidth: 190 }}> <FormControl size="small" sx={{ minWidth: 190 }}>
@@ -489,14 +492,14 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number
</Stack> </Stack>
<TextField <TextField
label={t("coverAiAdditionalInstructions")} label={t("coverAiAdditionalInstructions")}
placeholder="For example: Focus on my .NET experience and keep it concise." placeholder={t("coverAiInstructionsPlaceholder")}
value={instructions} value={instructions}
onChange={(event) => setInstructions(event.target.value)} onChange={(event) => setInstructions(event.target.value)}
multiline multiline
minRows={2} minRows={2}
fullWidth fullWidth
/> />
{error && <Alert severity="error" action={<Button color="inherit" size="small" onClick={() => void generate()}>Retry</Button>}>{error}</Alert>} {error && <Alert severity="error" action={<Button color="inherit" size="small" onClick={() => void generate()}>{t("retry")}</Button>}>{error}</Alert>}
<Button <Button
variant="contained" variant="contained"
startIcon={<AutoFixHighIcon />} startIcon={<AutoFixHighIcon />}
@@ -504,7 +507,7 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number
onClick={() => void generate()} onClick={() => void generate()}
sx={{ alignSelf: "flex-start" }} sx={{ alignSelf: "flex-start" }}
> >
{busy ? "Generating" : canUseAi ? COVER_LETTER_ACTIONS.find((item) => item.key === action)?.label : "Pro required"} {busy ? t("interviewAiGenerating") : canUseAi ? t(COVER_LETTER_ACTIONS.find((item) => item.key === action)?.labelKey ?? "coverActionGenerate") : t("interviewAiProRequired")}
</Button> </Button>
{suggestion && ( {suggestion && (
@@ -512,7 +515,7 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number
<Paper variant="outlined" sx={{ p: 2, minWidth: 0 }}> <Paper variant="outlined" sx={{ p: 2, minWidth: 0 }}>
<Typography variant="overline" color="text.secondary">{t("coverAiCurrent")}</Typography> <Typography variant="overline" color="text.secondary">{t("coverAiCurrent")}</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}> <Typography variant="body2" sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>
{currentText || "No current draft"} {currentText || t("coverAiNoDraft")}
</Typography> </Typography>
</Paper> </Paper>
<Paper variant="outlined" sx={{ p: 2, minWidth: 0, borderColor: "primary.main" }}> <Paper variant="outlined" sx={{ p: 2, minWidth: 0, borderColor: "primary.main" }}>
@@ -547,6 +550,7 @@ export function ApplicationPackageDraftsSection({
onSaved?: () => void; onSaved?: () => void;
onDirtyChange?: (dirty: boolean) => void; onDirtyChange?: (dirty: boolean) => void;
}) { }) {
const { t } = useI18n();
const initial = { applicationAnswer: initialApplicationAnswer, recruiterMessage: initialRecruiterMessage }; const initial = { applicationAnswer: initialApplicationAnswer, recruiterMessage: initialRecruiterMessage };
const [saved, setSaved] = useState<PackageDrafts>(initial); const [saved, setSaved] = useState<PackageDrafts>(initial);
const [draft, setDraft] = useState<PackageDrafts | null>(null); const [draft, setDraft] = useState<PackageDrafts | null>(null);
@@ -588,36 +592,36 @@ export function ApplicationPackageDraftsSection({
return ( return (
<Shell <Shell
title="Application answers and recruiter message" title={t("assetsApplicationDrafts")}
subtitle="Keep reusable application-form answers and a recruiter note with this application. Ordinary job notes stay separate." subtitle={t("assetsApplicationDraftsSubtitle")}
loading={false} loading={false}
error={error} error={error}
> >
<Stack spacing={2}> <Stack spacing={2}>
<RichTextField <RichTextField
minRows={6} minRows={6}
label="Application answer" label={t("assetsApplicationAnswer")}
value={value.applicationAnswer} value={value.applicationAnswer}
disabled={busy} disabled={busy}
onChange={(applicationAnswer) => update({ applicationAnswer })} onChange={(applicationAnswer) => update({ applicationAnswer })}
placeholder="Draft an answer for motivation, suitability, or another application-form question." placeholder={t("assetsApplicationAnswerPlaceholder")}
/> />
<RichTextField <RichTextField
minRows={4} minRows={4}
label="Recruiter message" label={t("assetsRecruiterMessage")}
value={value.recruiterMessage} value={value.recruiterMessage}
disabled={busy} disabled={busy}
onChange={(recruiterMessage) => update({ recruiterMessage })} onChange={(recruiterMessage) => update({ recruiterMessage })}
placeholder="Draft a concise message to the recruiter or hiring manager." placeholder={t("assetsRecruiterMessagePlaceholder")}
/> />
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap alignItems="center"> <Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap alignItems="center">
<Button variant="contained" disabled={busy || !dirty} onClick={save}> <Button variant="contained" disabled={busy || !dirty} onClick={save}>
Save application drafts {t("assetsSaveDrafts")}
</Button> </Button>
<Button disabled={busy || !dirty} onClick={() => setDraft(null)}> <Button disabled={busy || !dirty} onClick={() => setDraft(null)}>
Discard changes {t("assetsDiscardChanges")}
</Button> </Button>
{dirty && <Chip size="small" color="warning" variant="outlined" label="Unsaved changes" />} {dirty && <Chip size="small" color="warning" variant="outlined" label={t("assetsUnsavedChanges")} />}
</Stack> </Stack>
</Stack> </Stack>
</Shell> </Shell>
@@ -7,7 +7,7 @@ import {
import { getApiErrorMessage } from "../api"; import { getApiErrorMessage } from "../api";
import { import {
CareerMatch, JobAnalysis, TIMELINE_CATEGORY_LABELS, Timeline, applicationIntelligenceApi, CareerMatch, JobAnalysis, Timeline, applicationIntelligenceApi,
} from "../applicationWorkspace"; } from "../applicationWorkspace";
import { useI18n } from "../i18n/I18nProvider"; import { useI18n } from "../i18n/I18nProvider";
@@ -110,6 +110,7 @@ function Bullets({ label, values }: { label: string; values: string[] }) {
// ---------- Timeline ---------- // ---------- Timeline ----------
export function ApplicationTimeline({ jobId }: { jobId: number }) { export function ApplicationTimeline({ jobId }: { jobId: number }) {
const { t } = useI18n();
const [category, setCategory] = useState<string>(""); const [category, setCategory] = useState<string>("");
const { data, error, loading } = useIntelligence<Timeline>( const { data, error, loading } = useIntelligence<Timeline>(
() => applicationIntelligenceApi.timeline(jobId, category || undefined), () => applicationIntelligenceApi.timeline(jobId, category || undefined),
@@ -122,7 +123,7 @@ export function ApplicationTimeline({ jobId }: { jobId: number }) {
<Stack spacing={2}> <Stack spacing={2}>
{hasEvents && (data?.milestones.length ?? 0) > 0 && ( {hasEvents && (data?.milestones.length ?? 0) > 0 && (
<Paper sx={{ p: 2, borderRadius: 3 }}> <Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>Milestones</Typography> <Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>{t("intelligenceMilestones")}</Typography>
<Stack spacing={0.75}> <Stack spacing={0.75}>
{(data?.milestones ?? []).map((m) => ( {(data?.milestones ?? []).map((m) => (
<Stack key={m.id} direction="row" spacing={1} justifyContent="space-between" alignItems="baseline"> <Stack key={m.id} direction="row" spacing={1} justifyContent="space-between" alignItems="baseline">
@@ -137,12 +138,12 @@ export function ApplicationTimeline({ jobId }: { jobId: number }) {
)} )}
<SectionShell <SectionShell
title="Timeline" title={t("intelligenceTimeline")}
subtitle="Everything recorded for this application, newest first." subtitle={t("intelligenceTimelineSubtitle")}
loading={loading} loading={loading}
error={error} error={error}
empty={!hasEvents} empty={!hasEvents}
emptyText="Nothing has happened yet. Activity appears here as you move the application along." emptyText={t("intelligenceTimelineEmpty")}
> >
{/* SectionShell takes children as a prop, so this JSX is built before it decides whether to {/* SectionShell takes children as a prop, so this JSX is built before it decides whether to
render it — every access has to tolerate a null `data`. */} render it — every access has to tolerate a null `data`. */}
@@ -153,20 +154,20 @@ export function ApplicationTimeline({ jobId }: { jobId: number }) {
exclusive exclusive
value={category} value={category}
onChange={(_e, next) => setCategory(next ?? "")} onChange={(_e, next) => setCategory(next ?? "")}
aria-label="Filter timeline by category" aria-label={t("intelligenceTimelineFilter")}
sx={{ flexWrap: "wrap" }} sx={{ flexWrap: "wrap" }}
> >
<ToggleButton value="" aria-label="All events">All</ToggleButton> <ToggleButton value="" aria-label={t("intelligenceAllEvents")}>{t("intelligenceAll")}</ToggleButton>
{(data?.categories ?? []).map((c) => ( {(data?.categories ?? []).map((c) => (
<ToggleButton key={c} value={c} aria-label={TIMELINE_CATEGORY_LABELS[c] ?? c}> <ToggleButton key={c} value={c} aria-label={timelineCategoryLabel(t, c)}>
{TIMELINE_CATEGORY_LABELS[c] ?? c} {timelineCategoryLabel(t, c)}
</ToggleButton> </ToggleButton>
))} ))}
</ToggleButtonGroup> </ToggleButtonGroup>
)} )}
{(data?.days.length ?? 0) === 0 ? ( {(data?.days.length ?? 0) === 0 ? (
<Typography variant="body2" color="text.secondary">No events in this category.</Typography> <Typography variant="body2" color="text.secondary">{t("intelligenceNoCategoryEvents")}</Typography>
) : ( ) : (
(data?.days ?? []).map((day) => ( (data?.days ?? []).map((day) => (
<Box key={day.date}> <Box key={day.date}>
@@ -180,7 +181,7 @@ export function ApplicationTimeline({ jobId }: { jobId: number }) {
<Typography variant="body2" sx={{ fontWeight: e.isMilestone ? 700 : 500 }}> <Typography variant="body2" sx={{ fontWeight: e.isMilestone ? 700 : 500 }}>
{e.summary} {e.summary}
</Typography> </Typography>
{e.isMilestone && <Chip size="small" label="Milestone" color="primary" variant="outlined" />} {e.isMilestone && <Chip size="small" label={t("intelligenceMilestone")} color="primary" variant="outlined" />}
</Stack> </Stack>
{e.detail && ( {e.detail && (
<Typography variant="caption" color="text.secondary">{e.detail}</Typography> <Typography variant="caption" color="text.secondary">{e.detail}</Typography>
@@ -200,6 +201,7 @@ export function ApplicationTimeline({ jobId }: { jobId: number }) {
// ---------- Analysis ---------- // ---------- Analysis ----------
export function ApplicationAnalysis({ jobId }: { jobId: number }) { export function ApplicationAnalysis({ jobId }: { jobId: number }) {
const { t } = useI18n();
const { data, error, loading } = useIntelligence<JobAnalysis>( const { data, error, loading } = useIntelligence<JobAnalysis>(
() => applicationIntelligenceApi.analysis(jobId), () => applicationIntelligenceApi.analysis(jobId),
[jobId], [jobId],
@@ -207,27 +209,26 @@ export function ApplicationAnalysis({ jobId }: { jobId: number }) {
const facts: [string, string | null][] = data const facts: [string, string | null][] = data
? [ ? [
["Role", data.role], [t("intelligenceRole"), data.role],
["Company", data.company], [t("company"), data.company],
["Location", data.location], [t("location"), data.location],
["Employment type", data.employmentType], [t("intelligenceEmploymentType"), data.employmentType],
["Seniority", data.seniority], [t("intelligenceSeniority"), data.seniority],
["Salary", data.salary], [t("jobDetailsSalary"), data.salary],
] ]
: []; : [];
return ( return (
<SectionShell <SectionShell
title="Analysis" title={t("workspaceAnalysis")}
subtitle="Read straight from the advert — no AI, same answer every time." subtitle={t("intelligenceAnalysisSubtitle")}
loading={loading} loading={loading}
error={error} error={error}
> >
<Stack spacing={2}> <Stack spacing={2}>
{data && !data.hasJobDescription && ( {data && !data.hasJobDescription && (
<Alert severity="info" sx={{ borderRadius: 2 }}> <Alert severity="info" sx={{ borderRadius: 2 }}>
No advert text saved yet. Paste it into the application to get requirements, technologies {t("intelligenceNoAdvert")}
and interview topics.
</Alert> </Alert>
)} )}
@@ -242,12 +243,12 @@ export function ApplicationAnalysis({ jobId }: { jobId: number }) {
))} ))}
</Box> </Box>
<Chips label="Technologies" values={data?.technologies ?? []} /> <Chips label={t("intelligenceTechnologies")} values={data?.technologies ?? []} />
<Chips label="Skills" values={data?.skills ?? []} /> <Chips label={t("intelligenceSkills")} values={data?.skills ?? []} />
<Bullets label="Important requirements" values={data?.importantRequirements ?? []} /> <Bullets label={t("intelligenceRequirements")} values={data?.importantRequirements ?? []} />
<Bullets label="Responsibilities" values={data?.responsibilities ?? []} /> <Bullets label={t("intelligenceResponsibilities")} values={data?.responsibilities ?? []} />
<Chips label="Likely interview topics" values={data?.interviewTopics ?? []} /> <Chips label={t("intelligenceInterviewTopics")} values={data?.interviewTopics ?? []} />
<Bullets label="Not stated in the advert" values={data?.missingInformation ?? []} /> <Bullets label={t("intelligenceNotStated")} values={data?.missingInformation ?? []} />
</Stack> </Stack>
</SectionShell> </SectionShell>
); );
@@ -278,7 +279,7 @@ export function ApplicationMatch({ jobId }: { jobId: number }) {
</Alert> </Alert>
) : data && !data.hasCareerProfile ? ( ) : data && !data.hasCareerProfile ? (
<Alert severity="info" sx={{ borderRadius: 2 }}> <Alert severity="info" sx={{ borderRadius: 2 }}>
The linked CV cannot be matched until its career profile has content. {t("intelligenceCvProfileEmpty")}
</Alert> </Alert>
) : ( ) : (
<> <>
@@ -290,24 +291,24 @@ export function ApplicationMatch({ jobId }: { jobId: number }) {
<LinearProgress <LinearProgress
variant="determinate" variant="determinate"
value={data?.score ?? 0} value={data?.score ?? 0}
aria-label="Career match score" aria-label={t("intelligenceMatchScore")}
sx={{ mt: 1, height: 8, borderRadius: 4 }} sx={{ mt: 1, height: 8, borderRadius: 4 }}
/> />
</Box> </Box>
{data && !data.hasEnoughSignal && ( {data && !data.hasEnoughSignal && (
<Alert severity="warning" sx={{ borderRadius: 2 }}> <Alert severity="warning" sx={{ borderRadius: 2 }}>
The advert is too short to score reliably. Paste the full text for a real match. {t("intelligenceAdvertTooShort")}
</Alert> </Alert>
)} )}
<Chips label="Matched" values={data?.matchedSkills ?? []} color="success" /> <Chips label={t("intelligenceMatched")} values={data?.matchedSkills ?? []} color="success" />
<Chips label="Missing" values={data?.missingSkills ?? []} color="warning" /> <Chips label={t("intelligenceMissing")} values={data?.missingSkills ?? []} color="warning" />
{(data?.relevantExperience.length ?? 0) > 0 && ( {(data?.relevantExperience.length ?? 0) > 0 && (
<Box> <Box>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 700 }}> <Typography variant="caption" color="text.secondary" sx={{ fontWeight: 700 }}>
Relevant experience {t("intelligenceRelevantExperience")}
</Typography> </Typography>
<Stack spacing={1} sx={{ mt: 0.5 }}> <Stack spacing={1} sx={{ mt: 0.5 }}>
{(data?.relevantExperience ?? []).map((e, i) => ( {(data?.relevantExperience ?? []).map((e, i) => (
@@ -326,7 +327,7 @@ export function ApplicationMatch({ jobId }: { jobId: number }) {
{(data?.relevantProjects.length ?? 0) > 0 && ( {(data?.relevantProjects.length ?? 0) > 0 && (
<Box> <Box>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 700 }}> <Typography variant="caption" color="text.secondary" sx={{ fontWeight: 700 }}>
Relevant projects {t("intelligenceRelevantProjects")}
</Typography> </Typography>
<Stack spacing={1} sx={{ mt: 0.5 }}> <Stack spacing={1} sx={{ mt: 0.5 }}>
{(data?.relevantProjects ?? []).map((p, i) => ( {(data?.relevantProjects ?? []).map((p, i) => (
@@ -344,8 +345,19 @@ export function ApplicationMatch({ jobId }: { jobId: number }) {
</> </>
)} )}
<Bullets label="Suggestions" values={data?.suggestions ?? []} /> <Bullets label={t("intelligenceSuggestions")} values={data?.suggestions ?? []} />
</Stack> </Stack>
</SectionShell> </SectionShell>
); );
} }
function timelineCategoryLabel(t: (key: any) => string, category: string): string {
const keys: Record<string, any> = {
lifecycle: "intelligenceCategoryLifecycle",
stage: "intelligenceCategoryStage",
"follow-up": "intelligenceCategoryFollowUp",
communication: "intelligenceCategoryCommunication",
ai: "intelligenceCategoryAi",
};
return keys[category] ? t(keys[category]) : category;
}
+216
View File
@@ -131,6 +131,114 @@ export const translations = {
interviewFollowUpOpenTasks: "{count} open follow-up tasks on the checklist.", interviewFollowUpOpenTasks: "{count} open follow-up tasks on the checklist.",
interviewFollowUpDate: "Follow up on", interviewFollowUpDate: "Follow up on",
interviewFollowUpEmpty: "No follow-up scheduled. Applications without one can go quiet.", interviewFollowUpEmpty: "No follow-up scheduled. Applications without one can go quiet.",
intelligenceMilestones: "Milestones",
intelligenceTimeline: "Timeline",
intelligenceTimelineSubtitle: "Everything recorded for this application, newest first.",
intelligenceTimelineEmpty: "Nothing has happened yet. Activity appears here as the application moves forward.",
intelligenceTimelineFilter: "Filter timeline by category",
intelligenceAllEvents: "All events",
intelligenceAll: "All",
intelligenceNoCategoryEvents: "No events in this category.",
intelligenceMilestone: "Milestone",
intelligenceCategoryLifecycle: "Lifecycle",
intelligenceCategoryStage: "Stage",
intelligenceCategoryFollowUp: "Follow-up",
intelligenceCategoryCommunication: "Communication",
intelligenceCategoryAi: "AI",
intelligenceRole: "Role",
intelligenceEmploymentType: "Employment type",
intelligenceSeniority: "Seniority",
intelligenceAnalysisSubtitle: "Derived directly from the current advert — no repeated AI call when the page opens.",
intelligenceNoAdvert: "No advert text saved yet. Add it to detect requirements, technologies and likely interview topics.",
intelligenceTechnologies: "Technologies",
intelligenceSkills: "Skills",
intelligenceRequirements: "Important requirements",
intelligenceResponsibilities: "Responsibilities",
intelligenceInterviewTopics: "Likely interview topics",
intelligenceNotStated: "Not stated in the advert",
intelligenceCvProfileEmpty: "The linked CV cannot be matched until its career profile contains content.",
intelligenceMatchScore: "Career match score",
intelligenceAdvertTooShort: "The advert is too short to score reliably. Add the full text for a meaningful match.",
intelligenceMatched: "Matched",
intelligenceMissing: "Missing",
intelligenceRelevantExperience: "Relevant experience",
intelligenceRelevantProjects: "Relevant projects",
intelligenceSuggestions: "Suggestions",
assetsCvSubtitle: "The CV variant used for this application. Variants tailor the master career profile without duplicating it.",
assetsNoCvVariants: "No CV variants yet. Build one in the CV builder; it starts from the master career profile, so your history stays reusable.",
assetsAttachedCv: "Attached CV variant",
assetsAttachedCvHelp: "Changing this only changes which CV the application uses. The CV itself is untouched.",
assetsNone: "None",
assetsThemeVersion: "Theme {theme} · version {version}",
assetsPublic: "public",
assetsEditPreviewExport: "Edit, preview and export",
assetsDuplicateForJob: "Duplicate for this job",
assetsNoCvAttached: "No CV is attached to this application yet.",
assetsLegacyCvText: "This application also contains legacy tailored CV text. An attached CV variant takes precedence.",
assetsTailoring: "Tailoring",
assetsTailoringSubtitle: "What to emphasise for this advert. Suggestions never edit the profile or CV.",
assetsBuildProfile: "Build your career profile to get experience and project suggestions.",
assetsAddAdvert: "Add the advert text to get keyword and requirement suggestions.",
assetsNoSuggestions: "Nothing to suggest yet.",
assetsCoverSubtitle: "Every save creates a version, so manually edited text is never overwritten without review.",
assetsCoverPlaceholder: "Write it yourself, start from the template, or generate a tailored draft.",
assetsDiscardChanges: "Discard changes",
assetsStartTemplate: "Start from template",
assetsUnsavedChanges: "Unsaved changes",
assetsVersionHistory: "Version history",
assetsNoVersions: "No versions yet. The first save starts the history.",
assetsCurrent: "Current",
assetsCharacterCount: "{count} characters",
assetsRestoreVersion: "Restore this version",
assetsRestoreVersionNumber: "Restore version {version}",
coverAiAction: "Action",
coverAiTone: "Tone",
coverActionGenerate: "Generate",
coverActionRegenerate: "Fresh alternative",
coverActionImprove: "Improve",
coverActionShorten: "Shorten",
coverActionExpand: "Add detail",
coverActionProfessional: "More professional",
coverActionNatural: "More natural",
coverActionGrammar: "Fix grammar",
coverActionTailor: "Tailor more closely",
coverToneProfessional: "Professional",
coverToneFriendly: "Friendly",
coverToneShort: "Short",
coverToneDetailed: "Detailed",
coverToneModern: "Modern",
coverToneTraditional: "Traditional",
coverAiInstructionsPlaceholder: "For example: Focus on my .NET experience and keep it concise.",
coverAiNoDraft: "No current draft",
assetsApplicationDrafts: "Application answers and recruiter message",
assetsApplicationDraftsSubtitle: "Keep reusable application-form answers and a recruiter note with this application. Ordinary notes remain separate.",
assetsApplicationAnswer: "Application answer",
assetsApplicationAnswerPlaceholder: "Draft an answer about motivation, suitability, or another application-form question.",
assetsRecruiterMessage: "Recruiter message",
assetsRecruiterMessagePlaceholder: "Draft a concise message to the recruiter or hiring manager.",
assetsSaveDrafts: "Save application drafts",
workspaceTailoredText: "Tailored text",
workspaceNotPrepared: "Not prepared",
workspaceReady: "Ready",
workspaceNotWritten: "Not written",
workspaceAttachedCount: "{count} attached",
workspaceAiSuggestions: "AI suggestions",
workspaceSavedCount: "{count} saved",
workspaceNoneYet: "None yet",
workspaceDoneCount: "{completed}/{total} done",
workspaceCountry: "Country",
workspaceSource: "Source",
workspaceDiscovered: "Discovered",
workspaceApplied: "Applied",
workspaceNotScheduled: "Not scheduled",
workspaceApplicationInfo: "Application information",
workspaceEdit: "Edit",
workspaceNotes: "Notes",
workspaceJobDescription: "Job description",
workspaceAddAdvert: "Add advert",
workspaceNoJobDescription: "No advert text is saved. Analysis and matching need the job description.",
workspaceTranslatedAdvert: "Translated advert",
workspaceOriginalAdvert: "Original advert",
notFoundTitle: "Page not found", notFoundTitle: "Page not found",
notFoundBody: "The page you were looking for does not exist or may have moved.", notFoundBody: "The page you were looking for does not exist or may have moved.",
appErrorTitle: "Something went wrong", appErrorTitle: "Something went wrong",
@@ -1398,6 +1506,114 @@ export const translations = {
interviewFollowUpOpenTasks: "{count} åpne oppfølgingsoppgaver i sjekklisten.", interviewFollowUpOpenTasks: "{count} åpne oppfølgingsoppgaver i sjekklisten.",
interviewFollowUpDate: "Følg opp den", interviewFollowUpDate: "Følg opp den",
interviewFollowUpEmpty: "Ingen oppfølging er planlagt. Søknader uten oppfølging kan bli stille.", interviewFollowUpEmpty: "Ingen oppfølging er planlagt. Søknader uten oppfølging kan bli stille.",
intelligenceMilestones: "Milepæler",
intelligenceTimeline: "Tidslinje",
intelligenceTimelineSubtitle: "Alt som er registrert for søknaden, nyeste først.",
intelligenceTimelineEmpty: "Ingenting har skjedd ennå. Aktivitet vises her når søknaden går videre.",
intelligenceTimelineFilter: "Filtrer tidslinjen etter kategori",
intelligenceAllEvents: "Alle hendelser",
intelligenceAll: "Alle",
intelligenceNoCategoryEvents: "Ingen hendelser i denne kategorien.",
intelligenceMilestone: "Milepæl",
intelligenceCategoryLifecycle: "Livssyklus",
intelligenceCategoryStage: "Stadium",
intelligenceCategoryFollowUp: "Oppfølging",
intelligenceCategoryCommunication: "Kommunikasjon",
intelligenceCategoryAi: "AI",
intelligenceRole: "Rolle",
intelligenceEmploymentType: "Ansettelsesform",
intelligenceSeniority: "Erfaringsnivå",
intelligenceAnalysisSubtitle: "Utledet direkte fra gjeldende annonse — ingen gjentatt AI-kall når siden åpnes.",
intelligenceNoAdvert: "Ingen annonsetekst er lagret ennå. Legg den til for å finne krav, teknologier og sannsynlige intervjutemaer.",
intelligenceTechnologies: "Teknologier",
intelligenceSkills: "Ferdigheter",
intelligenceRequirements: "Viktige krav",
intelligenceResponsibilities: "Arbeidsoppgaver",
intelligenceInterviewTopics: "Sannsynlige intervjutemaer",
intelligenceNotStated: "Ikke oppgitt i annonsen",
intelligenceCvProfileEmpty: "Den tilknyttede CV-en kan ikke sammenlignes før karriereprofilen inneholder data.",
intelligenceMatchScore: "Samsvar mellom CV og jobb",
intelligenceAdvertTooShort: "Annonsen er for kort til å gi en pålitelig vurdering. Legg til hele teksten for et meningsfylt samsvar.",
intelligenceMatched: "Samsvarer",
intelligenceMissing: "Mangler",
intelligenceRelevantExperience: "Relevant erfaring",
intelligenceRelevantProjects: "Relevante prosjekter",
intelligenceSuggestions: "Forslag",
assetsCvSubtitle: "CV-varianten som brukes for denne søknaden. Varianter tilpasser karriereprofilen uten å duplisere den.",
assetsNoCvVariants: "Ingen CV-varianter ennå. Bygg en i CV-byggeren; den starter fra karriereprofilen, slik at historikken kan gjenbrukes.",
assetsAttachedCv: "Tilknyttet CV-variant",
assetsAttachedCvHelp: "Dette endrer bare hvilken CV søknaden bruker. Selve CV-en forblir urørt.",
assetsNone: "Ingen",
assetsThemeVersion: "Tema {theme} · versjon {version}",
assetsPublic: "offentlig",
assetsEditPreviewExport: "Rediger, forhåndsvis og eksporter",
assetsDuplicateForJob: "Dupliser for denne jobben",
assetsNoCvAttached: "Ingen CV er tilknyttet søknaden ennå.",
assetsLegacyCvText: "Søknaden inneholder også eldre tilpasset CV-tekst. En tilknyttet CV-variant har prioritet.",
assetsTailoring: "Tilpasning",
assetsTailoringSubtitle: "Hva som bør fremheves for annonsen. Forslag redigerer aldri profilen eller CV-en.",
assetsBuildProfile: "Bygg karriereprofilen for å få forslag til erfaring og prosjekter.",
assetsAddAdvert: "Legg til annonseteksten for å få forslag til nøkkelord og krav.",
assetsNoSuggestions: "Ingen forslag ennå.",
assetsCoverSubtitle: "Hver lagring oppretter en versjon, slik at manuelt redigert tekst aldri overskrives uten gjennomgang.",
assetsCoverPlaceholder: "Skriv selv, start fra malen eller generer et tilpasset utkast.",
assetsDiscardChanges: "Forkast endringer",
assetsStartTemplate: "Start fra mal",
assetsUnsavedChanges: "Ulagrede endringer",
assetsVersionHistory: "Versjonshistorikk",
assetsNoVersions: "Ingen versjoner ennå. Første lagring starter historikken.",
assetsCurrent: "Gjeldende",
assetsCharacterCount: "{count} tegn",
assetsRestoreVersion: "Gjenopprett denne versjonen",
assetsRestoreVersionNumber: "Gjenopprett versjon {version}",
coverAiAction: "Handling",
coverAiTone: "Tone",
coverActionGenerate: "Generer",
coverActionRegenerate: "Nytt alternativ",
coverActionImprove: "Forbedre",
coverActionShorten: "Gjør kortere",
coverActionExpand: "Legg til detaljer",
coverActionProfessional: "Mer profesjonell",
coverActionNatural: "Mer naturlig",
coverActionGrammar: "Rett grammatikk",
coverActionTailor: "Tilpass tettere",
coverToneProfessional: "Profesjonell",
coverToneFriendly: "Vennlig",
coverToneShort: "Kort",
coverToneDetailed: "Detaljert",
coverToneModern: "Moderne",
coverToneTraditional: "Tradisjonell",
coverAiInstructionsPlaceholder: "For eksempel: Fokuser på .NET-erfaringen min og hold brevet kort.",
coverAiNoDraft: "Ingen gjeldende kladd",
assetsApplicationDrafts: "Søknadssvar og melding til rekrutterer",
assetsApplicationDraftsSubtitle: "Lagre gjenbrukbare søknadssvar og en rekrutterermelding med søknaden. Vanlige notater forblir separate.",
assetsApplicationAnswer: "Søknadssvar",
assetsApplicationAnswerPlaceholder: "Skriv et svar om motivasjon, egnethet eller et annet spørsmål i søknadsskjemaet.",
assetsRecruiterMessage: "Melding til rekrutterer",
assetsRecruiterMessagePlaceholder: "Skriv en kort melding til rekruttereren eller ansettelsesansvarlig.",
assetsSaveDrafts: "Lagre søknadsutkast",
workspaceTailoredText: "Tilpasset tekst",
workspaceNotPrepared: "Ikke forberedt",
workspaceReady: "Klar",
workspaceNotWritten: "Ikke skrevet",
workspaceAttachedCount: "{count} vedlagt",
workspaceAiSuggestions: "AI-forslag",
workspaceSavedCount: "{count} lagret",
workspaceNoneYet: "Ingen ennå",
workspaceDoneCount: "{completed}/{total} ferdig",
workspaceCountry: "Land",
workspaceSource: "Kilde",
workspaceDiscovered: "Oppdaget",
workspaceApplied: "Søkt",
workspaceNotScheduled: "Ikke planlagt",
workspaceApplicationInfo: "Søknadsinformasjon",
workspaceEdit: "Rediger",
workspaceNotes: "Notater",
workspaceJobDescription: "Stillingsbeskrivelse",
workspaceAddAdvert: "Legg til annonse",
workspaceNoJobDescription: "Ingen annonsetekst er lagret. Analyse og samsvar trenger stillingsbeskrivelsen.",
workspaceTranslatedAdvert: "Oversatt annonse",
workspaceOriginalAdvert: "Original annonse",
notFoundTitle: "Siden ble ikke funnet", notFoundTitle: "Siden ble ikke funnet",
notFoundBody: "Siden du lette etter finnes ikke eller kan ha blitt flyttet.", notFoundBody: "Siden du lette etter finnes ikke eller kan ha blitt flyttet.",
appErrorTitle: "Noe gikk galt", appErrorTitle: "Noe gikk galt",
@@ -302,14 +302,14 @@ function OverviewSection({ overview, onGo, onReload, onEdit }: {
onReload: () => void; onReload: () => void;
onEdit: () => void; onEdit: () => void;
}) { }) {
const { t } = useI18n(); const { language, t } = useI18n();
const stats = useMemo(() => overview ? [ const stats = useMemo(() => overview ? [
{ icon: <DescriptionOutlinedIcon fontSize="small" />, label: "CV", value: overview.cv.variantName ?? (overview.cv.hasTailoredCvText ? "Tailored text" : "Not prepared"), ok: !!overview.cv.variantId || overview.cv.hasTailoredCvText, go: "cv" as const }, { icon: <DescriptionOutlinedIcon fontSize="small" />, label: t("workspaceCv"), value: overview.cv.variantName ?? (overview.cv.hasTailoredCvText ? t("workspaceTailoredText") : t("workspaceNotPrepared")), ok: !!overview.cv.variantId || overview.cv.hasTailoredCvText, go: "cv" as const },
{ icon: <MailOutlineIcon fontSize="small" />, label: "Cover letter", value: overview.hasCoverLetter ? "Ready" : "Not written", ok: overview.hasCoverLetter, go: "cover-letter" as const }, { icon: <MailOutlineIcon fontSize="small" />, label: t("jobDetailsCoverLetter"), value: overview.hasCoverLetter ? t("workspaceReady") : t("workspaceNotWritten"), ok: overview.hasCoverLetter, go: "cover-letter" as const },
{ icon: <FolderOutlinedIcon fontSize="small" />, label: "Documents", value: overview.documentCount ? `${overview.documentCount} attached` : "None", ok: overview.documentCount > 0, go: "overview" as const }, { icon: <FolderOutlinedIcon fontSize="small" />, label: t("workspaceDocuments"), value: overview.documentCount ? t("workspaceAttachedCount", { count: overview.documentCount }) : t("assetsNone"), ok: overview.documentCount > 0, go: "overview" as const },
{ icon: <AutoFixHighIcon fontSize="small" />, label: "AI suggestions", value: overview.aiInteractionCount ? `${overview.aiInteractionCount} saved` : "None yet", ok: overview.aiInteractionCount > 0, go: "analysis" as const }, { icon: <AutoFixHighIcon fontSize="small" />, label: t("workspaceAiSuggestions"), value: overview.aiInteractionCount ? t("workspaceSavedCount", { count: overview.aiInteractionCount }) : t("workspaceNoneYet"), ok: overview.aiInteractionCount > 0, go: "analysis" as const },
{ icon: <ChecklistIcon fontSize="small" />, label: "Checklist", value: overview.checklistProgress ? `${overview.checklistProgress.completed}/${overview.checklistProgress.total} done` : "—", ok: (overview.checklistProgress?.percent ?? 0) === 100, go: "overview" as const }, { icon: <ChecklistIcon fontSize="small" />, label: t("workspaceChecklist"), value: overview.checklistProgress ? t("workspaceDoneCount", { completed: overview.checklistProgress.completed, total: overview.checklistProgress.total }) : "—", ok: (overview.checklistProgress?.percent ?? 0) === 100, go: "overview" as const },
] : [], [overview]); ] : [], [overview, t]);
if (!overview) { if (!overview) {
return <Stack spacing={2}>{[0, 1].map((i) => <Skeleton key={i} variant="rounded" height={120} />)}</Stack>; return <Stack spacing={2}>{[0, 1].map((i) => <Skeleton key={i} variant="rounded" height={120} />)}</Stack>;
@@ -361,7 +361,7 @@ function OverviewSection({ overview, onGo, onReload, onEdit }: {
{overview.recentActivity.map((a, i) => ( {overview.recentActivity.map((a, i) => (
<Stack key={i} direction="row" spacing={1} justifyContent="space-between"> <Stack key={i} direction="row" spacing={1} justifyContent="space-between">
<Typography variant="body2"><strong>{a.type}</strong>{a.detail ? `${a.detail}` : ""}</Typography> <Typography variant="body2"><strong>{a.type}</strong>{a.detail ? `${a.detail}` : ""}</Typography>
<Typography variant="caption" color="text.secondary">{new Date(a.at).toLocaleDateString()}</Typography> <Typography variant="caption" color="text.secondary">{new Date(a.at).toLocaleDateString(language === "nb" ? "nb-NO" : "en")}</Typography>
</Stack> </Stack>
))} ))}
</Stack> </Stack>
@@ -408,26 +408,27 @@ function workspaceSectionLabel(t: (key: any) => string, section: WorkspaceSectio
} }
function JobDetailsSection({ overview, onEdit }: { overview: WorkspaceOverview | null; onEdit: () => void }) { function JobDetailsSection({ overview, onEdit }: { overview: WorkspaceOverview | null; onEdit: () => void }) {
const { language, t } = useI18n();
if (!overview) return <Skeleton variant="rounded" height={200} />; if (!overview) return <Skeleton variant="rounded" height={200} />;
const rows: [string, string][] = [ const rows: [string, string][] = [
["Company", overview.company ?? "—"], [t("company"), overview.company ?? "—"],
["Location", overview.location ?? "—"], [t("location"), overview.location ?? "—"],
["Country", overview.countryCode ?? "—"], [t("workspaceCountry"), overview.countryCode ?? "—"],
["Source", overview.source ?? "—"], [t("workspaceSource"), overview.source ?? "—"],
["Salary", overview.salary ?? "—"], [t("jobDetailsSalary"), overview.salary ?? "—"],
["Status", overview.status], [t("addJobModalStatus"), statusLabel(t, overview.status)],
["Discovered", overview.savedAt ? new Date(overview.savedAt).toLocaleDateString() : "—"], [t("workspaceDiscovered"), overview.savedAt ? new Date(overview.savedAt).toLocaleDateString(language === "nb" ? "nb-NO" : "en") : "—"],
["Applied", overview.dateApplied ? new Date(overview.dateApplied).toLocaleDateString() : "—"], [t("workspaceApplied"), overview.dateApplied ? new Date(overview.dateApplied).toLocaleDateString(language === "nb" ? "nb-NO" : "en") : "—"],
["Deadline", overview.deadline ? new Date(overview.deadline).toLocaleDateString() : "—"], [t("jobDetailsDeadline"), overview.deadline ? new Date(overview.deadline).toLocaleDateString(language === "nb" ? "nb-NO" : "en") : "—"],
["Follow-up", overview.followUpAt ? new Date(overview.followUpAt).toLocaleDateString() : "Not scheduled"], [t("intelligenceCategoryFollowUp"), overview.followUpAt ? new Date(overview.followUpAt).toLocaleDateString(language === "nb" ? "nb-NO" : "en") : t("workspaceNotScheduled")],
["Next action", overview.nextAction ?? "—"], [t("workspaceNextAction"), overview.nextAction ?? "—"],
]; ];
return ( return (
<Stack spacing={2}> <Stack spacing={2}>
<Paper sx={{ p: 2.5, borderRadius: 3 }}> <Paper sx={{ p: 2.5, borderRadius: 3 }}>
<Stack direction="row" alignItems="center" justifyContent="space-between" gap={1} sx={{ mb: 1.5 }}> <Stack direction="row" alignItems="center" justifyContent="space-between" gap={1} sx={{ mb: 1.5 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>Application information</Typography> <Typography variant="subtitle1" sx={{ fontWeight: 800 }}>{t("workspaceApplicationInfo")}</Typography>
<Button size="small" startIcon={<EditOutlinedIcon />} onClick={onEdit}>Edit</Button> <Button size="small" startIcon={<EditOutlinedIcon />} onClick={onEdit}>{t("workspaceEdit")}</Button>
</Stack> </Stack>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))", lg: "repeat(4, minmax(0, 1fr))" }, gap: 1.5 }}> <Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))", lg: "repeat(4, minmax(0, 1fr))" }, gap: 1.5 }}>
{rows.map(([k, v]) => ( {rows.map(([k, v]) => (
@@ -444,29 +445,29 @@ function JobDetailsSection({ overview, onEdit }: { overview: WorkspaceOverview |
) : null} ) : null}
{overview.notes ? ( {overview.notes ? (
<Box sx={{ mt: 2 }}> <Box sx={{ mt: 2 }}>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 700 }}>Notes</Typography> <Typography variant="caption" color="text.secondary" sx={{ fontWeight: 700 }}>{t("workspaceNotes")}</Typography>
<Typography variant="body2" sx={{ mt: 0.5, whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{overview.notes}</Typography> <Typography variant="body2" sx={{ mt: 0.5, whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{overview.notes}</Typography>
</Box> </Box>
) : null} ) : null}
</Paper> </Paper>
<Paper sx={{ p: { xs: 2, sm: 2.5 }, borderRadius: 3 }}> <Paper sx={{ p: { xs: 2, sm: 2.5 }, borderRadius: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 1.5 }}>Job description</Typography> <Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 1.5 }}>{t("workspaceJobDescription")}</Typography>
{!overview.hasJobDescription ? ( {!overview.hasJobDescription ? (
<Alert severity="warning" sx={{ borderRadius: 2 }} action={<Button color="inherit" size="small" onClick={onEdit}>Add advert</Button>}> <Alert severity="warning" sx={{ borderRadius: 2 }} action={<Button color="inherit" size="small" onClick={onEdit}>{t("workspaceAddAdvert")}</Button>}>
No advert text saved. Analysis and matching need the job description. {t("workspaceNoJobDescription")}
</Alert> </Alert>
) : ( ) : (
<Stack spacing={2.5}> <Stack spacing={2.5}>
{overview.translatedDescription ? ( {overview.translatedDescription ? (
<Box> <Box>
<Typography variant="overline" color="text.secondary">Translated advert</Typography> <Typography variant="overline" color="text.secondary">{t("workspaceTranslatedAdvert")}</Typography>
<Typography sx={{ mt: 0.5, whiteSpace: "pre-wrap", overflowWrap: "anywhere", lineHeight: 1.7 }}>{overview.translatedDescription}</Typography> <Typography sx={{ mt: 0.5, whiteSpace: "pre-wrap", overflowWrap: "anywhere", lineHeight: 1.7 }}>{overview.translatedDescription}</Typography>
</Box> </Box>
) : null} ) : null}
{overview.description ? ( {overview.description ? (
<Box> <Box>
{overview.translatedDescription ? <Typography variant="overline" color="text.secondary">Original advert{overview.descriptionLanguage ? ` · ${overview.descriptionLanguage.toUpperCase()}` : ""}</Typography> : null} {overview.translatedDescription ? <Typography variant="overline" color="text.secondary">{t("workspaceOriginalAdvert")}{overview.descriptionLanguage ? ` · ${overview.descriptionLanguage.toUpperCase()}` : ""}</Typography> : null}
<Typography sx={{ mt: 0.5, whiteSpace: "pre-wrap", overflowWrap: "anywhere", lineHeight: 1.7 }}>{overview.description}</Typography> <Typography sx={{ mt: 0.5, whiteSpace: "pre-wrap", overflowWrap: "anywhere", lineHeight: 1.7 }}>{overview.description}</Typography>
</Box> </Box>
) : null} ) : null}