feat(career): version history restore on /career
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:
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user