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) { 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(null); const avatarInputRef = useRef(null); const [me, setMe] = useState(null); const [loading, setLoading] = useState(false); const [loadError, setLoadError] = useState(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(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(emptyStructuredCv()); const [completeness, setCompleteness] = useState(null); const [versions, setVersions] = useState([]); // 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("/career/profile/versions"); setVersions(r.data ?? []); } catch { setVersions([]); } }, []); const restoreVersion = useCallback(async (version: number) => { setLoading(true); try { const r = await api.post(`/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([]); const [runDiffs, setRunDiffs] = useState>({}); const [reviewingRunId, setReviewingRunId] = useState(null); const [acceptedLowConfidenceIds, setAcceptedLowConfidenceIds] = useState>({}); const runStatusRef = useRef>({}); 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("/career/profile"), api.get("/auth/me"), api.get("/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(`/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 ( void restoreVersion(version)} showSummary={false} /> { 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 ? ( void loadProfile()}>Retry}> Unable to load profile. {loadError} ) : null} {initials} { const file = event.target.files?.[0] ?? null; event.target.value = ""; if (!file) return; setAvatarFile(file); setCropOpen(true); }} /> {me?.avatarImageDataUrl ? ( ) : null} {careerOnly ? "Career profile" : t("profileTitle")} {me?.userName || me?.displayName || fullName || me?.email || "-"} {headline || t("profileHeadlinePlaceholder")} {!canUseAi && View Pro}>AI CV import, rebuilding, improvement, and reprocessing require Pro. Manual profile editing remains available.} {t("profileMasterCv")} {t("profileMasterCvBody")} { 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("/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); } }} /> {uploadingCv ? : null} {t("profileCvStructuredDefaultHint")} }> {t("profileCvRawPanelTitle")} {t("profileCvRawPanelHelp")} setProfileCvText(e.target.value)} helperText={t("profileCvTextHelp")} multiline minRows={12} disabled={!isLocal} fullWidth /> {t("profileCvExtractionHistory")} {t("profileCvExtractionHistoryHelp")} {structuredCv.metadata.profileVersion ? : null} {latestRun ? ( {extractionRuns.map((run) => ( {run.trigger} {run.id === structuredCv.metadata.appliedExtractionRunId ? : null} {run.artifactFileName || t("profileCvNoStoredArtifact")} {run.parserVersion} · {new Date(run.startedAtUtc).toLocaleString()} {run.errorMessage ? ( {run.errorMessage} ) : null} {run.operation?.canCancel ? ( ) : null} {run.operation?.canRetry ? ( ) : null} {run.status === "pending_review" ? ( {runDiffs[run.id] ? ( <> {runDiffs[run.id].totalAdded} additions | {runDiffs[run.id].totalUpdated} updates {runDiffs[run.id].totalLowConfidence ? ` | ${runDiffs[run.id].totalLowConfidence} need attention` : ""} {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"} ) : } {runDiffs[run.id]?.categories.flatMap((category) => [...category.added, ...category.updated].map((change) => ({ ...change, category: category.category }))).filter((change) => change.confidence === "Low").map((change) => ( 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}`} /> ))} ) : null} ))} ) : ( {t("profileCvExtractionHistoryEmpty")} )} {t("profileCvStructureOverview")} {t("profileCvStructureOverviewHelp")} {structuredCv.sections.length > 0 ? ( {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 ( {section.name} {safeContent.slice(0, 280)}{safeContent.length > 280 ? "…" : ""} ); })} ) : ( {t("profileCvStructureEmpty")} )} {t("profileCvStructuredEditor")} {t("profileCvStructuredEditorHelp")} setStructuredCv((prev) => ({ ...prev, contact: next }))} getMetadata={metaFor} /> setStructuredCv((prev) => ({ ...prev, summary: next }))} getMetadata={metaFor} /> setStructuredCv((prev) => ({ ...prev, skills: next }))} getMetadata={metaFor} /> setStructuredCv((prev) => ({ ...prev, interests: next }))} getMetadata={metaFor} /> setStructuredCv((prev) => ({ ...prev, [key]: next }))} /> setStructuredCv((prev) => ({ ...prev, languages: next }))} getMetadata={metaFor} /> setStructuredCv((prev) => ({ ...prev, jobs: next }))} /> setStructuredCv((prev) => ({ ...prev, education: next }))} /> setStructuredCv((prev) => ({ ...prev, otherSections: next }))} /> {cvWordCount} words {t("profileCvPreferredUploads")} ); }