feat(career): migrate overview and factual evidence

This commit is contained in:
cesnimda
2026-10-12 07:50:32 +02:00
parent 01281f52d9
commit 8b4d5c2112
16 changed files with 9006 additions and 116 deletions
+22 -4
View File
@@ -150,7 +150,7 @@ export default function CareerProfilePage() {
const cvInputRef = useRef<HTMLInputElement | null>(null);
const avatarInputRef = useRef<HTMLInputElement | null>(null);
const [me, setMe] = useState<MeResponse | null>(null);
const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [uploadingCv, setUploadingCv] = useState(false);
const [improvingCv, setImprovingCv] = useState(false);
@@ -170,9 +170,11 @@ export default function CareerProfilePage() {
// The raw import/section parser remains available as an advanced recovery tool.
const [showAdvancedCvTools, setShowAdvancedCvTools] = useState(false);
const [workspaceSection, setWorkspaceSection] = useState<CareerWorkspaceSection>(initialWorkspaceSection);
const [evidenceVisited, setEvidenceVisited] = useState(() => initialWorkspaceSection() === "evidence");
const navigateWorkspace = useCallback((next: CareerWorkspaceSection) => {
setWorkspaceSection(next);
if (next === "evidence") setEvidenceVisited(true);
if (typeof window === "undefined") return;
const url = new URL(window.location.href);
if (next === "overview") url.searchParams.delete("section");
@@ -205,6 +207,9 @@ export default function CareerProfilePage() {
}
}, [loadVersions, t, toast]);
const [extractionRuns, setExtractionRuns] = useState<ExtractionRun[]>([]);
const [runsLoading, setRunsLoading] = useState(true);
const [runsLoadError, setRunsLoadError] = useState<string | null>(null);
const runsReadId = useRef(0);
const [runDiffs, setRunDiffs] = useState<Record<number, CvImportDiff>>({});
const [reviewingRunId, setReviewingRunId] = useState<number | null>(null);
const [acceptedLowConfidenceIds, setAcceptedLowConfidenceIds] = useState<Record<number, string[]>>({});
@@ -216,11 +221,19 @@ export default function CareerProfilePage() {
}, []);
const loadExtractionRuns = useCallback(async () => {
const readId = ++runsReadId.current;
setRunsLoading(true);
try {
const response = await api.get<ExtractionRun[]>("/profile-cv/runs");
setExtractionRuns(response.data ?? []);
} catch {
if (readId === runsReadId.current) {
setExtractionRuns(response.data ?? []);
setRunsLoadError(null);
}
} catch (error) {
// Polling failure must not clear existing run state or touch unsaved profile edits.
if (readId === runsReadId.current) setRunsLoadError(String(error));
} finally {
if (readId === runsReadId.current) setRunsLoading(false);
}
}, []);
@@ -367,11 +380,16 @@ export default function CareerProfilePage() {
runs={extractionRuns}
loading={loading}
loadError={loadError}
runsLoading={runsLoading}
runsLoadError={runsLoadError}
onRetryRuns={() => void loadExtractionRuns()}
onRetryProfile={() => { if (!profileDirty) void loadProfile(); }}
profileDirty={profileDirty}
onNavigate={navigateWorkspace}
/>
) : null}
{workspaceSection === "evidence" ? <CareerEvidenceBank /> : null}
{evidenceVisited ? <Box hidden={workspaceSection !== "evidence"}><CareerEvidenceBank /></Box> : null}
{workspaceSection === "profile" || workspaceSection === "import" ? <Paper id={workspaceSection === "profile" ? "career-profile-editor" : "career-cv-import"} sx={{ mt: 0, p: { xs: 1.5, sm: 2.5 }, borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", scrollMarginTop: 96 }}>
<input
@@ -1,38 +1,104 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { Alert, Box, Button, Chip, MenuItem, Paper, Stack, TextField, Typography } from "@mui/material";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import { api, getApiErrorMessage } from "../../api";
import { useConfirm } from "../../confirm";
import { useI18n } from "../../i18n/I18nProvider";
import { PageSection } from "../../layout/PageLayout";
type Evidence = { id: number; category: string; title: string; statement: string; tags: string[]; sourceReference: string | null };
export default function CareerEvidenceBank() {
const { t } = useI18n(); const { confirm } = useConfirm();
const [items, setItems] = useState<Evidence[]>([]); const [title, setTitle] = useState(""); const [statement, setStatement] = useState("");
const [category, setCategory] = useState("achievement"); const [tags, setTags] = useState(""); const [source, setSource] = useState("");
const [error, setError] = useState<string | null>(null); const [busy, setBusy] = useState(false);
const load = () => api.get<Evidence[]>("/career/evidence").then(r => { setItems(r.data); setError(null); }).catch(e => setError(getApiErrorMessage(e, t("careerEvidenceLoadFailed"))));
useEffect(() => { void load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
const add = async () => { setBusy(true); try { const r = await api.post<Evidence>("/career/evidence", { category, title, statement, tags: tags.split(","), sourceType: "manual", sourceReference: source, isVerified: true }); setItems(v => [r.data, ...v]); setTitle(""); setStatement(""); setTags(""); setSource(""); setError(null); } catch (e) { setError(getApiErrorMessage(e, t("careerEvidenceSaveFailed"))); } finally { setBusy(false); } };
const remove = async (item: Evidence) => { if (!(await confirm({ title: t("careerEvidenceDeleteTitle"), message: t("careerEvidenceDeleteMessage", { title: item.title }), confirmLabel: t("deleteAction"), destructive: true }))) return; try { await api.delete(`/career/evidence/${item.id}`); setItems(v => v.filter(x => x.id !== item.id)); } catch (e) { setError(getApiErrorMessage(e, t("careerEvidenceDeleteFailed"))); } };
const { t } = useI18n();
const { confirm } = useConfirm();
const [items, setItems] = useState<Evidence[]>([]);
const [title, setTitle] = useState("");
const [statement, setStatement] = useState("");
const [category, setCategory] = useState("achievement");
const [tags, setTags] = useState("");
const [source, setSource] = useState("");
const [loading, setLoading] = useState(true);
const [readAttempt, setReadAttempt] = useState(0);
const [loadError, setLoadError] = useState<{ cause: unknown } | null>(null);
const [saveError, setSaveError] = useState<{ cause: unknown } | null>(null);
const [deleteErrors, setDeleteErrors] = useState<Record<number, { cause: unknown }>>({});
const [busy, setBusy] = useState(false);
const [deletingId, setDeletingId] = useState<number | null>(null);
const writePending = useRef(false);
const titleInputRef = useRef<HTMLInputElement>(null);
const focusAfterDelete = useRef(false);
useEffect(() => {
let cancelled = false;
setLoading(true);
api.get<Evidence[]>("/career/evidence")
.then((response) => { if (!cancelled) { setItems(response.data); setLoadError(null); } })
.catch((cause) => { if (!cancelled) setLoadError({ cause }); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [readAttempt]);
useEffect(() => {
if (focusAfterDelete.current) {
focusAfterDelete.current = false;
titleInputRef.current?.focus();
}
}, [items]);
const add = async () => {
if (writePending.current || loading || loadError || !title.trim() || !statement.trim()) return;
writePending.current = true;
setBusy(true);
try {
const response = await api.post<Evidence>("/career/evidence", { category, title, statement, tags: tags.split(","), sourceType: "manual", sourceReference: source, isVerified: true });
setItems((previous) => [response.data, ...previous]);
setTitle(""); setStatement(""); setTags(""); setSource("");
setSaveError(null);
} catch (cause) {
setSaveError({ cause });
} finally {
writePending.current = false;
setBusy(false);
}
};
const remove = async (item: Evidence) => {
if (writePending.current || loading || loadError) return;
writePending.current = true;
try {
if (!(await confirm({ title: t("careerEvidenceDeleteTitle"), message: t("careerEvidenceDeleteMessage", { title: item.title }), confirmLabel: t("deleteAction"), destructive: true }))) return;
setDeletingId(item.id);
await api.delete(`/career/evidence/${item.id}`);
focusAfterDelete.current = true;
setItems((previous) => previous.filter((entry) => entry.id !== item.id));
setDeleteErrors((previous) => { const next = { ...previous }; delete next[item.id]; return next; });
} catch (cause) {
setDeleteErrors((previous) => ({ ...previous, [item.id]: { cause } }));
} finally {
writePending.current = false;
setDeletingId(null);
}
};
const categories = ["achievement", "metric", "project", "technology", "responsibility", "example"];
const categoryLabel = (value: string) => t(`careerEvidenceCategory_${value}` as "careerEvidenceCategory_achievement");
return <Stack spacing={2}>
<Box><Typography variant="h5" fontWeight={800}>{t("careerEvidenceTitle")}</Typography><Typography color="text.secondary">{t("careerEvidenceSubtitle")}</Typography></Box>
{error && <Alert severity="error" action={<Button color="inherit" onClick={() => void load()}>{t("retry")}</Button>}>{error}</Alert>}
<Paper variant="outlined" sx={{ p: { xs: 2, sm: 2.5 }, borderRadius: 3 }}><Stack spacing={1.5}>
<Typography variant="subtitle1" fontWeight={700}>{t("careerEvidenceAdd")}</Typography>
<TextField select size="small" label={t("careerEvidenceCategory")} value={category} onChange={e => setCategory(e.target.value)}>{categories.map(v => <MenuItem key={v} value={v}>{categoryLabel(v)}</MenuItem>)}</TextField>
<TextField label={t("careerEvidenceItemTitle")} value={title} onChange={e => setTitle(e.target.value)} />
<TextField label={t("careerEvidenceStatement")} value={statement} onChange={e => setStatement(e.target.value)} multiline minRows={3} helperText={t("careerEvidenceTruthHelp")} />
<TextField label={t("careerEvidenceTags")} value={tags} onChange={e => setTags(e.target.value)} helperText={t("careerEvidenceTagsHelp")} />
<TextField label={t("careerEvidenceSource")} value={source} onChange={e => setSource(e.target.value)} helperText={t("careerEvidenceSourceHelp")} />
<Button variant="contained" disabled={busy || !title.trim() || !statement.trim()} onClick={() => void add()} sx={{ alignSelf: "flex-start" }}>{t("careerEvidenceSave")}</Button>
</Stack></Paper>
{items.length === 0 ? <Alert severity="info">{t("careerEvidenceEmpty")}</Alert> : items.map(item => <Paper key={item.id} variant="outlined" sx={{ p: 2, borderRadius: 3 }}><Stack spacing={1}>
<Stack direction="row" justifyContent="space-between" gap={1}><Box><Typography fontWeight={700}>{item.title}</Typography><Typography variant="caption" color="text.secondary">{categoryLabel(item.category)} · {item.sourceReference || t("careerEvidenceManualSource")}</Typography></Box><Button color="error" size="small" startIcon={<DeleteOutlineIcon />} onClick={() => void remove(item)}>{t("deleteAction")}</Button></Stack>
const categoryLabel = (value: string) => categories.includes(value) ? t(`careerEvidenceCategory_${value}` as "careerEvidenceCategory_achievement") : value;
return <PageSection title={t("careerEvidenceTitle")} description={t("careerEvidenceSubtitle")}>
<Stack spacing={2}>
{loading ? <Typography role="status" color="text.secondary">{t("loading")}</Typography> : loadError ? <Alert severity="error" action={<Button color="inherit" disabled={busy || deletingId !== null} onClick={() => { titleInputRef.current?.focus(); setReadAttempt((attempt) => attempt + 1); }}>{t("retry")}</Button>}>{getApiErrorMessage(loadError.cause, t("careerEvidenceLoadFailed"))}</Alert> : null}
<PageSection surface title={t("careerEvidenceAdd")} level="h3"><Stack spacing={1.5}>
{saveError ? <Alert severity="error">{getApiErrorMessage(saveError.cause, t("careerEvidenceSaveFailed"))}</Alert> : null}
<TextField select size="small" disabled={busy} label={t("careerEvidenceCategory")} value={category} onChange={e => setCategory(e.target.value)}>{categories.map(v => <MenuItem key={v} value={v}>{categoryLabel(v)}</MenuItem>)}</TextField>
<TextField required disabled={busy} inputRef={titleInputRef} label={t("careerEvidenceItemTitle")} value={title} onChange={e => setTitle(e.target.value)} />
<TextField required disabled={busy} label={t("careerEvidenceStatement")} value={statement} onChange={e => setStatement(e.target.value)} multiline minRows={3} helperText={t("careerEvidenceTruthHelp")} />
<TextField disabled={busy} label={t("careerEvidenceTags")} value={tags} onChange={e => setTags(e.target.value)} helperText={t("careerEvidenceTagsHelp")} />
<TextField disabled={busy} label={t("careerEvidenceSource")} value={source} onChange={e => setSource(e.target.value)} helperText={t("careerEvidenceSourceHelp")} />
<Button variant="contained" disabled={busy || deletingId !== null || loading || Boolean(loadError) || !title.trim() || !statement.trim()} onClick={() => void add()} sx={{ alignSelf: "flex-start" }}>{t("careerEvidenceSave")}</Button>
</Stack></PageSection>
{!loading && !loadError && items.length === 0 ? <Alert severity="info">{t("careerEvidenceEmpty")}</Alert> : null}
{items.map(item => <Paper key={item.id} component="article" aria-labelledby={`career-evidence-${item.id}-title`} variant="outlined" sx={{ p: "var(--app-card-padding)", borderRadius: 1, minWidth: 0, overflowWrap: "anywhere" }}><Stack spacing={1}>
<Stack direction="row" justifyContent="space-between" flexWrap="wrap" gap={1}><Box sx={{ minWidth: 0 }}><Typography id={`career-evidence-${item.id}-title`} component="h3" variant="sectionTitle">{item.title}</Typography><Typography variant="caption" color="text.secondary">{categoryLabel(item.category)} · {item.sourceReference || t("careerEvidenceManualSource")}</Typography></Box><Button color="error" size="small" disabled={busy || deletingId !== null || loading || Boolean(loadError)} aria-label={t("careerEvidenceDeleteMessage", { title: item.title })} startIcon={<DeleteOutlineIcon />} onClick={() => void remove(item)}>{t("deleteAction")}</Button></Stack>
{deleteErrors[item.id] ? <Alert severity="error" action={<Button color="inherit" disabled={busy || deletingId !== null || loading || Boolean(loadError)} onClick={() => void remove(item)}>{t("retry")}</Button>}>{getApiErrorMessage(deleteErrors[item.id].cause, t("careerEvidenceDeleteFailed"))}</Alert> : null}
<Typography variant="body2">{item.statement}</Typography><Stack direction="row" flexWrap="wrap" gap={0.5}>{item.tags.map(tag => <Chip key={tag} size="small" label={tag} />)}</Stack>
</Stack></Paper>)}
</Stack>;
</Stack>
</PageSection>;
}
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Alert, Box, Button, Chip, LinearProgress, Paper, Skeleton, Stack, Typography } from "@mui/material";
import DescriptionOutlinedIcon from "@mui/icons-material/DescriptionOutlined";
@@ -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 { PageSection } from "../../layout/PageLayout";
import { localizeCompletenessLabel } from "./careerCompletenessLabels";
export type CareerWorkspaceCompleteness = {
@@ -40,10 +41,10 @@ function ActionCard({ title, body, href, label, icon, onNavigate }: {
onNavigate?: () => void;
}) {
return (
<Paper variant="outlined" component="article" sx={{ p: 2, borderRadius: 3, display: "flex", flexDirection: "column", gap: 1.25 }}>
<Paper variant="outlined" component="article" sx={{ p: "var(--app-card-padding)", borderRadius: 1, minWidth: 0, display: "flex", flexDirection: "column", gap: 1.25 }}>
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
<Box sx={{ color: "primary.main", display: "flex" }}>{icon}</Box>
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>{title}</Typography>
<Typography component="h3" variant="sectionTitle" sx={{ overflowWrap: "anywhere" }}>{title}</Typography>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ flex: 1 }}>{body}</Typography>
<Button
@@ -58,20 +59,30 @@ function ActionCard({ title, body, href, label, icon, onNavigate }: {
);
}
export default function CareerWorkspaceOverview({ completeness, runs, loading, loadError, onNavigate }: {
export default function CareerWorkspaceOverview({ completeness, runs, loading, loadError, onNavigate, runsLoading = false, runsLoadError = null, onRetryProfile, onRetryRuns, profileDirty = false }: {
completeness: CareerWorkspaceCompleteness | null;
runs: CareerWorkspaceImportRun[];
loading: boolean;
loadError: string | null;
onNavigate?: (section: "profile" | "import") => void;
runsLoading?: boolean;
runsLoadError?: string | null;
onRetryProfile?: () => void;
onRetryRuns?: () => void;
profileDirty?: boolean;
}) {
const { language, t } = useI18n();
const [recentCvs, setRecentCvs] = useState<CvVariantSummary[]>([]);
const [recentLoading, setRecentLoading] = useState(true);
const [recentError, setRecentError] = useState(false);
const [recentAttempt, setRecentAttempt] = useState(0);
const profileLinkRef = useRef<HTMLAnchorElement>(null);
const importLinkRef = useRef<HTMLAnchorElement>(null);
const recentLinkRef = useRef<HTMLAnchorElement>(null);
useEffect(() => {
let cancelled = false;
setRecentLoading(true);
cvBuilderApi.list()
.then((items) => {
if (!cancelled) {
@@ -88,7 +99,7 @@ export default function CareerWorkspaceOverview({ completeness, runs, loading, l
if (!cancelled) setRecentLoading(false);
});
return () => { cancelled = true; };
}, []);
}, [recentAttempt]);
const importState = useMemo(() => {
const review = runs.filter((run) => run.status === "pending_review");
@@ -100,66 +111,60 @@ export default function CareerWorkspaceOverview({ completeness, runs, loading, l
return { severity: "success" as const, label: t("careerOverviewImportCv"), message: t(runs.length > 0 ? "careerOverviewImportComplete" : "careerOverviewNoImport") };
}, [runs, t]);
const isFirstRun = !loading && !recentLoading && !loadError && (completeness?.percent ?? 0) === 0 && runs.length === 0 && recentCvs.length === 0;
const profileKnown = !loading && !loadError && completeness !== null;
const importUnknown = runsLoading && runs.length === 0;
const importLabel = importUnknown || runsLoadError ? t("careerWorkspaceImportTab") : importState.label;
const isFirstRun = profileKnown && !runsLoading && !runsLoadError && !recentLoading && !recentError && completeness.percent === 0 && runs.length === 0 && recentCvs.length === 0;
return (
<Box component="section" aria-labelledby="career-workspace-overview-title" sx={{ display: "grid", gap: 2 }}>
<Paper sx={{ p: { xs: 2, sm: 2.5 }, borderRadius: 4 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 2, flexWrap: "wrap" }}>
<Box>
<Typography id="career-workspace-overview-title" component="h2" variant="h5" sx={{ fontWeight: 900 }}>{t("careerWorkspaceOverviewTab")}</Typography>
<Typography color="text.secondary">{t("careerOverviewSubtitle")}</Typography>
</Box>
<Button variant="contained" startIcon={<DescriptionOutlinedIcon />} href="/career/builder">{t("careerOverviewOpenBuilder")}</Button>
</Box>
{isFirstRun ? <Alert severity="info" sx={{ mt: 2 }}>{t("careerOverviewFirstRun")}</Alert> : null}
{loadError ? <Alert severity="error" sx={{ mt: 2 }}>{t("careerOverviewUnavailable")}</Alert> : null}
<Box sx={{ mt: 2, display: "grid", gridTemplateColumns: { xs: "1fr", md: "minmax(0, 1fr) minmax(0, 1fr)" }, gap: 2 }}>
<Box aria-label={t("careerOverviewProfileStatus")} sx={{ p: 2, borderRadius: 3, bgcolor: "background.default", border: "1px solid", borderColor: "divider" }}>
<PageSection title={t("careerWorkspaceOverviewTab")} description={t("careerOverviewSubtitle")} actions={
<Button variant="contained" startIcon={<DescriptionOutlinedIcon />} href="/career/builder">{t("careerOverviewOpenBuilder")}</Button>
}>
<Box sx={{ display: "grid", gap: "var(--app-section-gap)" }}>
{isFirstRun ? <Alert severity="info">{t("careerOverviewFirstRun")}</Alert> : null}
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "minmax(0, 1fr) minmax(0, 1fr)" }, gap: 2 }}>
<PageSection surface title={t("careerOverviewProfileStatus")} level="h3">
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center" }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>{t("careerOverviewCompleteness")}</Typography>
{loading ? <Skeleton width={42} /> : <Typography sx={{ fontWeight: 900 }}>{completeness?.percent ?? 0}%</Typography>}
<Typography variant="body2" color="text.secondary">{t("careerOverviewCompleteness")}</Typography>
{loading ? <Skeleton width={42} /> : profileKnown ? <Typography sx={{ fontWeight: 900 }}>{completeness.percent}%</Typography> : null}
</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 ? (
{loading || profileKnown ? <LinearProgress aria-label={t("careerOverviewCompletenessLabel")} variant={loading ? "indeterminate" : "determinate"} value={completeness?.percent} sx={{ mt: 1, height: 8, borderRadius: 999 }} /> : null}
{loadError ? <Alert severity="error" sx={{ mt: 1 }} action={onRetryProfile ? <Button color="inherit" disabled={loading || profileDirty} onClick={() => { profileLinkRef.current?.focus(); onRetryProfile(); }}>{t("retry")}</Button> : undefined}>
{t("careerOverviewUnavailable")}
{profileDirty ? <Typography variant="body2" sx={{ mt: 1 }}>{t("careerOverviewRetryDraftHint")}</Typography> : null}
</Alert> : null}
{profileKnown && completeness.missing.length ? (
<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>
<Alert severity={importState.severity} sx={{ borderRadius: 3, alignItems: "flex-start" }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>{t("careerOverviewCvImport")}</Typography>
<Typography variant="body2">{importState.message}</Typography>
<Button href="/career?section=import" onClick={onNavigate ? (event) => { event.preventDefault(); onNavigate("import"); } : undefined} color="inherit" size="small" sx={{ mt: 0.75, px: 0.5 }}>{importState.label}</Button>
</Alert>
<Button ref={profileLinkRef} href="/career?section=profile" onClick={onNavigate ? (event) => { event.preventDefault(); onNavigate("profile"); } : undefined} size="small" sx={{ mt: 1, px: 0.5 }}>{t("careerOverviewImproveProfile")}</Button>
</PageSection>
<PageSection surface title={t("careerOverviewCvImport")} level="h3">
{importUnknown ? <Typography role="status" color="text.secondary">{t("loading")}</Typography>
: runsLoadError ? <Alert severity="error" action={onRetryRuns ? <Button color="inherit" disabled={runsLoading} onClick={() => { importLinkRef.current?.focus(); onRetryRuns(); }}>{t("retry")}</Button> : undefined}>{t("careerOverviewImportUnavailable")}</Alert>
: <Alert severity={importState.severity}>{importState.message}</Alert>}
<Button ref={importLinkRef} href="/career?section=import" onClick={onNavigate ? (event) => { event.preventDefault(); onNavigate("import"); } : undefined} size="small" sx={{ mt: 0.75, px: 0.5 }}>{importLabel}</Button>
</PageSection>
</Box>
</Paper>
<Box component="nav" aria-label={t("careerOverviewActions")} sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))", xl: "repeat(4, minmax(0, 1fr))" }, gap: 1.5 }}>
<ActionCard title={t("careerOverviewCareerProfile")} body={t("careerOverviewCareerProfileBody")} href="/career?section=profile" onNavigate={onNavigate ? () => onNavigate("profile") : undefined} label={t("careerOverviewEditProfile")} icon={<PersonOutlineIcon />} />
<ActionCard title={t("careerOverviewImportCv")} body={t("careerOverviewImportBody")} href="/career?section=import" onNavigate={onNavigate ? () => onNavigate("import") : undefined} label={importState.label} icon={<UploadFileOutlinedIcon />} />
<ActionCard title={t("careerOverviewImportCv")} body={t("careerOverviewImportBody")} href="/career?section=import" onNavigate={onNavigate ? () => onNavigate("import") : undefined} label={importLabel} icon={<UploadFileOutlinedIcon />} />
<ActionCard title={t("careerOverviewGeneralCv")} body={t("careerOverviewGeneralCvBody")} href="/career/builder" label={t("careerOverviewCreateGeneralCv")} icon={<DescriptionOutlinedIcon />} />
<ActionCard title={t("careerOverviewJobCv")} body={t("careerOverviewJobCvBody")} href="/jobs" label={t("careerOverviewChooseJob")} icon={<WorkOutlineIcon />} />
</Box>
<Paper component="section" aria-labelledby="recent-cvs-title" sx={{ p: 2, borderRadius: 4 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
<Typography id="recent-cvs-title" variant="h6" sx={{ fontWeight: 800 }}>{t("careerOverviewRecentCvs")}</Typography>
<Button href="/career/builder" size="small">{t("careerOverviewViewAll")}</Button>
</Box>
<PageSection surface title={t("careerOverviewRecentCvs")} level="h3" actions={<Button ref={recentLinkRef} href="/career/builder" size="small">{t("careerOverviewViewAll")}</Button>}>
{recentLoading ? (
<Stack spacing={1} sx={{ mt: 1.5 }}><Skeleton height={32} /><Skeleton height={32} /></Stack>
<Stack spacing={1}><Typography role="status" color="text.secondary">{t("loading")}</Typography><Skeleton height={32} /><Skeleton height={32} /></Stack>
) : recentError ? (
<Alert severity="warning" sx={{ mt: 1.5 }}>{t("careerOverviewRecentFailed")}</Alert>
<Alert severity="warning" action={<Button color="inherit" onClick={() => { recentLinkRef.current?.focus(); setRecentAttempt((attempt) => attempt + 1); }}>{t("retry")}</Button>}>{t("careerOverviewRecentFailed")}</Alert>
) : recentCvs.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>{t("careerOverviewNoCvs")}</Typography>
) : (
<Stack spacing={1} sx={{ mt: 1 }}>
{recentCvs.map((cv) => (
<Box key={cv.id} sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", flexWrap: "wrap" }}>
<Box>
<Box sx={{ minWidth: 0, overflowWrap: "anywhere" }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{cv.name}</Typography>
<Typography variant="caption" color="text.secondary">{t("careerOverviewUpdated", { date: new Date(cv.updatedAtUtc).toLocaleDateString(language === "nb" ? "nb-NO" : "en-GB") })}</Typography>
</Box>
@@ -171,7 +176,8 @@ export default function CareerWorkspaceOverview({ completeness, runs, loading, l
))}
</Stack>
)}
</Paper>
</Box>
</PageSection>
</Box>
</PageSection>
);
}