fix(i18n): finish career activity copy

This commit is contained in:
cesnimda
2026-08-29 21:57:40 +02:00
parent a704c75440
commit 457f0601c0
13 changed files with 272 additions and 91 deletions
+31 -31
View File
@@ -153,7 +153,7 @@ export default function CareerProfilePage() {
// Retained so the shared JSX (copied from ProfilePage) reads identically; hardcoded for /career.
const careerOnly = true;
const { toast } = useToast();
const { t } = useI18n();
const { language, t } = useI18n();
const cvInputRef = useRef<HTMLInputElement | null>(null);
const avatarInputRef = useRef<HTMLInputElement | null>(null);
const [me, setMe] = useState<MeResponse | null>(null);
@@ -393,8 +393,8 @@ export default function CareerProfilePage() {
/> : null}
{loadError ? (
<Alert severity="error" sx={{ mb: 2, borderRadius: 2.5 }} action={<Button color="inherit" size="small" onClick={() => void loadProfile()}>Retry</Button>}>
Unable to load profile.
<Alert severity="error" sx={{ mb: 2, borderRadius: 2.5 }} action={<Button color="inherit" size="small" onClick={() => void loadProfile()}>{t("retry")}</Button>}>
{t("profileLoadFailed")}
<Typography variant="body2" sx={{ mt: 0.5 }}>{loadError}</Typography>
</Alert>
) : null}
@@ -468,12 +468,12 @@ export default function CareerProfilePage() {
<Box sx={{ display: workspaceSection === "import" ? "block" : "none" }}>
{!canUseAi && (
<Box sx={{ mb: 2 }}>
<ProFeatureNotice featureKey="career-ai" title="Build your Career Profile faster with Pro.">
Import, rebuild and improve CV content with AI. Manual profile editing remains available on Free.
<ProFeatureNotice featureKey="career-ai" title={t("profileProImportTitle")}>
{t("profileProImportBody")}
</ProFeatureNotice>
</Box>
)}
{profileDirty ? <Alert severity="warning" sx={{ mb: 2 }}>You have unsaved career edits. Save them before running actions that use the stored profile.</Alert> : null}
{profileDirty ? <Alert severity="warning" sx={{ mb: 2 }}>{t("profileUnsavedBeforeActions")}</Alert> : null}
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.5 }}>
<Box>
<Typography variant="h6">{t("profileMasterCv")}</Typography>
@@ -497,7 +497,7 @@ export default function CareerProfilePage() {
try {
const res = await api.post<QueuedCvRunResponse>("/profile-cv/upload", formData, { headers: { "Content-Type": "multipart/form-data" } });
await loadExtractionRuns();
toast(`Queued CV upload (run ${res.data.extractionRunId}).`, "info");
toast(t("profileCvQueuedUpload", { id: res.data.extractionRunId }), "info");
} catch (e: any) {
toast(String(e?.response?.data || e?.message || t("profileCvUploadFailed")), "error");
} finally {
@@ -516,7 +516,7 @@ export default function CareerProfilePage() {
try {
const res = await api.post<QueuedCvRunResponse>("/profile-cv/rebuild");
await loadExtractionRuns();
toast(`Queued CV rebuild (run ${res.data.extractionRunId}).`, "info");
toast(t("profileCvQueuedRebuild", { id: res.data.extractionRunId }), "info");
} catch (e: any) {
toast(String(e?.response?.data || e?.message || t("profileCvRebuildFailed")), "error");
} finally {
@@ -534,7 +534,7 @@ export default function CareerProfilePage() {
try {
const res = await api.post<QueuedCvRunResponse>("/profile-cv/improve");
await loadExtractionRuns();
toast(`Queued CV improve run (run ${res.data.extractionRunId}).`, "info");
toast(t("profileCvQueuedImprove", { id: res.data.extractionRunId }), "info");
} catch (e: any) {
toast(String(e?.response?.data || e?.message || t("profileCvImproveFailed")), "error");
} finally {
@@ -552,7 +552,7 @@ export default function CareerProfilePage() {
try {
const res = await api.post<QueuedCvRunResponse>("/profile-cv/reprocess");
await loadExtractionRuns();
toast(`Queued CV reprocess run (run ${res.data.extractionRunId}).`, "info");
toast(t("profileCvQueuedReprocess", { id: res.data.extractionRunId }), "info");
} catch (e: any) {
toast(String(e?.response?.data || e?.message || t("profileCvReprocessFailed")), "error");
} finally {
@@ -617,7 +617,7 @@ export default function CareerProfilePage() {
</Box>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{run.artifactFileName || t("profileCvNoStoredArtifact")}</Typography>
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 0.75 }}>
{run.parserVersion} · {new Date(run.startedAtUtc).toLocaleString()}
{run.parserVersion} · {new Date(run.startedAtUtc).toLocaleString(language === "nb" ? "nb-NO" : "en")}
</Typography>
{run.errorMessage ? (
<Typography variant="caption" sx={{ color: "error.main", display: "block", mt: 0.75 }}>
@@ -629,36 +629,36 @@ export default function CareerProfilePage() {
try {
await api.post(`/operations/${run.operation!.id}/cancel`);
await loadExtractionRuns();
toast("CV processing cancellation requested.", "info");
toast(t("profileCvCancelRequested"), "info");
} catch (error) {
toast(getApiErrorMessage(error, "Could not cancel CV processing."), "error");
toast(getApiErrorMessage(error, t("profileCvCancelFailed")), "error");
}
}}>Cancel processing</Button>
}}>{t("profileCvCancelProcessing")}</Button>
) : null}
{run.operation?.canRetry ? (
<Button size="small" color="inherit" sx={{ mt: 0.75 }} onClick={async () => {
try {
await api.post(`/operations/${run.operation!.id}/retry`);
await loadExtractionRuns();
toast("CV processing queued again.", "info");
toast(t("profileCvRetryQueued"), "info");
} catch (error) {
toast(getApiErrorMessage(error, "Could not retry CV processing."), "error");
toast(getApiErrorMessage(error, t("profileCvRetryFailed")), "error");
}
}}>Retry processing</Button>
}}>{t("profileCvRetryProcessing")}</Button>
) : null}
{run.status === "pending_review" ? (
<Box sx={{ mt: 1.25 }}>
{runDiffs[run.id] ? (
<>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{runDiffs[run.id].totalAdded} additions | {runDiffs[run.id].totalUpdated} updates
{runDiffs[run.id].totalLowConfidence ? ` | ${runDiffs[run.id].totalLowConfidence} need attention` : ""}
{t("profileCvDiffSummary", { added: runDiffs[run.id].totalAdded, updated: runDiffs[run.id].totalUpdated })}
{runDiffs[run.id].totalLowConfidence ? ` | ${t("profileCvNeedAttention", { count: runDiffs[run.id].totalLowConfidence })}` : ""}
</Typography>
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 0.5 }}>
{runDiffs[run.id].categories
.filter((category) => category.added.length || category.updated.length)
.map((category) => `${category.category}: +${category.added.length} / ~${category.updated.length}`)
.join(" | ") || "No profile changes found"}
.join(" | ") || t("profileCvNoChangesFound")}
</Typography>
</>
) : <LinearProgress sx={{ my: 1 }} />}
@@ -672,12 +672,12 @@ export default function CareerProfilePage() {
? [...(current[run.id] ?? []), change.id]
: (current[run.id] ?? []).filter((id) => id !== change.id),
}))} />}
label={`Include low-confidence ${change.category}: ${change.label}`}
label={t("profileCvIncludeLowConfidence", { category: change.category, label: change.label })}
/>
))}
{profileDirty ? (
<Alert severity="warning" sx={{ mt: 1 }}>
Save your current career-profile edits before applying imported changes.
{t("profileCvSaveBeforeImport")}
</Alert>
) : null}
<Box sx={{ display: "flex", gap: 1, mt: 1, flexWrap: "wrap" }}>
@@ -687,22 +687,22 @@ export default function CareerProfilePage() {
await api.post(`/profile-cv/runs/${run.id}/accept`, { acceptedLowConfidenceIds: acceptedLowConfidenceIds[run.id] ?? [] });
setRunDiffs((current) => { const next = { ...current }; delete next[run.id]; return next; });
await Promise.all([loadProfile(), loadExtractionRuns(), loadVersions()]);
toast("CV changes merged into your career profile.", "success");
toast(t("profileCvChangesApplied"), "success");
} catch (error) {
toast(getApiErrorMessage(error, "Could not apply CV changes."), "error");
toast(getApiErrorMessage(error, t("profileCvApplyChangesFailed")), "error");
} finally { setReviewingRunId(null); }
}}>Apply changes</Button>
}}>{t("profileCvApplyChanges")}</Button>
<Button size="small" color="inherit" disabled={reviewingRunId !== null} onClick={async () => {
setReviewingRunId(run.id);
try {
await api.post(`/profile-cv/runs/${run.id}/discard`);
setRunDiffs((current) => { const next = { ...current }; delete next[run.id]; return next; });
await loadExtractionRuns();
toast("CV extraction discarded.", "info");
toast(t("profileCvExtractionDiscarded"), "info");
} catch (error) {
toast(getApiErrorMessage(error, "Could not discard CV extraction."), "error");
toast(getApiErrorMessage(error, t("profileCvDiscardFailed")), "error");
} finally { setReviewingRunId(null); }
}}>Discard</Button>
}}>{t("profileCvDiscard")}</Button>
</Box>
</Box>
) : null}
@@ -715,7 +715,7 @@ export default function CareerProfilePage() {
</Box>
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end", mt: 1 }}>
<Button size="small" variant="text" color="inherit" onClick={() => setShowAdvancedCvTools((v) => !v)}>
{showAdvancedCvTools ? "Hide advanced CV tools" : "Advanced CV tools"}
{showAdvancedCvTools ? t("profileCvHideAdvancedTools") : t("profileCvAdvancedTools")}
</Button>
</Box>
<Box sx={{ mt: 2, p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper", display: showAdvancedCvTools ? "block" : "none" }}>
@@ -794,7 +794,7 @@ export default function CareerProfilePage() {
</Box>
<Box sx={{ mt: 1, display: workspaceSection === "profile" ? "flex" : "none", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{cvWordCount} words
{t("profileCvSectionWordCount", { count: cvWordCount })}
</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{t("profileCvPreferredUploads")}
@@ -803,7 +803,7 @@ export default function CareerProfilePage() {
</Box>
<Box sx={{ gridColumn: "1 / -1", display: workspaceSection === "profile" ? "flex" : "none", justifyContent: "flex-end", gap: 2, flexWrap: "wrap", alignItems: "center" }}>
{profileDirty ? <Chip size="small" color="warning" variant="outlined" label="Unsaved changes" /> : null}
{profileDirty ? <Chip size="small" color="warning" variant="outlined" label={t("assetsUnsavedChanges")} /> : null}
<Button
variant="contained"
disabled={!isLocal || loading}
+3 -3
View File
@@ -701,8 +701,8 @@ function DocumentAiTab({ settings, outline, update }: {
</Box>
{!canUseAi && <ProFeatureNotice featureKey="cv-writing-ai" title={t("cvDocumentAiProTitle")}>{t("cvAiProBody")}</ProFeatureNotice>}
<TextField select size="small" label={t("cvAiLanguage")} value={targetLanguage} onChange={(event) => setTargetLanguage(event.target.value)} sx={{ maxWidth: 220 }}>
<MenuItem value="en">English</MenuItem>
<MenuItem value="nb-NO">Norsk bokmål</MenuItem>
<MenuItem value="en">{t("languageEnglish")}</MenuItem>
<MenuItem value="nb-NO">{t("languageNorwegianBokmal")}</MenuItem>
</TextField>
<Stack spacing={1}>
{actions.map((action) => (
@@ -1190,7 +1190,7 @@ function CustomizeTab({ mode, settings, update, themes }: {
<FormControl size="small" fullWidth><InputLabel>{t("cvEditorPageSize")}</InputLabel><Select inputProps={{ "aria-label": t("cvEditorPageSize") }} label={t("cvEditorPageSize")} value={settings.pageSize ?? "a4"} onChange={(e) => update({ pageSize: e.target.value })}><MenuItem value="a4">A4</MenuItem><MenuItem value="letter">US Letter</MenuItem></Select></FormControl>
<FormControl size="small" fullWidth><InputLabel>{t("cvEditorDensity")}</InputLabel><Select inputProps={{ "aria-label": t("cvEditorDensity") }} label={t("cvEditorDensity")} value={settings.density ?? "balanced"} onChange={(e) => update({ density: e.target.value })}><MenuItem value="compact">{t("cvEditorCompact")}</MenuItem><MenuItem value="balanced">{t("cvEditorBalanced")}</MenuItem><MenuItem value="roomy">{t("cvEditorRoomy")}</MenuItem></Select></FormControl>
</>}
<FormControl size="small" fullWidth><InputLabel>{t("cvEditorDocumentLanguage")}</InputLabel><Select inputProps={{ "aria-label": t("cvEditorDocumentLanguage") }} label={t("cvEditorDocumentLanguage")} value={settings.language === "no" ? "nb-NO" : settings.language ?? "en"} onChange={(e) => update({ language: e.target.value })}><MenuItem value="en">English</MenuItem><MenuItem value="nb-NO">Norsk bokmål</MenuItem></Select></FormControl>
<FormControl size="small" fullWidth><InputLabel>{t("cvEditorDocumentLanguage")}</InputLabel><Select inputProps={{ "aria-label": t("cvEditorDocumentLanguage") }} label={t("cvEditorDocumentLanguage")} value={settings.language === "no" ? "nb-NO" : settings.language ?? "en"} onChange={(e) => update({ language: e.target.value })}><MenuItem value="en">{t("languageEnglish")}</MenuItem><MenuItem value="nb-NO">{t("languageNorwegianBokmal")}</MenuItem></Select></FormControl>
<FormControl size="small" fullWidth><InputLabel>{t("cvEditorDateFormat")}</InputLabel><Select inputProps={{ "aria-label": t("cvEditorDateFormat") }} label={t("cvEditorDateFormat")} value={settings.dateFormat ?? "short"} onChange={(e) => update({ dateFormat: e.target.value })}><MenuItem value="long">January 2020</MenuItem><MenuItem value="short">Jan 2020</MenuItem><MenuItem value="numeric">01/2020</MenuItem><MenuItem value="year">2020</MenuItem></Select></FormControl>
</Box>
{supports("layout") && <FormControl size="small" fullWidth><InputLabel>{t("cvEditorColumns")}</InputLabel><Select inputProps={{ "aria-label": t("cvEditorColumns") }} label={t("cvEditorColumns")} value={settings.layout ?? ""} onChange={(e) => update({ layout: (e.target.value || null) as CvVariantSettings["layout"] })}><MenuItem value="">{t("cvEditorTemplateDefault")}</MenuItem><MenuItem value="single">{t("cvEditorOneColumn")}</MenuItem><MenuItem value="header-band">{t("cvEditorHeaderBandColumn")}</MenuItem><MenuItem value="sidebar-left">{t("cvEditorLeftSidebar")}</MenuItem><MenuItem value="sidebar-right">{t("cvEditorRightSidebar")}</MenuItem></Select></FormControl>}
+21 -20
View File
@@ -12,6 +12,7 @@ import {
} from "@mui/material";
import { api, getApiErrorMessage } from "../api";
import { useI18n } from "../i18n/I18nProvider";
import { notificationDateLabel, UserNotification } from "../notifications";
type Operation = {
@@ -30,9 +31,9 @@ type Operation = {
};
const statusLabel = (value: string) => value.replaceAll("_", " ");
const dateLabel = notificationDateLabel;
export default function OperationsPage() {
const { language, t } = useI18n();
const [operations, setOperations] = useState<Operation[]>([]);
const [notifications, setNotifications] = useState<UserNotification[]>([]);
const [loading, setLoading] = useState(true);
@@ -50,11 +51,11 @@ export default function OperationsPage() {
setNotifications(notificationResponse.data ?? []);
setError(null);
} catch (requestError) {
setError(getApiErrorMessage(requestError, "Operations could not be loaded."));
setError(getApiErrorMessage(requestError, t("operationsLoadFailed")));
} finally {
setLoading(false);
}
}, []);
}, [language]);
useEffect(() => {
void load();
@@ -70,7 +71,7 @@ export default function OperationsPage() {
await load();
if (notificationsChanged) window.dispatchEvent(new Event("notifications-changed"));
} catch (requestError) {
setError(getApiErrorMessage(requestError, "The action could not be completed."));
setError(getApiErrorMessage(requestError, t("operationsActionFailed")));
} finally {
setBusyKey(null);
}
@@ -79,30 +80,30 @@ export default function OperationsPage() {
return (
<Stack spacing={2}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
<Typography color="text.secondary">Background work survives navigation and refresh.</Typography>
<Button variant="outlined" onClick={() => void load(true)} disabled={loading}>Refresh</Button>
<Typography color="text.secondary">{t("operationsSubtitle")}</Typography>
<Button variant="outlined" onClick={() => void load(true)} disabled={loading}>{t("refresh")}</Button>
</Box>
{error ? <Alert severity="error" aria-live="polite">{error}</Alert> : null}
{loading ? <LinearProgress aria-label="Loading operations" /> : null}
{loading ? <LinearProgress aria-label={t("operationsLoading")} /> : null}
<Paper component="section" aria-labelledby="notifications-heading" sx={{ p: { xs: 2, sm: 3 } }}>
<Typography id="notifications-heading" variant="h6" sx={{ mb: 2 }}>Notifications</Typography>
{notifications.length === 0 && !loading ? <Typography color="text.secondary">No notifications.</Typography> : null}
<Typography id="notifications-heading" variant="h6" sx={{ mb: 2 }}>{t("notifications")}</Typography>
{notifications.length === 0 && !loading ? <Typography color="text.secondary">{t("operationsNoNotifications")}</Typography> : null}
<Stack spacing={1.5}>
{notifications.map((notification) => (
<Box key={notification.id} sx={{ p: 2, border: "1px solid", borderColor: "divider", borderRadius: 2, opacity: notification.readAtUtc ? 0.75 : 1 }}>
<Typography sx={{ fontWeight: notification.readAtUtc ? 600 : 800 }}>{notification.title}</Typography>
<Typography color="text.secondary">{notification.message}</Typography>
<Typography variant="caption" color="text.secondary">{dateLabel(notification.createdAtUtc)}</Typography>
<Typography variant="caption" color="text.secondary">{notificationDateLabel(notification.createdAtUtc, language === "nb" ? "nb-NO" : "en")}</Typography>
<Stack direction="row" spacing={1} sx={{ mt: 1, flexWrap: "wrap" }}>
{!notification.readAtUtc ? (
<Button size="small" disabled={busyKey !== null} onClick={() => void runAction(`read-${notification.id}`, () => api.post(`/notifications/${notification.id}/read`), true)}>
Mark read
{t("notificationsMarkRead")}
</Button>
) : null}
<Button size="small" color="inherit" disabled={busyKey !== null} onClick={() => void runAction(`dismiss-${notification.id}`, () => api.delete(`/notifications/${notification.id}`), true)}>
Dismiss
{t("notificationsDismiss")}
</Button>
</Stack>
</Box>
@@ -111,8 +112,8 @@ export default function OperationsPage() {
</Paper>
<Paper component="section" aria-labelledby="operations-heading" sx={{ p: { xs: 2, sm: 3 } }}>
<Typography id="operations-heading" variant="h6" sx={{ mb: 2 }}>Operations</Typography>
{operations.length === 0 && !loading ? <Typography color="text.secondary">No background operations yet.</Typography> : null}
<Typography id="operations-heading" variant="h6" sx={{ mb: 2 }}>{t("operations")}</Typography>
{operations.length === 0 && !loading ? <Typography color="text.secondary">{t("operationsEmpty")}</Typography> : null}
<Stack spacing={1.5}>
{operations.map((operation) => (
<Box key={operation.id} sx={{ p: 2, border: "1px solid", borderColor: "divider", borderRadius: 2 }}>
@@ -120,20 +121,20 @@ export default function OperationsPage() {
<Typography sx={{ fontWeight: 800, overflowWrap: "anywhere" }}>{operation.taskType}</Typography>
<Chip size="small" label={statusLabel(operation.status)} />
</Box>
<Typography variant="caption" color="text.secondary">Started {dateLabel(operation.createdAtUtc)}</Typography>
<Typography variant="caption" color="text.secondary">{t("operationsStarted", { date: notificationDateLabel(operation.createdAtUtc, language === "nb" ? "nb-NO" : "en") })}</Typography>
{operation.progressStage ? <Typography sx={{ mt: 1 }}>{operation.progressStage}</Typography> : null}
{operation.progressPercent != null ? <LinearProgress variant="determinate" value={operation.progressPercent} aria-label={`${operation.taskType} progress`} sx={{ mt: 1 }} /> : null}
{operation.cancellationRequestedAtUtc ? <Typography color="text.secondary" sx={{ mt: 1 }}>Cancellation requested.</Typography> : null}
{operation.failureCategory ? <Alert severity="error" sx={{ mt: 1 }}>Failed: {statusLabel(operation.failureCategory)}</Alert> : null}
{operation.progressPercent != null ? <LinearProgress variant="determinate" value={operation.progressPercent} aria-label={t("operationsProgress", { task: operation.taskType })} sx={{ mt: 1 }} /> : null}
{operation.cancellationRequestedAtUtc ? <Typography color="text.secondary" sx={{ mt: 1 }}>{t("operationsCancellationRequested")}</Typography> : null}
{operation.failureCategory ? <Alert severity="error" sx={{ mt: 1 }}>{t("operationsFailed", { category: statusLabel(operation.failureCategory) })}</Alert> : null}
<Stack direction="row" spacing={1} sx={{ mt: 1, flexWrap: "wrap" }}>
{operation.canCancel ? (
<Button size="small" color="error" disabled={busyKey !== null} onClick={() => void runAction(`cancel-${operation.id}`, () => api.post(`/operations/${operation.id}/cancel`))}>
Cancel
{t("cancel")}
</Button>
) : null}
{operation.canRetry ? (
<Button size="small" variant="outlined" disabled={busyKey !== null} onClick={() => void runAction(`retry-${operation.id}`, () => api.post(`/operations/${operation.id}/retry`), true)}>
Retry
{t("retry")}
</Button>
) : null}
</Stack>
@@ -30,18 +30,22 @@ type MetadataLookup = (path: string) => StructuredCvFieldMetadata | undefined;
// Field-review chip + tone, moved verbatim from CareerProfilePage so the sections and the parent
// share one definition. Behaviour (thresholds, labels, source snippet) is unchanged.
function confidenceTone(confidence?: number) {
if (typeof confidence !== "number") return { label: "Review", color: "default" as const };
if (confidence >= 0.8) return { label: `High ${Math.round(confidence * 100)}%`, color: "success" as const };
if (confidence >= 0.65) return { label: `Medium ${Math.round(confidence * 100)}%`, color: "warning" as const };
return { label: `Low ${Math.round(confidence * 100)}%`, color: "error" as const };
if (typeof confidence !== "number") return { key: "profileCvConfidenceReview" as const, color: "default" as const };
if (confidence >= 0.8) return { key: "profileCvConfidenceHigh" as const, color: "success" as const };
if (confidence >= 0.65) return { key: "profileCvConfidenceMedium" as const, color: "warning" as const };
return { key: "profileCvConfidenceLow" as const, color: "error" as const };
}
export function FieldReviewNote({ metadata }: { metadata?: StructuredCvFieldMetadata }) {
const { t } = useI18n();
if (!metadata) return null;
const tone = confidenceTone(metadata.confidence);
const label = typeof metadata.confidence === "number"
? t(tone.key, { percent: Math.round(metadata.confidence * 100) })
: t(tone.key);
return (
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.75, alignItems: "center" }}>
<Chip size="small" color={tone.color} variant={tone.color === "default" ? "outlined" : "filled"} label={tone.label} />
<Chip size="small" color={tone.color} variant={tone.color === "default" ? "outlined" : "filled"} label={label} />
{metadata.method ? <Chip size="small" variant="outlined" label={metadata.method} /> : null}
{metadata.sourceBlockId ? <Chip size="small" variant="outlined" label={metadata.sourceBlockId} /> : null}
{metadata.reviewState ? <Chip size="small" variant="outlined" label={metadata.reviewState} /> : null}
@@ -91,8 +95,8 @@ export function PersonalInformationSection({
<TextField label={t("profileCvContactLinkedIn")} value={value.linkedIn ?? ""} onChange={(e) => set({ linkedIn: e.target.value || undefined })} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
<TextField label="GitHub" value={value.gitHub ?? ""} onChange={(e) => set({ gitHub: e.target.value || undefined })} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
<Box sx={{ gridColumn: "1 / -1" }}>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1 }}><Typography variant="subtitle2">Other links</Typography><Button size="small" startIcon={<AddIcon />} onClick={() => set({ links: [...(value.links ?? []), { label: "", url: "" }] })}>Add link</Button></Stack>
<Stack spacing={1}>{(value.links ?? []).map((link, index) => <Stack key={index} direction={{ xs: "column", sm: "row" }} spacing={1} alignItems={{ sm: "center" }}><TextField size="small" label="Label" value={link.label ?? ""} onChange={(event) => set({ links: (value.links ?? []).map((item, itemIndex) => itemIndex === index ? { ...item, label: event.target.value || undefined } : item) })} sx={{ flex: "0 1 180px" }} /><TextField size="small" label="URL" value={link.url ?? ""} onChange={(event) => set({ links: (value.links ?? []).map((item, itemIndex) => itemIndex === index ? { ...item, url: event.target.value || undefined } : item) })} fullWidth /><IconButton aria-label={`Delete custom link ${index + 1}`} onClick={() => set({ links: (value.links ?? []).filter((_, itemIndex) => itemIndex !== index) })}><DeleteOutlineIcon fontSize="small" /></IconButton></Stack>)}</Stack>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1 }}><Typography variant="subtitle2">{t("profileCvOtherLinks")}</Typography><Button size="small" startIcon={<AddIcon />} onClick={() => set({ links: [...(value.links ?? []), { label: "", url: "" }] })}>{t("profileCvAddLink")}</Button></Stack>
<Stack spacing={1}>{(value.links ?? []).map((link, index) => <Stack key={index} direction={{ xs: "column", sm: "row" }} spacing={1} alignItems={{ sm: "center" }}><TextField size="small" label={t("profileCvLinkLabel")} value={link.label ?? ""} onChange={(event) => set({ links: (value.links ?? []).map((item, itemIndex) => itemIndex === index ? { ...item, label: event.target.value || undefined } : item) })} sx={{ flex: "0 1 180px" }} /><TextField size="small" label={t("profileCvLinkUrl")} value={link.url ?? ""} onChange={(event) => set({ links: (value.links ?? []).map((item, itemIndex) => itemIndex === index ? { ...item, url: event.target.value || undefined } : item) })} fullWidth /><IconButton aria-label={t("profileCvDeleteCustomLink", { index: index + 1 })} onClick={() => set({ links: (value.links ?? []).filter((_, itemIndex) => itemIndex !== index) })}><DeleteOutlineIcon fontSize="small" /></IconButton></Stack>)}</Stack>
</Box>
</Box>
);
@@ -9,6 +9,7 @@ import WorkOutlineIcon from "@mui/icons-material/WorkOutline";
import { CvVariantSummary, cvBuilderApi } from "../../cvBuilder";
import type { UserOperation } from "../../types";
import { useI18n } from "../../i18n/I18nProvider";
import { localizeCompletenessLabel } from "./careerCompletenessLabels";
export type CareerWorkspaceCompleteness = {
percent: number;
@@ -123,7 +124,7 @@ export default function CareerWorkspaceOverview({ completeness, runs, loading, l
</Box>
<LinearProgress aria-label={t("careerOverviewCompletenessLabel")} variant={loading ? "indeterminate" : "determinate"} value={completeness?.percent ?? 0} sx={{ mt: 1, height: 8, borderRadius: 999 }} />
{!loading && completeness?.missing.length ? (
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>{t("careerOverviewAddNext", { items: completeness.missing.slice(0, 3).join(", ") })}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>{t("careerOverviewAddNext", { items: completeness.missing.slice(0, 3).map((label) => localizeCompletenessLabel(label, t)).join(", ") })}</Typography>
) : null}
<Button href="/career?section=profile" onClick={onNavigate ? (event) => { event.preventDefault(); onNavigate("profile"); } : undefined} size="small" sx={{ mt: 1, px: 0.5 }}>{t("careerOverviewImproveProfile")}</Button>
</Box>
@@ -1,6 +1,8 @@
import React from "react";
import { Accordion, AccordionDetails, AccordionSummary, Box, Button, Chip, LinearProgress, Typography } from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { useI18n } from "../../i18n/I18nProvider";
import { localizeCompletenessLabel } from "./careerCompletenessLabels";
// Display-only slice of the Career Profile page: the completeness meter, the "missing" chips, and the
// version-history accordion. Extracted from CareerProfilePage as the first step of the Phase 1
@@ -31,14 +33,17 @@ export default function ProfileCompleteness({
onRestore: (version: number) => void;
showSummary?: boolean;
}) {
const { language, t } = useI18n();
if ((!showSummary || !completeness) && versions.length <= 1) return null;
const locale = language === "nb" ? "nb-NO" : "en";
return (
<Box sx={{ mb: 2.5, p: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
{showSummary && completeness ? (
<>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 2, flexWrap: "wrap", mb: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 900 }}>Profile completeness</Typography>
<Typography variant="subtitle1" sx={{ fontWeight: 900 }}>{t("profileCvCompletenessTitle")}</Typography>
<Typography variant="h6" sx={{ fontWeight: 900 }}>{completeness.percent}%</Typography>
</Box>
<LinearProgress
@@ -49,30 +54,30 @@ export default function ProfileCompleteness({
/>
{completeness.missing.length > 0 ? (
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", alignItems: "center", mt: 1.25 }}>
<Typography variant="body2" sx={{ color: "text.secondary" }}>Missing:</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvCompletenessMissing")}</Typography>
{completeness.missing.map((label) => (
<Chip key={label} size="small" label={label} sx={{ height: 22, fontWeight: 700 }} />
<Chip key={label} size="small" label={localizeCompletenessLabel(label, t)} sx={{ height: 22, fontWeight: 700 }} />
))}
</Box>
) : (
<Typography variant="body2" sx={{ color: "success.main", mt: 1.25, fontWeight: 700 }}>Your career profile is complete.</Typography>
<Typography variant="body2" sx={{ color: "success.main", mt: 1.25, fontWeight: 700 }}>{t("profileCvCompletenessComplete")}</Typography>
)}
</>
) : null}
{versions.length > 1 ? (
<Accordion disableGutters elevation={0} sx={{ mt: showSummary ? 1.5 : 0, "&:before": { display: "none" }, backgroundColor: "transparent" }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ px: 0, minHeight: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>Version history ({versions.length})</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{t("profileCvVersionHistory", { count: versions.length })}</Typography>
</AccordionSummary>
<AccordionDetails sx={{ px: 0, pt: 0 }}>
<Box sx={{ display: "grid", gap: 0.75 }}>
{versions.slice(0, 12).map((v) => (
<Box key={v.version} sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
v{v.version} · {v.source} · {new Date(v.createdAtUtc).toLocaleString()}{v.isCurrent ? " · current" : ""}
v{v.version} · {v.source} · {new Date(v.createdAtUtc).toLocaleString(locale)}{v.isCurrent ? ` · ${t("profileCvVersionCurrent")}` : ""}
</Typography>
{!v.isCurrent ? (
<Button size="small" variant="text" disabled={loading} onClick={() => onRestore(v.version)}>Restore</Button>
<Button size="small" variant="text" disabled={loading} onClick={() => onRestore(v.version)}>{t("profileCvVersionRestore")}</Button>
) : null}
</Box>
))}
@@ -0,0 +1,16 @@
import type { TranslationKey } from "../../i18n/translations";
const completenessLabelKeys: Record<string, TranslationKey> = {
"Personal details": "profileCvCompletenessPersonal",
"Professional summary": "profileCvCompletenessSummary",
Experience: "profileCvCompletenessExperience",
Education: "profileCvCompletenessEducation",
Skills: "profileCvCompletenessSkills",
Projects: "profileCvCompletenessProjects",
Languages: "profileCvCompletenessLanguages",
};
export function localizeCompletenessLabel(label: string, t: (key: TranslationKey) => string) {
const key = completenessLabelKeys[label];
return key ? t(key) : label;
}