feat(career): version history restore on /career
CI and Deploy / test (push) Failing after 1m54s
CI and Deploy / deploy (push) Has been skipped

Phase 3, versioning UI. The /career overview now shows a collapsible version
history with a Restore action per past version, backed by the versioning API.
Restore reapplies the chosen snapshot as a new version (non-destructive) and
refreshes the profile + completeness in place.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-18 01:01:54 +02:00
parent f1bf92a4e0
commit a1dd447091
2 changed files with 52 additions and 1 deletions
+3
View File
@@ -106,6 +106,9 @@ beforeEach(() => {
},
} as any);
}
if (url === '/career/profile/versions') {
return Promise.resolve({ data: [] } as any);
}
if (url === '/auth/me') {
return Promise.resolve({
data: {
+49 -1
View File
@@ -230,6 +230,7 @@ function FieldReviewNote({ metadata }: { metadata?: StructuredCvFieldMetadata })
type CareerSectionStatus = { key: string; label: string; complete: boolean; count: number };
type CareerCompleteness = { percent: number; missing: string[]; sections: CareerSectionStatus[] };
type CareerProfileResponse = { profile: StructuredCvProfile; completeness: CareerCompleteness; cvText?: string | null };
type CareerVersion = { version: number; source: string; createdAtUtc: string; isCurrent: boolean };
// CareerProfilePage backs /career: the master career profile — the single editable source of
// truth for all future generated documents. Split out from ProfilePage in Phase 2.2; wired to the
@@ -272,6 +273,31 @@ export default function CareerProfilePage() {
const [reprocessingCv, setReprocessingCv] = useState(false);
const [structuredCv, setStructuredCv] = useState<StructuredCvProfile>(emptyStructuredCv());
const [completeness, setCompleteness] = useState<CareerCompleteness | null>(null);
const [versions, setVersions] = useState<CareerVersion[]>([]);
const loadVersions = useCallback(async () => {
try {
const r = await api.get<CareerVersion[]>("/career/profile/versions");
setVersions(r.data ?? []);
} catch {
setVersions([]);
}
}, []);
const restoreVersion = useCallback(async (version: number) => {
setLoading(true);
try {
const r = await api.post<CareerProfileResponse>(`/career/profile/versions/${version}/restore`);
setStructuredCv(normalizeStructuredCv(r.data?.profile ?? emptyStructuredCv()));
setCompleteness(r.data?.completeness ?? null);
await loadVersions();
toast(t("profileUpdated"), "success");
} catch (e: any) {
toast(String(e?.response?.data || e?.message || t("profileUpdateFailed")), "error");
} finally {
setLoading(false);
}
}, [loadVersions, t, toast]);
const [extractionRuns, setExtractionRuns] = useState<ExtractionRun[]>([]);
const runStatusRef = useRef<Record<number, string>>({});
@@ -328,7 +354,8 @@ export default function CareerProfilePage() {
useEffect(() => {
void loadProfile();
}, [loadProfile]);
void loadVersions();
}, [loadProfile, loadVersions]);
useEffect(() => {
const activeRuns = extractionRuns.filter((run) => run.status === "queued" || run.status === "running");
@@ -493,6 +520,27 @@ export default function CareerProfilePage() {
) : (
<Typography variant="body2" sx={{ color: "success.main", mt: 1.25, fontWeight: 700 }}>Your master profile is complete.</Typography>
)}
{versions.length > 1 ? (
<Accordion disableGutters elevation={0} sx={{ mt: 1.5, "&:before": { display: "none" }, backgroundColor: "transparent" }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ px: 0, minHeight: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>Version history ({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" : ""}
</Typography>
{!v.isCurrent ? (
<Button size="small" variant="text" disabled={loading} onClick={() => void restoreVersion(v.version)}>Restore</Button>
) : null}
</Box>
))}
</Box>
</AccordionDetails>
</Accordion>
) : null}
</Box>
) : null}
<CropImageDialog