feat(ui): migrate Career import review and version history

This commit is contained in:
cesnimda
2026-10-12 19:28:25 +02:00
parent ec23b650f7
commit 5d6e32358c
20 changed files with 10454 additions and 124 deletions
+210 -100
View File
@@ -26,6 +26,7 @@ import {
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
import { useAccountPlan } from "../accountPlan";
import type { TranslationKey } from "../i18n/translations";
import ProFeatureNotice from "../components/ProFeatureNotice";
import { PageSection } from "../layout/PageLayout";
import type { UserOperation } from "../types";
@@ -52,6 +53,7 @@ type ExtractionRun = {
operation?: UserOperation | null;
};
type CvImportChange = { id: string; label: string; confidence: string; fieldChanges?: Array<{ field: string; oldValue?: string | null; newValue?: string | null }> };
type CvImportDiff = {
totalAdded: number;
totalUpdated: number;
@@ -59,8 +61,8 @@ type CvImportDiff = {
hasChanges: boolean;
categories: Array<{
category: string;
added: Array<{ id: string; label: string; confidence: string }>;
updated: Array<{ id: string; label: string; confidence: string }>;
added: CvImportChange[];
updated: CvImportChange[];
unchangedCount: number;
lowConfidenceCount: number;
}>;
@@ -92,6 +94,21 @@ function cvRunStatus(run: ExtractionRun) {
}
}
const importLabelKeys: Record<string, TranslationKey> = {
Contact: "careerProfileContactSection", Summary: "profileCvStructuredSummary", "Professional summary": "profileCvStructuredSummary",
Experience: "cvSectionExperience", Education: "cvSectionEducation", Projects: "cvSectionProjects", Certifications: "cvSectionCertifications",
Languages: "cvSectionLanguages", Skills: "cvSectionSkills", Interests: "cvSectionInterests", Awards: "cvSectionAwards",
Publications: "cvSectionPublications", Organisations: "cvSectionOrganisations", References: "cvSectionReferences",
"Full name": "profileCvContactFullName", Headline: "profileCvContactHeadline", Email: "profileCvContactEmail", Phone: "profileCvContactPhone",
Location: "profileCvContactLocation", Website: "profileCvContactWebsite", LinkedIn: "profileCvContactLinkedIn", Level: "profileCvLanguageLevel",
Dates: "careerImportDates", Date: "careerImportDates", Bullets: "careerImportDetails", Details: "careerImportDetails", Issuer: "careerImportIssuer",
queued: "careerImportQueued", running: "careerImportProcessing", "processing locally": "careerImportProcessing",
"waiting to retry": "careerImportWaitingRetry", "waiting for approved fallback": "careerImportWaitingFallback",
"cancellation requested": "operationsCancellationRequested", pending_review: "careerImportPendingReview", applied: "careerImportApplied",
discarded: "careerImportDiscarded", failed: "careerImportFailed", cancelled: "careerImportCancelled",
upload: "careerImportUpload", rebuild: "careerImportRebuild", improve: "careerImportImprove", reprocess: "careerImportReprocess",
};
type MeResponse = {
provider?: "local" | "google" | "external";
id?: string;
@@ -173,6 +190,10 @@ export default function CareerProfilePage() {
const [profileDirty, setProfileDirty] = useState(false);
const [completeness, setCompleteness] = useState<CareerCompleteness | null>(null);
const [versions, setVersions] = useState<CareerVersion[]>([]);
const [versionsLoading, setVersionsLoading] = useState(true);
const [versionsError, setVersionsError] = useState<string | null>(null);
const versionsReadId = useRef(0);
const [restoreError, setRestoreError] = useState<string | null>(null);
// The raw import/section parser remains available as an advanced recovery tool.
const [showAdvancedCvTools, setShowAdvancedCvTools] = useState(false);
const [workspaceSection, setWorkspaceSection] = useState<CareerWorkspaceSection>(initialWorkspaceSection);
@@ -189,41 +210,87 @@ export default function CareerProfilePage() {
}, []);
const loadVersions = useCallback(async () => {
const readId = ++versionsReadId.current;
setVersionsLoading(true);
try {
const r = await api.get<CareerVersion[]>("/career/profile/versions");
setVersions(r.data ?? []);
} catch {
setVersions([]);
if (readId === versionsReadId.current) {
setVersions(r.data ?? []);
setVersionsError(null);
}
} catch (error) {
if (readId === versionsReadId.current) setVersionsError(getApiErrorMessage(error, ""));
} finally {
if (readId === versionsReadId.current) setVersionsLoading(false);
}
}, []);
const restoreVersion = useCallback(async (version: number) => {
if (profileMutationPending.current || me?.provider !== "local" || loading) return;
if (profileMutationPending.current || me?.provider !== "local" || loading || profileDirty) return;
profileMutationPending.current = true;
setLoading(true);
setRestoreError(null);
try {
const r = await api.post<CareerProfileResponse>(`/career/profile/versions/${version}/restore`);
setStructuredCv(normalizeStructuredCv(r.data?.profile ?? emptyStructuredCv()));
setCompleteness(r.data?.completeness ?? null);
setProfileDirty(false);
invalidateImportComparisons();
await loadVersions();
toast(t("profileUpdated"), "success");
} catch (e: any) {
toast(String(e?.response?.data || e?.message || t("profileUpdateFailed")), "error");
} catch (error) {
const message = getApiErrorMessage(error, "");
setRestoreError(message);
toast(message || translationRef.current("careerRestoreFailed"), "error");
} finally {
profileMutationPending.current = false;
setLoading(false);
}
}, [loadVersions, loading, me?.provider, t, toast]);
}, [loadVersions, loading, me?.provider, profileDirty, 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 [runDiffErrors, setRunDiffErrors] = useState<Record<number, string>>({});
const [runDiffLoading, setRunDiffLoading] = useState<Record<number, boolean>>({});
const diffReadGeneration = useRef(0);
const diffReadIds = useRef<Record<number, number>>({});
const [reviewingRunId, setReviewingRunId] = useState<number | null>(null);
const [reviewAction, setReviewAction] = useState<"accept" | "discard" | null>(null);
const reviewPending = useRef(false);
const [runActionErrors, setRunActionErrors] = useState<Record<number, { key: TranslationKey; message: string }>>({});
const operationPending = useRef(new Set<string>());
const [pendingOperations, setPendingOperations] = useState<string[]>([]);
const [acceptedLowConfidenceIds, setAcceptedLowConfidenceIds] = useState<Record<number, string[]>>({});
const runStatusRef = useRef<Record<number, string>>({});
function invalidateImportComparisons() {
// A comparison and its choices refer to the source that was current when it was read.
++diffReadGeneration.current;
setRunDiffs({});
setRunDiffErrors({});
setRunDiffLoading({});
setAcceptedLowConfidenceIds({});
}
const loadRunDiff = useCallback(async (runId: number) => {
const generation = diffReadGeneration.current;
const readId = (diffReadIds.current[runId] ?? 0) + 1;
diffReadIds.current[runId] = readId;
const isCurrent = () => generation === diffReadGeneration.current && readId === diffReadIds.current[runId];
setRunDiffLoading((current) => ({ ...current, [runId]: true }));
setRunDiffErrors((current) => { const next = { ...current }; delete next[runId]; return next; });
try {
const response = await api.get<CvRunDiffResponse>(`/profile-cv/runs/${runId}/diff`);
if (isCurrent()) setRunDiffs((current) => ({ ...current, [runId]: response.data.diff }));
} catch (error) {
if (isCurrent()) setRunDiffErrors((current) => ({ ...current, [runId]: getApiErrorMessage(error, "") }));
} finally {
if (isCurrent()) setRunDiffLoading((current) => ({ ...current, [runId]: false }));
}
}, []);
const editStructuredCv = useCallback((next: React.SetStateAction<StructuredCvProfile>) => {
setStructuredCv(next);
setProfileDirty(true);
@@ -306,15 +373,64 @@ export default function CareerProfilePage() {
}, [extractionRuns, loadExtractionRuns]);
useEffect(() => {
const pending = extractionRuns.filter((run) => run.status === "pending_review" && !runDiffs[run.id]);
if (pending.length === 0) return;
void Promise.all(pending.map((run) => api.get<CvRunDiffResponse>(`/profile-cv/runs/${run.id}/diff`)))
.then((responses) => setRunDiffs((current) => Object.fromEntries([
...Object.entries(current),
...responses.map((response) => [response.data.runId, response.data.diff]),
])))
.catch(() => undefined);
}, [extractionRuns, runDiffs]);
for (const run of extractionRuns) {
if (run.status === "pending_review" && !runDiffs[run.id] && !runDiffLoading[run.id] && runDiffErrors[run.id] === undefined) void loadRunDiff(run.id);
}
}, [extractionRuns, loadRunDiff, runDiffErrors, runDiffLoading, runDiffs]);
const changeRunOperation = async (run: ExtractionRun, action: "cancel" | "retry") => {
const operation = run.operation;
if (!isLocal || !operation || operationPending.current.has(operation.id)) return;
const runRegion = document.getElementById(`career-import-run-${run.id}`);
if (runRegion?.contains(document.activeElement)) runRegion.focus();
operationPending.current.add(operation.id);
setPendingOperations([...operationPending.current]);
setRunActionErrors((current) => { const next = { ...current }; delete next[run.id]; return next; });
try {
await api.post(`/operations/${operation.id}/${action}`);
await loadExtractionRuns();
toast(translationRef.current(action === "cancel" ? "profileCvCancelRequested" : "profileCvRetryQueued"), "info");
} catch (error) {
const key = action === "cancel" ? "profileCvCancelFailed" : "profileCvRetryFailed";
const message = getApiErrorMessage(error, "");
setRunActionErrors((current) => ({ ...current, [run.id]: { key, message } }));
toast(message || translationRef.current(key), "error");
} finally {
operationPending.current.delete(operation.id);
setPendingOperations([...operationPending.current]);
}
};
const reviewRun = async (runId: number, action: "accept" | "discard") => {
if (!isLocal || loading || saving || reviewPending.current || profileMutationPending.current || (action === "accept" && (profileDirty || !runDiffs[runId]))) return;
const runRegion = document.getElementById(`career-import-run-${runId}`);
if (runRegion?.contains(document.activeElement)) runRegion.focus();
reviewPending.current = true;
if (action === "accept") profileMutationPending.current = true;
setReviewingRunId(runId);
setReviewAction(action);
setRunActionErrors((current) => { const next = { ...current }; delete next[runId]; return next; });
try {
if (action === "accept") await api.post(`/profile-cv/runs/${runId}/accept`, { acceptedLowConfidenceIds: acceptedLowConfidenceIds[runId] ?? [] });
else await api.post(`/profile-cv/runs/${runId}/discard`);
setExtractionRuns((current) => current.map((run) => run.id === runId ? { ...run, status: action === "accept" ? "applied" : "discarded" } : run));
if (action === "accept") {
invalidateImportComparisons();
await Promise.all([loadProfile(), loadExtractionRuns(), loadVersions()]);
} else await loadExtractionRuns();
toast(translationRef.current(action === "accept" ? "profileCvChangesApplied" : "profileCvExtractionDiscarded"), action === "accept" ? "success" : "info");
} catch (error) {
const key = action === "accept" ? "profileCvApplyChangesFailed" : "profileCvDiscardFailed";
const message = getApiErrorMessage(error, "");
setRunActionErrors((current) => ({ ...current, [runId]: { key, message } }));
toast(message || translationRef.current(key), "error");
} finally {
if (action === "accept") profileMutationPending.current = false;
reviewPending.current = false;
setReviewingRunId(null);
setReviewAction(null);
}
};
useEffect(() => {
const previous = runStatusRef.current;
@@ -337,7 +453,7 @@ export default function CareerProfilePage() {
const initials = useMemo(() => initialsFrom([me?.displayName, me?.firstName, me?.lastName, me?.userName, me?.email]), [me]);
const isLocal = me?.provider === "local";
const editorDisabled = !isLocal || loading || saving;
const editorDisabled = !isLocal || loading || saving || parsingCvSections || (reviewingRunId !== null && reviewAction === "accept");
const loadErrorMessage = loadError === null ? null : loadError || t("profileLoadFailed");
const fullName = [me?.firstName, me?.lastName].filter(Boolean).join(" ");
const cvWordCount = profileCvText.trim() ? profileCvText.trim().split(/\s+/).length : 0;
@@ -355,8 +471,11 @@ export default function CareerProfilePage() {
: run.status === "queued" || run.status === "running");
const pendingCvRun = extractionRuns.find((run) => run.status === "pending_review");
const extractionProgress = activeCvRun?.operation?.progressPercent;
const importLabel = (value: string) => Object.hasOwn(importLabelKeys, value) ? t(importLabelKeys[value]) : value;
const importChangeLabel = (change: CvImportChange, category: string) => category === "Professional summary" ? importLabel(category)
: change.id === "Contact" && change.confidence === "Low" && change.label === "Contact details" ? t("careerImportContactDetails") : change.label;
return (
<Box sx={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr)", gap: 2, minWidth: 0 }}>
<Box sx={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr)", gap: 2, minWidth: 0, "& .MuiAlert-root": { flexWrap: "wrap" }, "& .MuiAlert-message": { minWidth: 0, flex: "1 1 160px", overflow: "visible", overflowWrap: "anywhere" } }}>
<Paper component="nav" aria-label={t("careerWorkspaceNavigation")} variant="outlined" sx={{ borderRadius: 1, minWidth: 0, overflow: "hidden" }}>
<FormControl size="small" fullWidth sx={{ display: { xs: "flex", sm: "none" }, m: 1.25, width: "auto" }}>
<InputLabel id="career-workspace-section-label">{t("careerWorkspaceNavigation")}</InputLabel>
@@ -389,12 +508,12 @@ export default function CareerProfilePage() {
<CareerWorkspaceOverview
completeness={completeness}
runs={extractionRuns}
loading={loading || saving}
loading={loading || saving || (reviewingRunId !== null && reviewAction === "accept")}
loadError={loadErrorMessage}
runsLoading={runsLoading}
runsLoadError={runsLoadError}
onRetryRuns={() => void loadExtractionRuns()}
onRetryProfile={() => { if (!profileDirty) void loadProfile(); }}
onRetryProfile={() => { if (!profileDirty && !profileMutationPending.current) void loadProfile(); }}
profileDirty={profileDirty}
onNavigate={navigateWorkspace}
/>
@@ -402,7 +521,7 @@ export default function CareerProfilePage() {
{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, "& .MuiFormHelperText-root.Mui-disabled": { color: "text.secondary" } }}>
{workspaceSection === "profile" || workspaceSection === "import" ? <Paper variant="outlined" id={workspaceSection === "profile" ? "career-profile-editor" : "career-cv-import"} sx={{ mt: 0, p: "var(--app-card-padding)", borderRadius: 1, minWidth: 0, scrollMarginTop: 96, "& .MuiFormHelperText-root.Mui-disabled": { color: "text.secondary" } }}>
<input
ref={cvInputRef}
type="file"
@@ -420,6 +539,11 @@ export default function CareerProfilePage() {
loading={editorDisabled}
onRestore={(version) => void restoreVersion(version)}
showSummary={false}
versionsLoading={versionsLoading}
versionsError={versionsError}
onRetryVersions={() => void loadVersions()}
restoreDisabled={profileDirty}
restoreError={restoreError}
/> : null}
{workspaceSection === "profile" ? <CropImageDialog
open={cropOpen}
@@ -450,7 +574,7 @@ export default function CareerProfilePage() {
/> : null}
{loadErrorMessage ? (
<Alert severity="error" sx={{ mb: 2 }} action={<Button color="inherit" size="small" disabled={profileDirty || loading || saving} onClick={() => { if (!profileDirty && !saving) void loadProfile(); }}>{t("retry")}</Button>}>
<Alert severity="error" sx={{ mb: 2 }} action={<Button color="inherit" size="small" disabled={profileDirty || editorDisabled} onClick={() => { if (!profileDirty && !profileMutationPending.current) void loadProfile(); }}>{t("retry")}</Button>}>
{t("profileLoadFailed")}
{loadError ? <Typography variant="body2" sx={{ mt: 0.5 }}>{loadError}</Typography> : null}
</Alert>
@@ -487,7 +611,7 @@ export default function CareerProfilePage() {
{pendingCvRun ? <Alert severity="success" sx={{ mt: 1.5 }} action={<Button color="inherit" size="small" onClick={() => navigateWorkspace("import")}>{t("careerProfileImportReview")}</Button>}>
{t("careerProfileImportReady")}
</Alert> : null}
{!canUseAi ? <Box sx={{ mt: 1.5, "& .MuiAlert-root": { flexWrap: "wrap" }, "& .MuiAlert-message": { minWidth: 0, overflowWrap: "anywhere" } }}><ProFeatureNotice featureKey="career-ai" title={t("profileProImportTitle")}>{t("profileProImportBody")}</ProFeatureNotice></Box> : null}
{!canUseAi ? <Box sx={{ mt: 1.5 }}><ProFeatureNotice featureKey="career-ai" title={t("profileProImportTitle")}>{t("profileProImportBody")}</ProFeatureNotice></Box> : null}
</Paper> : null}
{workspaceSection === "profile" ? <Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
@@ -552,10 +676,10 @@ export default function CareerProfilePage() {
</Box> : null}
<Box sx={{ mt: 3, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
<Box sx={{ mt: 2, display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "minmax(0, 1fr) minmax(0, 1fr)" }, gap: 2, minWidth: 0 }}>
<Box sx={{ gridColumn: "1 / -1", p: workspaceSection === "import" ? { xs: 1.5, sm: 2 } : 0, borderRadius: 3, border: workspaceSection === "import" ? "1px solid" : "none", borderColor: "divider", backgroundColor: workspaceSection === "import" ? "background.default" : "transparent", display: careerOnly ? "block" : "none" }}>
<Box sx={{ gridColumn: "1 / -1", minWidth: 0, display: careerOnly ? "block" : "none" }}>
<Box sx={{ display: workspaceSection === "import" ? "block" : "none" }}>
{!canUseAi && (
<Box sx={{ mb: 2 }}>
@@ -573,12 +697,12 @@ export default function CareerProfilePage() {
</Typography>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Button variant="outlined" disabled={!canUseAi || !isLocal || uploadingCv || improvingCv || rebuildingCv} onClick={() => cvInputRef.current?.click()}>
<Button variant="outlined" disabled={!canUseAi || editorDisabled || uploadingCv || improvingCv || rebuildingCv} onClick={() => cvInputRef.current?.click()}>
{uploadingCv ? t("profileUploading") : t("profileUploadCv")}
</Button>
<Button
variant="outlined"
disabled={!canUseAi || !isLocal || profileDirty || !profileCvText.trim() || uploadingCv || improvingCv || rebuildingCv}
disabled={!canUseAi || editorDisabled || profileDirty || !profileCvText.trim() || uploadingCv || improvingCv || rebuildingCv}
onClick={async () => {
setRebuildingCv(true);
try {
@@ -596,7 +720,7 @@ export default function CareerProfilePage() {
</Button>
<Button
variant="outlined"
disabled={!canUseAi || !isLocal || profileDirty || !profileCvText.trim() || uploadingCv || improvingCv || rebuildingCv}
disabled={!canUseAi || editorDisabled || profileDirty || !profileCvText.trim() || uploadingCv || improvingCv || rebuildingCv}
onClick={async () => {
setImprovingCv(true);
try {
@@ -614,7 +738,7 @@ export default function CareerProfilePage() {
</Button>
<Button
variant="outlined"
disabled={!canUseAi || !isLocal || uploadingCv || improvingCv || rebuildingCv || reprocessingCv || !latestRun}
disabled={!canUseAi || editorDisabled || uploadingCv || improvingCv || rebuildingCv || reprocessingCv || !latestRun}
onClick={async () => {
setReprocessingCv(true);
try {
@@ -636,9 +760,9 @@ export default function CareerProfilePage() {
<Alert severity="info" sx={{ mb: 2, borderRadius: 2.5 }}>
{t("profileCvStructuredDefaultHint")}
</Alert>
<Accordion disableGutters elevation={0} sx={{ mb: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper", "&:before": { display: "none" } }}>
<Accordion disableGutters elevation={0} sx={{ mb: 2, borderRadius: 1, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper", "&:before": { display: "none" } }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1.5, alignItems: "center", width: "100%", pr: 1 }}>
<Box sx={{ display: "flex", flexWrap: "wrap", justifyContent: "space-between", gap: 1.5, alignItems: "center", width: "100%", minWidth: 0, pr: 1 }}>
<Box>
<Typography component="span" variant="subtitle1" sx={{ fontWeight: 800 }}>{t("profileCvRawPanelTitle")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvRawPanelHelp")}</Typography>
@@ -647,6 +771,7 @@ export default function CareerProfilePage() {
</Box>
</AccordionSummary>
<AccordionDetails>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>{t("careerImportRawSourceHelp")}</Typography>
<TextField
label={t("profileCvTextLabel")}
value={profileCvText}
@@ -664,22 +789,17 @@ export default function CareerProfilePage() {
</Box>
</AccordionDetails>
</Accordion>
<Box sx={{ mt: 2, p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.5 }}>
<Box>
<Typography component="h3" variant="subtitle1" sx={{ fontWeight: 800 }}>{t("profileCvExtractionHistory")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvExtractionHistoryHelp")}</Typography>
</Box>
{structuredCv.metadata.profileVersion ? <Chip label={t("profileCvProfileVersion", { count: structuredCv.metadata.profileVersion })} size="small" /> : null}
</Box>
<PageSection title={t("profileCvExtractionHistory")} description={t("profileCvExtractionHistoryHelp")} surface>
{runsLoading ? <LinearProgress aria-label={t("loading")} /> : null}
{runsLoadError !== null ? <Alert severity="error" action={<Button color="inherit" disabled={runsLoading} onClick={() => void loadExtractionRuns()}>{t("retry")}</Button>}>{t("careerOverviewImportUnavailable")}</Alert> : null}
{latestRun ? (
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.25 }}>
<Box sx={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr)", gap: 1.5, minWidth: 0 }}>
{extractionRuns.map((run) => (
<Box key={run.id} sx={{ p: 1.25, borderRadius: 2.5, border: "1px solid", borderColor: run.id === structuredCv.metadata.appliedExtractionRunId ? "primary.main" : "divider", backgroundColor: "background.default" }}>
<Box component="article" id={`career-import-run-${run.id}`} tabIndex={-1} aria-label={`${importLabel(run.trigger)} ${run.id}`} key={run.id} sx={{ p: "var(--app-card-padding)", minWidth: 0, overflowWrap: "anywhere", borderRadius: 1, border: "1px solid", borderColor: run.id === structuredCv.metadata.appliedExtractionRunId ? "primary.main" : "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap", alignItems: "center", mb: 0.75 }}>
<Typography variant="overline">{run.trigger}</Typography>
<Typography component="h3" variant="subtitle1" sx={{ fontWeight: 600 }}>{importLabel(run.trigger)} · {run.id}</Typography>
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap" }}>
<Chip size="small" label={cvRunStatus(run)} color={run.status === "applied" ? "success" : run.operation?.status === "failed" || run.status === "failed" ? "error" : "default"} variant={run.status === "applied" ? "filled" : "outlined"} />
<Chip size="small" label={importLabel(cvRunStatus(run))} color={run.status === "applied" ? "success" : run.operation?.status === "failed" || run.status === "failed" ? "error" : "default"} variant={run.status === "applied" ? "filled" : "outlined"} />
{run.id === structuredCv.metadata.appliedExtractionRunId ? <Chip size="small" color="primary" label={t("profileCvCurrentRun")} /> : null}
</Box>
</Box>
@@ -693,27 +813,15 @@ export default function CareerProfilePage() {
</Typography>
) : null}
{run.operation?.canCancel ? (
<Button size="small" color="inherit" sx={{ mt: 0.75 }} onClick={async () => {
try {
await api.post(`/operations/${run.operation!.id}/cancel`);
await loadExtractionRuns();
toast(t("profileCvCancelRequested"), "info");
} catch (error) {
toast(getApiErrorMessage(error, t("profileCvCancelFailed")), "error");
}
}}>{t("profileCvCancelProcessing")}</Button>
<Button size="small" color="inherit" disabled={!isLocal || pendingOperations.includes(run.operation.id)} sx={{ mt: 0.75 }} onClick={() => void changeRunOperation(run, "cancel")}>{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(t("profileCvRetryQueued"), "info");
} catch (error) {
toast(getApiErrorMessage(error, t("profileCvRetryFailed")), "error");
}
}}>{t("profileCvRetryProcessing")}</Button>
<Button size="small" color="inherit" disabled={!isLocal || pendingOperations.includes(run.operation.id)} sx={{ mt: 0.75 }} onClick={() => void changeRunOperation(run, "retry")}>{t("profileCvRetryProcessing")}</Button>
) : null}
{runActionErrors[run.id] ? <Alert severity="error" sx={{ mt: 1 }}>
<Typography variant="body2" component="p" sx={{ fontWeight: 600 }}>{t(runActionErrors[run.id].key)}</Typography>
{runActionErrors[run.id].message ? <Typography variant="body2" component="p">{runActionErrors[run.id].message}</Typography> : null}
</Alert> : null}
{run.status === "pending_review" ? (
<Box sx={{ mt: 1.25 }}>
{runDiffs[run.id] ? (
@@ -722,25 +830,39 @@ export default function CareerProfilePage() {
{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(" | ") || t("profileCvNoChangesFound")}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ my: 1 }}>{t("careerImportComparisonScope")}</Typography>
{!runDiffs[run.id].hasChanges ? <Typography variant="body2" color="text.secondary">{t("careerImportNoComparedChanges")}</Typography> : null}
{runDiffs[run.id].categories.filter((category) => category.added.length || category.updated.length).map((category) => (
<Box key={category.category} sx={{ mt: 1.5, minWidth: 0 }}>
<Typography component="h4" variant="subtitle2">{importLabel(category.category)}</Typography>
{[...category.added.map((change) => ({ ...change, added: true })), ...category.updated.map((change) => ({ ...change, added: false }))].map((change) => (
<Box key={change.id} sx={{ py: 1, borderBottom: "1px solid", borderColor: "divider" }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{importChangeLabel(change, category.category)} · {t(change.added ? "careerImportAdded" : "careerImportUpdated")}</Typography>
{(change.fieldChanges ?? []).map((field) => <Box key={field.field} sx={{ mt: 0.5, display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", sm: "minmax(0, 1fr) minmax(0, 1fr)" }, columnGap: 2 }}>
<Typography variant="caption" color="text.secondary" sx={{ gridColumn: "1 / -1" }}>{importLabel(field.field)}</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{t("careerImportCurrentValue", { value: field.oldValue || t("careerImportEmptyValue") })}</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{t("careerImportIncomingValue", { value: field.newValue || t("careerImportEmptyValue") })}</Typography>
</Box>)}
</Box>
))}
</Box>
))}
</>
) : <LinearProgress sx={{ my: 1 }} />}
) : runDiffErrors[run.id] !== undefined ? <Alert severity="error" action={<Button color="inherit" disabled={runDiffLoading[run.id]} onClick={() => { document.getElementById(`career-import-run-${run.id}`)?.focus(); void loadRunDiff(run.id); }}>{t("retry")}</Button>}>
<Typography variant="body2" component="p" sx={{ fontWeight: 600 }}>{t("careerImportComparisonFailed")}</Typography>
{runDiffErrors[run.id] ? <Typography variant="body2" component="p">{runDiffErrors[run.id]}</Typography> : null}
</Alert> : <LinearProgress aria-label={t("careerImportComparisonLoading")} sx={{ my: 1 }} />}
{runDiffs[run.id]?.categories.flatMap((category) => [...category.added, ...category.updated].map((change) => ({ ...change, category: category.category }))).filter((change) => change.confidence === "Low").map((change) => (
<FormControlLabel
key={change.id}
sx={{ display: "flex", mt: 0.5 }}
control={<Checkbox size="small" checked={(acceptedLowConfidenceIds[run.id] ?? []).includes(change.id)} onChange={(event) => setAcceptedLowConfidenceIds((current) => ({
sx={{ display: "flex", mt: 0.5, mr: 0, "& .MuiFormControlLabel-label.Mui-disabled": { color: "text.secondary" } }}
control={<Checkbox size="small" disabled={editorDisabled || reviewingRunId !== null} checked={(acceptedLowConfidenceIds[run.id] ?? []).includes(change.id)} onChange={(event) => setAcceptedLowConfidenceIds((current) => ({
...current,
[run.id]: event.target.checked
? [...(current[run.id] ?? []), change.id]
: (current[run.id] ?? []).filter((id) => id !== change.id),
}))} />}
label={t("profileCvIncludeLowConfidence", { category: change.category, label: change.label })}
label={t("profileCvIncludeLowConfidence", { category: importLabel(change.category), label: importChangeLabel(change, change.category) })}
/>
))}
{profileDirty ? (
@@ -749,44 +871,25 @@ export default function CareerProfilePage() {
</Alert>
) : null}
<Box sx={{ display: "flex", gap: 1, mt: 1, flexWrap: "wrap" }}>
<Button size="small" variant="contained" disabled={!runDiffs[run.id] || reviewingRunId !== null || profileDirty} onClick={async () => {
setReviewingRunId(run.id);
try {
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(t("profileCvChangesApplied"), "success");
} catch (error) {
toast(getApiErrorMessage(error, t("profileCvApplyChangesFailed")), "error");
} finally { setReviewingRunId(null); }
}}>{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(t("profileCvExtractionDiscarded"), "info");
} catch (error) {
toast(getApiErrorMessage(error, t("profileCvDiscardFailed")), "error");
} finally { setReviewingRunId(null); }
}}>{t("profileCvDiscard")}</Button>
<Button size="small" variant="contained" disabled={editorDisabled || !runDiffs[run.id] || reviewingRunId !== null || profileDirty} onClick={() => void reviewRun(run.id, "accept")}>{reviewingRunId === run.id && reviewAction === "accept" ? t("careerImportApplying") : t("profileCvApplyChanges")}</Button>
<Button size="small" color="inherit" disabled={editorDisabled || reviewingRunId !== null} onClick={() => void reviewRun(run.id, "discard")}>{reviewingRunId === run.id && reviewAction === "discard" ? t("careerImportDiscarding") : t("profileCvDiscard")}</Button>
</Box>
</Box>
) : null}
</Box>
))}
</Box>
) : (
) : !runsLoading && runsLoadError === null ? (
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvExtractionHistoryEmpty")}</Typography>
)}
</Box>
) : null}
</PageSection>
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end", mt: 1 }}>
<Button size="small" variant="text" color="inherit" onClick={() => setShowAdvancedCvTools((v) => !v)}>
{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" }}>
<Box sx={{ mt: 2, p: "var(--app-card-padding)", borderRadius: 1, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper", display: showAdvancedCvTools ? "block" : "none" }}>
<Alert severity="warning" sx={{ mb: 1.5 }}>{t("careerImportParseWarning")}</Alert>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.5 }}>
<Box>
<Typography component="h3" variant="subtitle1" sx={{ fontWeight: 800 }}>{t("profileCvStructureOverview")}</Typography>
@@ -794,16 +897,21 @@ export default function CareerProfilePage() {
</Box>
<Button
variant="outlined"
disabled={editorDisabled || !profileCvText.trim() || parsingCvSections}
disabled={editorDisabled || profileDirty || !profileCvText.trim() || parsingCvSections}
onClick={async () => {
if (profileMutationPending.current || editorDisabled || profileDirty) return;
profileMutationPending.current = true;
setParsingCvSections(true);
try {
const res = await api.post<{ structuredCv?: StructuredCvProfile }>("/profile-cv/parse", { text: profileCvText });
editStructuredCv(normalizeStructuredCv(res.data?.structuredCv));
invalidateImportComparisons();
void loadVersions();
toast(t("profileCvStructureParsed"), "success");
} catch (e: any) {
toast(String(e?.response?.data || e?.message || t("profileCvStructureParseFailed")), "error");
} finally {
profileMutationPending.current = false;
setParsingCvSections(false);
}
}}
@@ -892,6 +1000,8 @@ export default function CareerProfilePage() {
setStructuredCv(normalizeStructuredCv(saved.data?.profile ?? structuredCv));
setCompleteness(saved.data?.completeness ?? null);
setProfileDirty(false);
invalidateImportComparisons();
void loadVersions();
toast(t("profileUpdated"), "success");
} catch (error) {
const message = getApiErrorMessage(error, "");