Files
jobtrackingapp/job-tracker-ui/src/views/CareerProfilePage.tsx
T
2026-08-09 19:01:40 +02:00

755 lines
39 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Accordion, AccordionDetails, AccordionSummary, Alert, Avatar, Box, Button, Checkbox, Chip, Divider, FormControlLabel, LinearProgress, Paper, TextField, Typography } from "@mui/material";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import PhotoCameraOutlinedIcon from "@mui/icons-material/PhotoCameraOutlined";
import { api, getApiErrorMessage } from "../api";
import GoogleAuthCard from "../components/GoogleAuthCard";
import MicrosoftAuthCard from "../components/MicrosoftAuthCard";
import AuthStatusCard from "../components/AuthStatusCard";
import TwoFactorSettingsCard from "../components/TwoFactorSettingsCard";
import SessionsSettingsCard from "../components/SessionsSettingsCard";
import CropImageDialog from "../components/CropImageDialog";
import ProfileCompleteness from "./career/ProfileCompleteness";
import CareerWorkspaceOverview from "./career/CareerWorkspaceOverview";
import {
EducationSection,
InterestsSection,
LanguagesSection,
LongTailSections,
OtherSectionsSection,
PersonalInformationSection,
ProfessionalSummarySection,
SkillsSection,
WorkExperienceSection,
} from "./career/CareerProfileSections";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
import { useAccountPlan } from "../accountPlan";
import type { UserOperation } from "../types";
import {
emptyStructuredCv,
getStructuredCvFieldMetadata,
joinLines,
normalizeStructuredCv,
parseStructuredCvJson,
splitLines,
StructuredCvFieldMetadata,
StructuredCvProfile,
} from "../profileCv";
type ExtractionRun = {
id: number;
trigger: string;
status: string;
artifactFileName?: string;
startedAtUtc: string;
completedAtUtc?: string;
appliedAtUtc?: string;
parserVersion: string;
normalizerVersion: string;
llmPromptVersion: string;
errorMessage?: string;
operation?: UserOperation | null;
};
type CvImportDiff = {
totalAdded: number;
totalUpdated: number;
totalLowConfidence: number;
hasChanges: boolean;
categories: Array<{
category: string;
added: Array<{ id: string; label: string; confidence: string }>;
updated: Array<{ id: string; label: string; confidence: string }>;
unchangedCount: number;
lowConfidenceCount: number;
}>;
};
type CvRunDiffResponse = { runId: number; status: string; diff: CvImportDiff };
type QueuedCvRunResponse = {
queued: boolean;
extractionRunId: number;
status: string;
operation?: UserOperation | null;
statusUrl?: string | null;
created: boolean;
};
const activeOperationStatuses = new Set(["queued", "running", "waiting_for_retry", "waiting_for_external_fallback"]);
const activeRunLabels = new Set(["queued", "running", "processing locally", "waiting to retry", "waiting for approved fallback"]);
function cvRunStatus(run: ExtractionRun) {
const operation = run.operation;
if (!operation || operation.status === "succeeded") return run.status;
if (operation.cancellationRequestedAtUtc) return "cancellation requested";
switch (operation.status) {
case "running": return "processing locally";
case "waiting_for_retry": return "waiting to retry";
case "waiting_for_external_fallback": return "waiting for approved fallback";
default: return operation.status;
}
}
type MeResponse = {
provider?: "local" | "google" | "external";
id?: string;
email?: string;
userName?: string;
firstName?: string;
lastName?: string;
displayName?: string;
profileCvText?: string;
profileCvStructureJson?: string;
avatarImageDataUrl?: string;
roles?: string[];
googleLink?: {
linked: boolean;
email?: string | null;
linkedAt?: string | null;
} | null;
};
const CV_UPLOAD_ACCEPT = ".pdf,.docx,.txt,.md,image/png,image/jpeg,image/webp,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,text/plain,text/markdown";
const AVATAR_UPLOAD_ACCEPT = "image/png,image/jpeg,image/webp";
function initialsFrom(values: Array<string | undefined>) {
const joined = values.map((x) => (x ?? "").trim()).filter(Boolean);
if (joined.length === 0) return "?";
if (joined.length === 1) {
const parts = joined[0].split(/[\s@._-]+/).filter(Boolean);
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[1][0]).toUpperCase();
}
return (joined[0][0] + joined[1][0]).toUpperCase();
}
// Phase 3: the master profile now comes from the relational source of truth via /career/profile.
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
// relational /career/profile API in Phase 3.
export default function CareerProfilePage() {
const { canUseAi } = useAccountPlan();
// Retained so the shared JSX (copied from ProfilePage) reads identically; hardcoded for /career.
const careerOnly = true;
const { toast } = useToast();
const { t } = useI18n();
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 [loadError, setLoadError] = useState<string | null>(null);
const [uploadingCv, setUploadingCv] = useState(false);
const [improvingCv, setImprovingCv] = useState(false);
const [rebuildingCv, setRebuildingCv] = useState(false);
const [uploadingAvatar, setUploadingAvatar] = useState(false);
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [cropOpen, setCropOpen] = useState(false);
const [headline, setHeadline] = useState("");
const [profileCvText, setProfileCvText] = useState("");
const [parsingCvSections, setParsingCvSections] = useState(false);
const [reprocessingCv, setReprocessingCv] = useState(false);
const [structuredCv, setStructuredCv] = useState<StructuredCvProfile>(emptyStructuredCv());
const [completeness, setCompleteness] = useState<CareerCompleteness | null>(null);
const [versions, setVersions] = useState<CareerVersion[]>([]);
// The raw import/section parser remains available as an advanced recovery tool.
const [showAdvancedCvTools, setShowAdvancedCvTools] = useState(false);
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 [runDiffs, setRunDiffs] = useState<Record<number, CvImportDiff>>({});
const [reviewingRunId, setReviewingRunId] = useState<number | null>(null);
const [acceptedLowConfidenceIds, setAcceptedLowConfidenceIds] = useState<Record<number, string[]>>({});
const runStatusRef = useRef<Record<number, string>>({});
const loadProfile = useCallback(async () => {
setLoading(true);
try {
// /career reads the structured profile from the relational source of truth (/career/profile);
// /auth/me still provides the account row (avatar, provider chips) shown in the header.
const [careerResponse, meResponse, runsResponse] = await Promise.all([
api.get<CareerProfileResponse>("/career/profile"),
api.get<MeResponse>("/auth/me"),
api.get<ExtractionRun[]>("/profile-cv/runs").catch(() => ({ data: [] as ExtractionRun[] } as any)),
]);
setMe(meResponse.data);
setProfileCvText(careerResponse.data?.cvText ?? "");
setStructuredCv(normalizeStructuredCv(careerResponse.data?.profile ?? emptyStructuredCv()));
setCompleteness(careerResponse.data?.completeness ?? null);
setExtractionRuns(runsResponse.data ?? []);
setHeadline(window.localStorage.getItem("profileHeadline") ?? "");
setLoadError(null);
} catch (error: any) {
setMe(null);
setExtractionRuns([]);
setLoadError(String(error?.response?.data || error?.message || "Unable to load profile right now."));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadProfile();
void loadVersions();
}, [loadProfile, loadVersions]);
useEffect(() => {
const activeRuns = extractionRuns.filter((run) => run.operation
? activeOperationStatuses.has(run.operation.status)
: run.status === "queued" || run.status === "running");
if (activeRuns.length === 0) return;
const timer = window.setInterval(() => {
void loadProfile();
}, 4000);
return () => window.clearInterval(timer);
}, [extractionRuns, loadProfile]);
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]);
useEffect(() => {
const previous = runStatusRef.current;
for (const run of extractionRuns) {
const status = cvRunStatus(run);
const prior = previous[run.id];
if (activeRunLabels.has(prior) && status === "pending_review") {
toast(`CV ${run.trigger} is ready to review.`, "info");
}
if (activeRunLabels.has(prior) && status === "failed") {
toast(run.errorMessage || `CV ${run.trigger} failed.`, "error");
}
previous[run.id] = status;
}
}, [extractionRuns, toast]);
// Field-review lookup passed to the extracted sections. They stay decoupled from the full profile
// shape; the parent still owns structuredCv and the metadata source.
const metaFor = useCallback((path: string) => getStructuredCvFieldMetadata(structuredCv, path), [structuredCv]);
const initials = useMemo(() => initialsFrom([me?.displayName, me?.firstName, me?.lastName, me?.userName, me?.email]), [me]);
const isLocal = me?.provider === "local";
const fullName = [me?.firstName, me?.lastName].filter(Boolean).join(" ");
const cvWordCount = profileCvText.trim() ? profileCvText.trim().split(/\s+/).length : 0;
const providerLabel = me?.provider === "local" ? t("profileLocalAccount") : me?.provider === "google" ? t("profileGoogleSession") : t("profileExternalSession");
const googleLabel = me?.googleLink?.linked
? me.googleLink.email
? t("profileGoogleLinkedWithEmail", { email: me.googleLink.email })
: t("profileGoogleLinked")
: t("profileGoogleNotLinked");
const cvLabel = profileCvText.trim() ? t("profileCvReady", { count: cvWordCount }) : t("profileCvMissing");
const latestRun = extractionRuns[0];
return (
<Box sx={{ display: "grid", gap: 2 }}>
<CareerWorkspaceOverview completeness={completeness} runs={extractionRuns} loading={loading} loadError={loadError} />
<Paper id="career-profile-editor" 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 }}>
<ProfileCompleteness
completeness={completeness}
versions={versions}
loading={loading}
onRestore={(version) => void restoreVersion(version)}
showSummary={false}
/>
<CropImageDialog
open={cropOpen}
file={avatarFile}
onClose={() => {
setCropOpen(false);
setAvatarFile(null);
}}
onSave={async (blob) => {
const file = new File([blob], "avatar.png", { type: "image/png" });
const formData = new FormData();
formData.append("file", file);
setUploadingAvatar(true);
try {
const response = await api.post<{ avatarImageDataUrl?: string }>("/auth/avatar", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
setMe((prev) => (prev ? { ...prev, avatarImageDataUrl: response.data?.avatarImageDataUrl ?? prev.avatarImageDataUrl } : prev));
setCropOpen(false);
setAvatarFile(null);
toast(t("profileImageUpdated"), "success");
} catch (e: any) {
toast(String(e?.response?.data || e?.message || t("profileImageUploadFailed")), "error");
} finally {
setUploadingAvatar(false);
}
}}
/>
{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.
<Typography variant="body2" sx={{ mt: 0.5 }}>{loadError}</Typography>
</Alert>
) : null}
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 1 }}>
<Avatar src={me?.avatarImageDataUrl || undefined} sx={{ width: 84, height: 84, fontWeight: 900, fontSize: 28 }}>{initials}</Avatar>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", justifyContent: "center" }}>
<input
ref={avatarInputRef}
type="file"
accept={AVATAR_UPLOAD_ACCEPT}
style={{ display: "none" }}
onChange={(event) => {
const file = event.target.files?.[0] ?? null;
event.target.value = "";
if (!file) return;
setAvatarFile(file);
setCropOpen(true);
}}
/>
<Button variant="outlined" size="small" startIcon={<PhotoCameraOutlinedIcon />} disabled={!isLocal || uploadingAvatar} onClick={() => avatarInputRef.current?.click()}>
{uploadingAvatar ? t("profileUploading") : t("profileChangeImage")}
</Button>
{me?.avatarImageDataUrl ? (
<Button
variant="text"
size="small"
color="inherit"
startIcon={<DeleteOutlineIcon />}
disabled={!isLocal || uploadingAvatar}
onClick={async () => {
setUploadingAvatar(true);
try {
await api.delete("/auth/avatar");
setMe((prev) => (prev ? { ...prev, avatarImageDataUrl: undefined } : prev));
toast(t("profileImageRemoved"), "success");
} catch (e: any) {
toast(String(e?.response?.data || e?.message || t("profileImageRemoveFailed")), "error");
} finally {
setUploadingAvatar(false);
}
}}
>
{t("profileRemoveImage")}
</Button>
) : null}
</Box>
</Box>
<Box>
<Typography variant="h5" sx={{ fontWeight: 900 }}>
{careerOnly ? "Career profile" : t("profileTitle")}
</Typography>
<Typography sx={{ color: "text.secondary" }}>{me?.userName || me?.displayName || fullName || me?.email || "-"}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{headline || t("profileHeadlinePlaceholder")}</Typography>
</Box>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "flex-start" }}>
<Chip label={providerLabel} color={me?.provider === "local" ? "primary" : "default"} />
<Chip label={googleLabel} color={me?.googleLink?.linked ? "success" : "default"} variant={me?.googleLink?.linked ? "filled" : "outlined"} />
<Chip label={cvLabel} color={profileCvText.trim() ? "success" : "warning"} variant={profileCvText.trim() ? "filled" : "outlined"} />
</Box>
</Box>
<Box sx={{ mt: 3, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
<Box id="career-cv-import" sx={{ gridColumn: "1 / -1", p: { xs: 1.5, sm: 2 }, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default", display: careerOnly ? "block" : "none", scrollMarginTop: 96 }}>
{!canUseAi && <Alert severity="info" sx={{ mb: 2 }} action={<Button href="/settings" size="small">View Pro</Button>}>AI CV import, rebuilding, improvement, and reprocessing require Pro. Manual profile editing remains available.</Alert>}
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.5 }}>
<Box>
<Typography variant="h6">{t("profileMasterCv")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{t("profileMasterCvBody")}
</Typography>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<input
ref={cvInputRef}
type="file"
accept={CV_UPLOAD_ACCEPT}
style={{ display: "none" }}
onChange={async (event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
const formData = new FormData();
formData.append("file", file);
setUploadingCv(true);
try {
const res = await api.post<QueuedCvRunResponse>("/profile-cv/upload", formData, { headers: { "Content-Type": "multipart/form-data" } });
await loadProfile();
toast(`Queued CV upload (run ${res.data.extractionRunId}).`, "info");
} catch (e: any) {
toast(String(e?.response?.data || e?.message || t("profileCvUploadFailed")), "error");
} finally {
setUploadingCv(false);
}
}}
/>
<Button variant="outlined" disabled={!canUseAi || !isLocal || uploadingCv || improvingCv || rebuildingCv} onClick={() => cvInputRef.current?.click()}>
{uploadingCv ? t("profileUploading") : t("profileUploadCv")}
</Button>
<Button
variant="outlined"
disabled={!canUseAi || !isLocal || !profileCvText.trim() || uploadingCv || improvingCv || rebuildingCv}
onClick={async () => {
setRebuildingCv(true);
try {
const res = await api.post<QueuedCvRunResponse>("/profile-cv/rebuild");
await loadProfile();
toast(`Queued CV rebuild (run ${res.data.extractionRunId}).`, "info");
} catch (e: any) {
toast(String(e?.response?.data || e?.message || t("profileCvRebuildFailed")), "error");
} finally {
setRebuildingCv(false);
}
}}
>
{rebuildingCv ? t("profileCvRebuilding") : t("profileCvRebuild")}
</Button>
<Button
variant="outlined"
disabled={!canUseAi || !isLocal || !profileCvText.trim() || uploadingCv || improvingCv || rebuildingCv}
onClick={async () => {
setImprovingCv(true);
try {
const res = await api.post<QueuedCvRunResponse>("/profile-cv/improve");
await loadProfile();
toast(`Queued CV improve run (run ${res.data.extractionRunId}).`, "info");
} catch (e: any) {
toast(String(e?.response?.data || e?.message || t("profileCvImproveFailed")), "error");
} finally {
setImprovingCv(false);
}
}}
>
{improvingCv ? t("profileCvImproving") : t("profileCvImprove")}
</Button>
<Button
variant="outlined"
disabled={!canUseAi || !isLocal || uploadingCv || improvingCv || rebuildingCv || reprocessingCv || !latestRun}
onClick={async () => {
setReprocessingCv(true);
try {
const res = await api.post<QueuedCvRunResponse>("/profile-cv/reprocess");
await loadProfile();
toast(`Queued CV reprocess run (run ${res.data.extractionRunId}).`, "info");
} catch (e: any) {
toast(String(e?.response?.data || e?.message || t("profileCvReprocessFailed")), "error");
} finally {
setReprocessingCv(false);
}
}}
>
{reprocessingCv ? t("profileCvReprocessing") : t("profileCvReprocess")}
</Button>
</Box>
</Box>
{uploadingCv ? <LinearProgress sx={{ mb: 1.5 }} /> : null}
<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" } }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1.5, alignItems: "center", width: "100%", pr: 1 }}>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>{t("profileCvRawPanelTitle")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvRawPanelHelp")}</Typography>
</Box>
<Chip size="small" label={t("profileCvSectionWordCount", { count: cvWordCount })} />
</Box>
</AccordionSummary>
<AccordionDetails>
<TextField
label={t("profileCvTextLabel")}
value={profileCvText}
onChange={(e) => setProfileCvText(e.target.value)}
helperText={t("profileCvTextHelp")}
multiline
minRows={12}
disabled={!isLocal}
fullWidth
/>
<Box sx={{ mt: 1.5, display: "flex", justifyContent: "flex-end" }}>
<Button variant="text" disabled={!profileCvText.trim()} onClick={() => navigator.clipboard.writeText(profileCvText)}>
{t("profileCopyCvText")}
</Button>
</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 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>
{latestRun ? (
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.25 }}>
{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 sx={{ display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap", alignItems: "center", mb: 0.75 }}>
<Typography variant="overline">{run.trigger}</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"} />
{run.id === structuredCv.metadata.appliedExtractionRunId ? <Chip size="small" color="primary" label={t("profileCvCurrentRun")} /> : null}
</Box>
</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()}
</Typography>
{run.errorMessage ? (
<Typography variant="caption" sx={{ color: "error.main", display: "block", mt: 0.75 }}>
{run.errorMessage}
</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 loadProfile();
toast("CV processing cancellation requested.", "info");
} catch (error) {
toast(getApiErrorMessage(error, "Could not cancel CV processing."), "error");
}
}}>Cancel processing</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 loadProfile();
toast("CV processing queued again.", "info");
} catch (error) {
toast(getApiErrorMessage(error, "Could not retry CV processing."), "error");
}
}}>Retry processing</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` : ""}
</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"}
</Typography>
</>
) : <LinearProgress 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) => ({
...current,
[run.id]: event.target.checked
? [...(current[run.id] ?? []), change.id]
: (current[run.id] ?? []).filter((id) => id !== change.id),
}))} />}
label={`Include low-confidence ${change.category}: ${change.label}`}
/>
))}
<Box sx={{ display: "flex", gap: 1, mt: 1, flexWrap: "wrap" }}>
<Button size="small" variant="contained" disabled={!runDiffs[run.id] || reviewingRunId !== null} 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(), loadVersions()]);
toast("CV changes merged into your career profile.", "success");
} catch (error) {
toast(getApiErrorMessage(error, "Could not apply CV changes."), "error");
} finally { setReviewingRunId(null); }
}}>Apply changes</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 loadProfile();
toast("CV extraction discarded.", "info");
} catch (error) {
toast(getApiErrorMessage(error, "Could not discard CV extraction."), "error");
} finally { setReviewingRunId(null); }
}}>Discard</Button>
</Box>
</Box>
) : null}
</Box>
))}
</Box>
) : (
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvExtractionHistoryEmpty")}</Typography>
)}
</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"}
</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={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.5 }}>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>{t("profileCvStructureOverview")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructureOverviewHelp")}</Typography>
</Box>
<Button
variant="outlined"
disabled={!isLocal || !profileCvText.trim() || parsingCvSections}
onClick={async () => {
setParsingCvSections(true);
try {
const res = await api.post<{ structuredCv?: StructuredCvProfile }>("/profile-cv/parse", { text: profileCvText });
setStructuredCv(normalizeStructuredCv(res.data?.structuredCv));
toast(t("profileCvStructureParsed"), "success");
} catch (e: any) {
toast(String(e?.response?.data || e?.message || t("profileCvStructureParseFailed")), "error");
} finally {
setParsingCvSections(false);
}
}}
>
{parsingCvSections ? t("profileCvStructureParsing") : t("profileCvStructureParse")}
</Button>
</Box>
{structuredCv.sections.length > 0 ? (
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
{structuredCv.sections.map((section) => {
const safeContent = typeof section.content === "string" ? section.content : "";
const safeWordCount = Number.isFinite(Number(section.wordCount)) ? Number(section.wordCount) : (safeContent.trim() ? safeContent.trim().split(/\s+/).length : 0);
return (
<Box key={section.name} sx={{ p: 1.25, borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 0.75 }}>
<Typography variant="overline">{section.name}</Typography>
<Chip size="small" label={t("profileCvSectionWordCount", { count: safeWordCount })} />
</Box>
<Typography variant="body2" sx={{ color: "text.secondary", whiteSpace: "pre-wrap" }}>{safeContent.slice(0, 280)}{safeContent.length > 280 ? "…" : ""}</Typography>
</Box>
);
})}
</Box>
) : (
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructureEmpty")}</Typography>
)}
</Box>
<Box sx={{ mt: 2, p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
<Box sx={{ mb: 1.5 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>{t("profileCvStructuredEditor")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructuredEditorHelp")}</Typography>
</Box>
<PersonalInformationSection
value={structuredCv.contact}
onChange={(next) => setStructuredCv((prev) => ({ ...prev, contact: next }))}
getMetadata={metaFor}
/>
<Box sx={{ mt: 2, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
<ProfessionalSummarySection value={structuredCv.summary} onChange={(next) => setStructuredCv((prev) => ({ ...prev, summary: next }))} getMetadata={metaFor} />
<SkillsSection value={structuredCv.skills} onChange={(next) => setStructuredCv((prev) => ({ ...prev, skills: next }))} getMetadata={metaFor} />
<InterestsSection value={structuredCv.interests} onChange={(next) => setStructuredCv((prev) => ({ ...prev, interests: next }))} getMetadata={metaFor} />
</Box>
<LongTailSections values={{ awards: structuredCv.awards, publications: structuredCv.publications, organisations: structuredCv.organisations, references: structuredCv.references }} onChange={(key, next) => setStructuredCv((prev) => ({ ...prev, [key]: next }))} />
<LanguagesSection value={structuredCv.languages} onChange={(next) => setStructuredCv((prev) => ({ ...prev, languages: next }))} getMetadata={metaFor} />
<WorkExperienceSection value={structuredCv.jobs} onChange={(next) => setStructuredCv((prev) => ({ ...prev, jobs: next }))} />
<EducationSection value={structuredCv.education} onChange={(next) => setStructuredCv((prev) => ({ ...prev, education: next }))} />
<OtherSectionsSection value={structuredCv.otherSections} onChange={(next) => setStructuredCv((prev) => ({ ...prev, otherSections: next }))} />
</Box>
<Box sx={{ mt: 1, display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{cvWordCount} words
</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{t("profileCvPreferredUploads")}
</Typography>
</Box>
</Box>
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end", gap: 2, flexWrap: "wrap", alignItems: "center" }}>
<Button
variant="contained"
disabled={!isLocal || loading}
onClick={async () => {
setLoading(true);
try {
// Save the master profile through the relational source of truth. The endpoint
// persists the structured children + version and keeps the legacy blob projection
// in sync; identity fields are never touched (they belong to /profile).
const saved = await api.put<CareerProfileResponse>("/career/profile", { profile: structuredCv, cvText: profileCvText });
setStructuredCv(normalizeStructuredCv(saved.data?.profile ?? structuredCv));
setCompleteness(saved.data?.completeness ?? null);
toast(t("profileUpdated"), "success");
} catch (e: any) {
const msg = e?.response?.data || e?.message || t("profileUpdateFailed");
toast(String(msg), "error");
} finally {
setLoading(false);
}
}}
>
{t("profileSaveChanges")}
</Button>
</Box>
</Box>
</Paper>
</Box>
);
}