9edcbfc5de
Make job/CV comparisons language-aware and filter recruitment noise. Improve responsive career navigation, shared spacing, dashboard priorities, settings, localized workspace controls, and portable browser tests.
840 lines
43 KiB
TypeScript
840 lines
43 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 { 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 ProFeatureNotice from "../components/ProFeatureNotice";
|
|
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 };
|
|
type CareerWorkspaceSection = "overview" | "profile" | "import";
|
|
|
|
function initialWorkspaceSection(): CareerWorkspaceSection {
|
|
if (typeof window === "undefined") return "overview";
|
|
const section = new URLSearchParams(window.location.search).get("section");
|
|
return section === "profile" || 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 { 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 [profileDirty, setProfileDirty] = useState(false);
|
|
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 [workspaceSection, setWorkspaceSection] = useState<CareerWorkspaceSection>(initialWorkspaceSection);
|
|
|
|
const navigateWorkspace = useCallback((next: CareerWorkspaceSection) => {
|
|
setWorkspaceSection(next);
|
|
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 () => {
|
|
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);
|
|
setProfileDirty(false);
|
|
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 editStructuredCv = useCallback((next: React.SetStateAction<StructuredCvProfile>) => {
|
|
setStructuredCv(next);
|
|
setProfileDirty(true);
|
|
}, []);
|
|
|
|
const loadExtractionRuns = useCallback(async () => {
|
|
try {
|
|
const response = await api.get<ExtractionRun[]>("/profile-cv/runs");
|
|
setExtractionRuns(response.data ?? []);
|
|
} catch {
|
|
// Polling failure must not clear existing run state or touch unsaved profile edits.
|
|
}
|
|
}, []);
|
|
|
|
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: any) {
|
|
setMe(null);
|
|
setLoadError(String(error?.response?.data || error?.message || "Unable to load profile right now."));
|
|
} 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(() => {
|
|
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 }}>
|
|
<Paper component="nav" aria-label={t("careerWorkspaceNavigation")} variant="outlined" sx={{ borderRadius: 3, overflow: "hidden" }}>
|
|
<FormControl size="small" fullWidth sx={{ display: { xs: "flex", sm: "none" }, p: 1.25 }}>
|
|
<InputLabel id="career-workspace-section-label" sx={{ ml: 1.25 }}>{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="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="import" label={t("careerWorkspaceImportTab")} />
|
|
</Tabs>
|
|
</Paper>
|
|
|
|
{workspaceSection === "overview" ? (
|
|
<CareerWorkspaceOverview
|
|
completeness={completeness}
|
|
runs={extractionRuns}
|
|
loading={loading}
|
|
loadError={loadError}
|
|
onNavigate={navigateWorkspace}
|
|
/>
|
|
) : null}
|
|
|
|
{workspaceSection !== "overview" ? <Paper id={workspaceSection === "profile" ? "career-profile-editor" : "career-cv-import"} sx={{ mt: 0, p: { xs: 1.5, sm: 2.5 }, borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", scrollMarginTop: 96 }}>
|
|
{workspaceSection === "profile" ? <ProfileCompleteness
|
|
completeness={completeness}
|
|
versions={versions}
|
|
loading={loading}
|
|
onRestore={(version) => void restoreVersion(version)}
|
|
showSummary={false}
|
|
/> : 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}
|
|
|
|
{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}
|
|
|
|
{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 }}>{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 ? 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: 3, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
|
|
|
|
|
<Box sx={{ gridColumn: "1 / -1", p: workspaceSection === "import" ? { xs: 1.5, sm: 2 } : 0, borderRadius: 3, border: workspaceSection === "import" ? "1px solid" : "none", borderColor: "divider", backgroundColor: workspaceSection === "import" ? "background.default" : "transparent", display: careerOnly ? "block" : "none" }}>
|
|
<Box sx={{ display: workspaceSection === "import" ? "block" : "none" }}>
|
|
{!canUseAi && (
|
|
<Box sx={{ mb: 2 }}>
|
|
<ProFeatureNotice featureKey="career-ai" title="Build your Career Profile faster with Pro.">
|
|
Import, rebuild and improve CV content with AI. Manual profile editing remains available on Free.
|
|
</ProFeatureNotice>
|
|
</Box>
|
|
)}
|
|
{profileDirty ? <Alert severity="warning" sx={{ mb: 2 }}>You have unsaved career edits. Save them before running actions that use the stored profile.</Alert> : null}
|
|
<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 loadExtractionRuns();
|
|
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 || profileDirty || !profileCvText.trim() || uploadingCv || improvingCv || rebuildingCv}
|
|
onClick={async () => {
|
|
setRebuildingCv(true);
|
|
try {
|
|
const res = await api.post<QueuedCvRunResponse>("/profile-cv/rebuild");
|
|
await loadExtractionRuns();
|
|
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 || profileDirty || !profileCvText.trim() || uploadingCv || improvingCv || rebuildingCv}
|
|
onClick={async () => {
|
|
setImprovingCv(true);
|
|
try {
|
|
const res = await api.post<QueuedCvRunResponse>("/profile-cv/improve");
|
|
await loadExtractionRuns();
|
|
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 loadExtractionRuns();
|
|
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); setProfileDirty(true); }}
|
|
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 loadExtractionRuns();
|
|
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 loadExtractionRuns();
|
|
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}`}
|
|
/>
|
|
))}
|
|
{profileDirty ? (
|
|
<Alert severity="warning" sx={{ mt: 1 }}>
|
|
Save your current career-profile edits before applying imported changes.
|
|
</Alert>
|
|
) : null}
|
|
<Box sx={{ display: "flex", gap: 1, mt: 1, flexWrap: "wrap" }}>
|
|
<Button size="small" variant="contained" disabled={!runDiffs[run.id] || reviewingRunId !== null || profileDirty} onClick={async () => {
|
|
setReviewingRunId(run.id);
|
|
try {
|
|
await api.post(`/profile-cv/runs/${run.id}/accept`, { acceptedLowConfidenceIds: acceptedLowConfidenceIds[run.id] ?? [] });
|
|
setRunDiffs((current) => { const next = { ...current }; delete next[run.id]; return next; });
|
|
await Promise.all([loadProfile(), loadExtractionRuns(), loadVersions()]);
|
|
toast("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 loadExtractionRuns();
|
|
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 });
|
|
editStructuredCv(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>
|
|
<Box sx={{ mt: workspaceSection === "profile" ? 2 : 0, p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper", display: workspaceSection === "profile" ? "block" : "none" }}>
|
|
<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) => editStructuredCv((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) => editStructuredCv((prev) => ({ ...prev, summary: next }))} getMetadata={metaFor} />
|
|
<SkillsSection value={structuredCv.skills} onChange={(next) => editStructuredCv((prev) => ({ ...prev, skills: next }))} getMetadata={metaFor} />
|
|
<InterestsSection value={structuredCv.interests} onChange={(next) => editStructuredCv((prev) => ({ ...prev, interests: next }))} getMetadata={metaFor} />
|
|
</Box>
|
|
|
|
<LongTailSections values={{ awards: structuredCv.awards, publications: structuredCv.publications, organisations: structuredCv.organisations, references: structuredCv.references }} onChange={(key, next) => editStructuredCv((prev) => ({ ...prev, [key]: next }))} />
|
|
|
|
<LanguagesSection value={structuredCv.languages} onChange={(next) => editStructuredCv((prev) => ({ ...prev, languages: next }))} getMetadata={metaFor} />
|
|
|
|
<WorkExperienceSection value={structuredCv.jobs} onChange={(next) => editStructuredCv((prev) => ({ ...prev, jobs: next }))} />
|
|
|
|
<EducationSection value={structuredCv.education} onChange={(next) => editStructuredCv((prev) => ({ ...prev, education: next }))} />
|
|
|
|
<OtherSectionsSection value={structuredCv.otherSections} onChange={(next) => editStructuredCv((prev) => ({ ...prev, otherSections: next }))} />
|
|
</Box>
|
|
<Box sx={{ mt: 1, display: workspaceSection === "profile" ? "flex" : "none", 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: workspaceSection === "profile" ? "flex" : "none", justifyContent: "flex-end", gap: 2, flexWrap: "wrap", alignItems: "center" }}>
|
|
{profileDirty ? <Chip size="small" color="warning" variant="outlined" label="Unsaved changes" /> : null}
|
|
<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);
|
|
setProfileDirty(false);
|
|
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> : null}
|
|
</Box>
|
|
);
|
|
}
|