1027 lines
58 KiB
TypeScript
1027 lines
58 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
import { Accordion, AccordionDetails, AccordionSummary, Alert, Avatar, Box, Button, Checkbox, Chip, FormControl, FormControlLabel, InputLabel, LinearProgress, MenuItem, Paper, Select, Tab, Tabs, 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 UploadFileOutlinedIcon from "@mui/icons-material/UploadFileOutlined";
|
|
|
|
import { api, getApiErrorMessage } from "../api";
|
|
import CropImageDialog from "../components/CropImageDialog";
|
|
import ProfileCompleteness from "./career/ProfileCompleteness";
|
|
import CareerWorkspaceOverview from "./career/CareerWorkspaceOverview";
|
|
import CareerEvidenceBank from "./career/CareerEvidenceBank";
|
|
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 { TranslationKey } from "../i18n/translations";
|
|
import ProFeatureNotice from "../components/ProFeatureNotice";
|
|
import { PageSection } from "../layout/PageLayout";
|
|
import type { UserOperation } from "../types";
|
|
import {
|
|
emptyStructuredCv,
|
|
getStructuredCvFieldMetadata,
|
|
normalizeStructuredCv,
|
|
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 CvImportChange = { id: string; label: string; confidence: string; fieldChanges?: Array<{ field: string; oldValue?: string | null; newValue?: string | null }> };
|
|
type CvImportDiff = {
|
|
totalAdded: number;
|
|
totalUpdated: number;
|
|
totalLowConfidence: number;
|
|
hasChanges: boolean;
|
|
categories: Array<{
|
|
category: string;
|
|
added: CvImportChange[];
|
|
updated: CvImportChange[];
|
|
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;
|
|
}
|
|
}
|
|
|
|
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;
|
|
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 };
|
|
type CareerWorkspaceSection = "overview" | "profile" | "evidence" | "import";
|
|
|
|
function initialWorkspaceSection(): CareerWorkspaceSection {
|
|
if (typeof window === "undefined") return "overview";
|
|
const section = new URLSearchParams(window.location.search).get("section");
|
|
return section === "profile" || section === "evidence" || section === "import" ? section : "overview";
|
|
}
|
|
|
|
// 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 { language, t } = useI18n();
|
|
const translationRef = useRef(t);
|
|
useEffect(() => { translationRef.current = t; }, [t]);
|
|
const cvInputRef = useRef<HTMLInputElement | null>(null);
|
|
const avatarInputRef = useRef<HTMLInputElement | null>(null);
|
|
const [me, setMe] = useState<MeResponse | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [loadError, setLoadError] = useState<string | null>(null);
|
|
const [saving, setSaving] = useState(false);
|
|
const [saveError, setSaveError] = useState<string | null>(null);
|
|
const profileMutationPending = useRef(false);
|
|
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 [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);
|
|
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");
|
|
else url.searchParams.set("section", next);
|
|
window.history.replaceState(window.history.state, "", `${url.pathname}${url.search}${url.hash}`);
|
|
}, []);
|
|
|
|
const loadVersions = useCallback(async () => {
|
|
const readId = ++versionsReadId.current;
|
|
setVersionsLoading(true);
|
|
try {
|
|
const r = await api.get<CareerVersion[]>("/career/profile/versions");
|
|
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 || 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 (error) {
|
|
const message = getApiErrorMessage(error, "");
|
|
setRestoreError(message);
|
|
toast(message || translationRef.current("careerRestoreFailed"), "error");
|
|
} finally {
|
|
profileMutationPending.current = false;
|
|
setLoading(false);
|
|
}
|
|
}, [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);
|
|
}, []);
|
|
|
|
const loadExtractionRuns = useCallback(async () => {
|
|
const readId = ++runsReadId.current;
|
|
setRunsLoading(true);
|
|
try {
|
|
const response = await api.get<ExtractionRun[]>("/profile-cv/runs");
|
|
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);
|
|
}
|
|
}, []);
|
|
|
|
const queueCvUpload = useCallback(async (file: File) => {
|
|
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 loadExtractionRuns();
|
|
toast(t("profileCvQueuedUpload", { id: res.data.extractionRunId }), "info");
|
|
} catch (error) {
|
|
toast(getApiErrorMessage(error, t("profileCvUploadFailed")), "error");
|
|
} finally {
|
|
setUploadingCv(false);
|
|
}
|
|
}, [loadExtractionRuns, t, toast]);
|
|
|
|
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] = await Promise.all([
|
|
api.get<CareerProfileResponse>("/career/profile"),
|
|
api.get<MeResponse>("/auth/me"),
|
|
]);
|
|
setMe(meResponse.data);
|
|
setProfileCvText(careerResponse.data?.cvText ?? "");
|
|
const profile = normalizeStructuredCv(careerResponse.data?.profile ?? emptyStructuredCv());
|
|
setStructuredCv(profile);
|
|
setCompleteness(careerResponse.data?.completeness ?? null);
|
|
setProfileDirty(false);
|
|
setHeadline(profile.contact.headline ?? "");
|
|
setLoadError(null);
|
|
} catch (error) {
|
|
setMe(null);
|
|
setLoadError(getApiErrorMessage(error, ""));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void loadProfile();
|
|
void loadExtractionRuns();
|
|
void loadVersions();
|
|
}, [loadExtractionRuns, 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 loadExtractionRuns();
|
|
}, 4000);
|
|
|
|
return () => window.clearInterval(timer);
|
|
}, [extractionRuns, loadExtractionRuns]);
|
|
|
|
useEffect(() => {
|
|
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;
|
|
for (const run of extractionRuns) {
|
|
const status = cvRunStatus(run);
|
|
const prior = previous[run.id];
|
|
if (activeRunLabels.has(prior) && status === "pending_review") {
|
|
toast(t("profileCvRunReady", { trigger: run.trigger }), "info");
|
|
}
|
|
if (activeRunLabels.has(prior) && status === "failed") {
|
|
toast(run.errorMessage || t("profileCvRunFailed", { trigger: run.trigger }), "error");
|
|
}
|
|
previous[run.id] = status;
|
|
}
|
|
}, [extractionRuns, t, 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 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;
|
|
|
|
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];
|
|
const activeCvRun = extractionRuns.find((run) => run.operation
|
|
? activeOperationStatuses.has(run.operation.status)
|
|
: 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, "& .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>
|
|
<Select
|
|
labelId="career-workspace-section-label"
|
|
value={workspaceSection}
|
|
label={t("careerWorkspaceNavigation")}
|
|
onChange={(event) => navigateWorkspace(event.target.value as CareerWorkspaceSection)}
|
|
>
|
|
<MenuItem value="overview">{t("careerWorkspaceOverviewTab")}</MenuItem>
|
|
<MenuItem value="profile">{t("careerWorkspaceProfileTab")}</MenuItem>
|
|
<MenuItem value="evidence">{t("careerWorkspaceEvidenceTab")}</MenuItem>
|
|
<MenuItem value="import">{t("careerWorkspaceImportTab")}</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
<Tabs
|
|
value={workspaceSection}
|
|
onChange={(_, value: CareerWorkspaceSection) => navigateWorkspace(value)}
|
|
aria-label={t("careerWorkspaceNavigation")}
|
|
sx={{ display: { xs: "none", sm: "flex" }, px: 1 }}
|
|
>
|
|
<Tab value="overview" label={t("careerWorkspaceOverviewTab")} />
|
|
<Tab value="profile" label={t("careerWorkspaceProfileTab")} />
|
|
<Tab value="evidence" label={t("careerWorkspaceEvidenceTab")} />
|
|
<Tab value="import" label={t("careerWorkspaceImportTab")} />
|
|
</Tabs>
|
|
</Paper>
|
|
|
|
{workspaceSection === "overview" ? (
|
|
<CareerWorkspaceOverview
|
|
completeness={completeness}
|
|
runs={extractionRuns}
|
|
loading={loading || saving || (reviewingRunId !== null && reviewAction === "accept")}
|
|
loadError={loadErrorMessage}
|
|
runsLoading={runsLoading}
|
|
runsLoadError={runsLoadError}
|
|
onRetryRuns={() => void loadExtractionRuns()}
|
|
onRetryProfile={() => { if (!profileDirty && !profileMutationPending.current) void loadProfile(); }}
|
|
profileDirty={profileDirty}
|
|
onNavigate={navigateWorkspace}
|
|
/>
|
|
) : null}
|
|
|
|
{evidenceVisited ? <Box hidden={workspaceSection !== "evidence"}><CareerEvidenceBank /></Box> : null}
|
|
|
|
{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"
|
|
accept={CV_UPLOAD_ACCEPT}
|
|
style={{ display: "none" }}
|
|
onChange={(event) => {
|
|
const file = event.target.files?.[0];
|
|
event.target.value = "";
|
|
if (file) void queueCvUpload(file);
|
|
}}
|
|
/>
|
|
{workspaceSection === "profile" ? <ProfileCompleteness
|
|
completeness={completeness}
|
|
versions={versions}
|
|
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}
|
|
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);
|
|
}
|
|
}}
|
|
/> : null}
|
|
|
|
{loadErrorMessage ? (
|
|
<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>
|
|
) : null}
|
|
|
|
{workspaceSection === "profile" ? <Paper variant="outlined" sx={{ mb: 2.5, p: { xs: 1.5, sm: 2 }, borderRadius: 3 }}>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
|
|
<Box sx={{ minWidth: 0 }}>
|
|
<Typography component="h2" variant="subtitle1" sx={{ fontWeight: 800 }}>{t("careerProfileImportTitle")}</Typography>
|
|
<Typography variant="body2" color="text.secondary">{t("careerProfileImportBody")}</Typography>
|
|
</Box>
|
|
<Button
|
|
variant="outlined"
|
|
startIcon={<UploadFileOutlinedIcon />}
|
|
disabled={!canUseAi || editorDisabled || uploadingCv || Boolean(activeCvRun)}
|
|
onClick={() => cvInputRef.current?.click()}
|
|
>
|
|
{uploadingCv ? t("profileUploading") : t("profileUploadCv")}
|
|
</Button>
|
|
</Box>
|
|
{uploadingCv || activeCvRun ? <Box sx={{ mt: 1.5 }}>
|
|
<LinearProgress
|
|
variant={typeof extractionProgress === "number" ? "determinate" : "indeterminate"}
|
|
value={extractionProgress ?? undefined}
|
|
aria-label={t("careerProfileImportProgress")}
|
|
sx={{ height: 8, borderRadius: 999 }}
|
|
/>
|
|
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 0.75 }}>
|
|
{typeof extractionProgress === "number"
|
|
? t("careerProfileImportPercent", { percent: extractionProgress })
|
|
: t("careerProfileImportProcessing")}
|
|
</Typography>
|
|
</Box> : null}
|
|
{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 }}><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" }}>
|
|
<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, bgcolor: "primary.main", color: "primary.contrastText" }}>{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 component="h2" variant="h5" sx={{ fontWeight: 900 }}>
|
|
{careerOnly ? t("careerWorkspaceProfileTab") : 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> : null}
|
|
|
|
|
|
<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", minWidth: 0, display: careerOnly ? "block" : "none" }}>
|
|
<Box sx={{ display: workspaceSection === "import" ? "block" : "none" }}>
|
|
{!canUseAi && (
|
|
<Box sx={{ mb: 2 }}>
|
|
<ProFeatureNotice featureKey="career-ai" title={t("profileProImportTitle")}>
|
|
{t("profileProImportBody")}
|
|
</ProFeatureNotice>
|
|
</Box>
|
|
)}
|
|
{profileDirty ? <Alert severity="warning" sx={{ mb: 2 }}>{t("profileUnsavedBeforeActions")}</Alert> : null}
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.5 }}>
|
|
<Box>
|
|
<Typography component="h2" variant="h6">{t("profileMasterCv")}</Typography>
|
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
|
{t("profileMasterCvBody")}
|
|
</Typography>
|
|
</Box>
|
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
|
<Button variant="outlined" disabled={!canUseAi || editorDisabled || uploadingCv || improvingCv || rebuildingCv} onClick={() => cvInputRef.current?.click()}>
|
|
{uploadingCv ? t("profileUploading") : t("profileUploadCv")}
|
|
</Button>
|
|
<Button
|
|
variant="outlined"
|
|
disabled={!canUseAi || editorDisabled || profileDirty || !profileCvText.trim() || uploadingCv || improvingCv || rebuildingCv}
|
|
onClick={async () => {
|
|
setRebuildingCv(true);
|
|
try {
|
|
const res = await api.post<QueuedCvRunResponse>("/profile-cv/rebuild");
|
|
await loadExtractionRuns();
|
|
toast(t("profileCvQueuedRebuild", { id: 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 || editorDisabled || profileDirty || !profileCvText.trim() || uploadingCv || improvingCv || rebuildingCv}
|
|
onClick={async () => {
|
|
setImprovingCv(true);
|
|
try {
|
|
const res = await api.post<QueuedCvRunResponse>("/profile-cv/improve");
|
|
await loadExtractionRuns();
|
|
toast(t("profileCvQueuedImprove", { id: 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 || editorDisabled || uploadingCv || improvingCv || rebuildingCv || reprocessingCv || !latestRun}
|
|
onClick={async () => {
|
|
setReprocessingCv(true);
|
|
try {
|
|
const res = await api.post<QueuedCvRunResponse>("/profile-cv/reprocess");
|
|
await loadExtractionRuns();
|
|
toast(t("profileCvQueuedReprocess", { id: 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: 1, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper", "&:before": { display: "none" } }}>
|
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
|
<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>
|
|
</Box>
|
|
<Chip size="small" label={t("profileCvSectionWordCount", { count: cvWordCount })} />
|
|
</Box>
|
|
</AccordionSummary>
|
|
<AccordionDetails>
|
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>{t("careerImportRawSourceHelp")}</Typography>
|
|
<TextField
|
|
label={t("profileCvTextLabel")}
|
|
value={profileCvText}
|
|
onChange={(e) => { setProfileCvText(e.target.value); setProfileDirty(true); }}
|
|
helperText={t("profileCvTextHelp")}
|
|
multiline
|
|
minRows={12}
|
|
disabled={editorDisabled}
|
|
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>
|
|
<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: "minmax(0, 1fr)", gap: 1.5, minWidth: 0 }}>
|
|
{extractionRuns.map((run) => (
|
|
<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 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={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>
|
|
<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(language === "nb" ? "nb-NO" : "en")}
|
|
</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" 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" 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] ? (
|
|
<>
|
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
|
{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="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>
|
|
))}
|
|
</>
|
|
) : 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, 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: importLabel(change.category), label: importChangeLabel(change, change.category) })}
|
|
/>
|
|
))}
|
|
{profileDirty ? (
|
|
<Alert severity="warning" sx={{ mt: 1 }}>
|
|
{t("profileCvSaveBeforeImport")}
|
|
</Alert>
|
|
) : null}
|
|
<Box sx={{ display: "flex", gap: 1, mt: 1, flexWrap: "wrap" }}>
|
|
<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>
|
|
) : 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: "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>
|
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructureOverviewHelp")}</Typography>
|
|
</Box>
|
|
<Button
|
|
variant="outlined"
|
|
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);
|
|
}
|
|
}}
|
|
>
|
|
{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>
|
|
<Box sx={{ mt: workspaceSection === "profile" ? 2 : 0, display: workspaceSection === "profile" ? "block" : "none" }}>
|
|
<PageSection title={t("profileCvStructuredEditor")} description={t("profileCvStructuredEditorHelp")} surface>
|
|
|
|
<PersonalInformationSection
|
|
value={structuredCv.contact}
|
|
onChange={(next) => editStructuredCv((prev) => ({ ...prev, contact: next }))}
|
|
getMetadata={metaFor}
|
|
disabled={editorDisabled}
|
|
/>
|
|
|
|
<Box sx={{ mt: 2, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
|
|
<ProfessionalSummarySection value={structuredCv.summary} onChange={(next) => editStructuredCv((prev) => ({ ...prev, summary: next }))} getMetadata={metaFor} disabled={editorDisabled} />
|
|
<SkillsSection value={structuredCv.skills} onChange={(next) => editStructuredCv((prev) => ({ ...prev, skills: next }))} getMetadata={metaFor} disabled={editorDisabled} />
|
|
<InterestsSection value={structuredCv.interests} onChange={(next) => editStructuredCv((prev) => ({ ...prev, interests: next }))} getMetadata={metaFor} disabled={editorDisabled} />
|
|
</Box>
|
|
|
|
<LongTailSections values={{ awards: structuredCv.awards, publications: structuredCv.publications, organisations: structuredCv.organisations, references: structuredCv.references }} onChange={(key, next) => editStructuredCv((prev) => ({ ...prev, [key]: next }))} disabled={editorDisabled} />
|
|
|
|
<LanguagesSection value={structuredCv.languages} onChange={(next) => editStructuredCv((prev) => ({ ...prev, languages: next }))} getMetadata={metaFor} disabled={editorDisabled} />
|
|
|
|
<WorkExperienceSection value={structuredCv.jobs} onChange={(next) => editStructuredCv((prev) => ({ ...prev, jobs: next }))} disabled={editorDisabled} />
|
|
|
|
<EducationSection value={structuredCv.education} onChange={(next) => editStructuredCv((prev) => ({ ...prev, education: next }))} disabled={editorDisabled} />
|
|
|
|
<OtherSectionsSection value={structuredCv.otherSections} onChange={(next) => editStructuredCv((prev) => ({ ...prev, otherSections: next }))} disabled={editorDisabled} />
|
|
</PageSection>
|
|
</Box>
|
|
<Box sx={{ mt: 1, display: workspaceSection === "profile" ? "flex" : "none", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
|
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
|
{t("profileCvSectionWordCount", { count: cvWordCount })}
|
|
</Typography>
|
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
|
{t("profileCvPreferredUploads")}
|
|
</Typography>
|
|
</Box>
|
|
</Box>
|
|
|
|
<Box sx={{ gridColumn: "1 / -1", display: workspaceSection === "profile" ? "flex" : "none", justifyContent: "flex-end", gap: 2, flexWrap: "wrap", alignItems: "center" }}>
|
|
<Typography variant="body2" color="text.secondary" sx={{ width: "100%" }}>{t("profileCvStructuredEditorHelp")}</Typography>
|
|
{saveError !== null ? <Alert severity="error" sx={{ width: "100%" }}>
|
|
{t("profileUpdateFailed")}
|
|
{saveError ? <Typography variant="body2" sx={{ mt: 0.5 }}>{saveError}</Typography> : null}
|
|
</Alert> : null}
|
|
{profileDirty ? <Chip size="small" color="warning" variant="outlined" label={t("assetsUnsavedChanges")} /> : null}
|
|
<Button
|
|
variant="contained"
|
|
disabled={editorDisabled}
|
|
onClick={async () => {
|
|
if (profileMutationPending.current || editorDisabled) return;
|
|
profileMutationPending.current = true;
|
|
setSaving(true);
|
|
setSaveError(null);
|
|
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);
|
|
setProfileDirty(false);
|
|
invalidateImportComparisons();
|
|
void loadVersions();
|
|
toast(t("profileUpdated"), "success");
|
|
} catch (error) {
|
|
const message = getApiErrorMessage(error, "");
|
|
setSaveError(message);
|
|
toast(message || translationRef.current("profileUpdateFailed"), "error");
|
|
} finally {
|
|
profileMutationPending.current = false;
|
|
setSaving(false);
|
|
}
|
|
}}
|
|
>
|
|
{saving ? t("saving") : t("profileSaveChanges")}
|
|
</Button>
|
|
</Box>
|
|
|
|
|
|
</Box>
|
|
|
|
</Paper> : null}
|
|
</Box>
|
|
);
|
|
}
|