feat(ui): migrate Career Profile structured editor
This commit is contained in:
@@ -27,6 +27,7 @@ import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { useAccountPlan } from "../accountPlan";
|
||||
import ProFeatureNotice from "../components/ProFeatureNotice";
|
||||
import { PageSection } from "../layout/PageLayout";
|
||||
import type { UserOperation } from "../types";
|
||||
import {
|
||||
emptyStructuredCv,
|
||||
@@ -147,11 +148,16 @@ export default function CareerProfilePage() {
|
||||
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);
|
||||
@@ -192,6 +198,8 @@ export default function CareerProfilePage() {
|
||||
}, []);
|
||||
|
||||
const restoreVersion = useCallback(async (version: number) => {
|
||||
if (profileMutationPending.current || me?.provider !== "local" || loading) return;
|
||||
profileMutationPending.current = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await api.post<CareerProfileResponse>(`/career/profile/versions/${version}/restore`);
|
||||
@@ -203,9 +211,10 @@ export default function CareerProfilePage() {
|
||||
} catch (e: any) {
|
||||
toast(String(e?.response?.data || e?.message || t("profileUpdateFailed")), "error");
|
||||
} finally {
|
||||
profileMutationPending.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loadVersions, t, toast]);
|
||||
}, [loadVersions, loading, me?.provider, t, toast]);
|
||||
const [extractionRuns, setExtractionRuns] = useState<ExtractionRun[]>([]);
|
||||
const [runsLoading, setRunsLoading] = useState(true);
|
||||
const [runsLoadError, setRunsLoadError] = useState<string | null>(null);
|
||||
@@ -269,13 +278,13 @@ export default function CareerProfilePage() {
|
||||
setProfileDirty(false);
|
||||
setHeadline(profile.contact.headline ?? "");
|
||||
setLoadError(null);
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
setMe(null);
|
||||
setLoadError(String(error?.response?.data || error?.message || t("profileLoadFailed")));
|
||||
setLoadError(getApiErrorMessage(error, ""));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadProfile();
|
||||
@@ -328,6 +337,8 @@ export default function CareerProfilePage() {
|
||||
|
||||
const initials = useMemo(() => initialsFrom([me?.displayName, me?.firstName, me?.lastName, me?.userName, me?.email]), [me]);
|
||||
const isLocal = me?.provider === "local";
|
||||
const editorDisabled = !isLocal || loading || saving;
|
||||
const 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;
|
||||
|
||||
@@ -345,8 +356,8 @@ export default function CareerProfilePage() {
|
||||
const pendingCvRun = extractionRuns.find((run) => run.status === "pending_review");
|
||||
const extractionProgress = activeCvRun?.operation?.progressPercent;
|
||||
return (
|
||||
<Box sx={{ display: "grid", gap: 2 }}>
|
||||
<Paper component="nav" aria-label={t("careerWorkspaceNavigation")} variant="outlined" sx={{ borderRadius: 3, overflow: "hidden" }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr)", gap: 2, minWidth: 0 }}>
|
||||
<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
|
||||
@@ -378,8 +389,8 @@ export default function CareerProfilePage() {
|
||||
<CareerWorkspaceOverview
|
||||
completeness={completeness}
|
||||
runs={extractionRuns}
|
||||
loading={loading}
|
||||
loadError={loadError}
|
||||
loading={loading || saving}
|
||||
loadError={loadErrorMessage}
|
||||
runsLoading={runsLoading}
|
||||
runsLoadError={runsLoadError}
|
||||
onRetryRuns={() => void loadExtractionRuns()}
|
||||
@@ -391,7 +402,7 @@ export default function CareerProfilePage() {
|
||||
|
||||
{evidenceVisited ? <Box hidden={workspaceSection !== "evidence"}><CareerEvidenceBank /></Box> : null}
|
||||
|
||||
{workspaceSection === "profile" || workspaceSection === "import" ? <Paper id={workspaceSection === "profile" ? "career-profile-editor" : "career-cv-import"} sx={{ mt: 0, p: { xs: 1.5, sm: 2.5 }, borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", scrollMarginTop: 96 }}>
|
||||
{workspaceSection === "profile" || workspaceSection === "import" ? <Paper id={workspaceSection === "profile" ? "career-profile-editor" : "career-cv-import"} sx={{ mt: 0, p: { xs: 1.5, sm: 2.5 }, borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", scrollMarginTop: 96, "& .MuiFormHelperText-root.Mui-disabled": { color: "text.secondary" } }}>
|
||||
<input
|
||||
ref={cvInputRef}
|
||||
type="file"
|
||||
@@ -406,7 +417,7 @@ export default function CareerProfilePage() {
|
||||
{workspaceSection === "profile" ? <ProfileCompleteness
|
||||
completeness={completeness}
|
||||
versions={versions}
|
||||
loading={loading}
|
||||
loading={editorDisabled}
|
||||
onRestore={(version) => void restoreVersion(version)}
|
||||
showSummary={false}
|
||||
/> : null}
|
||||
@@ -438,10 +449,10 @@ export default function CareerProfilePage() {
|
||||
}}
|
||||
/> : null}
|
||||
|
||||
{loadError ? (
|
||||
<Alert severity="error" sx={{ mb: 2, borderRadius: 2.5 }} action={<Button color="inherit" size="small" onClick={() => void loadProfile()}>{t("retry")}</Button>}>
|
||||
{loadErrorMessage ? (
|
||||
<Alert severity="error" sx={{ mb: 2 }} action={<Button color="inherit" size="small" disabled={profileDirty || loading || saving} onClick={() => { if (!profileDirty && !saving) void loadProfile(); }}>{t("retry")}</Button>}>
|
||||
{t("profileLoadFailed")}
|
||||
<Typography variant="body2" sx={{ mt: 0.5 }}>{loadError}</Typography>
|
||||
{loadError ? <Typography variant="body2" sx={{ mt: 0.5 }}>{loadError}</Typography> : null}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
@@ -452,9 +463,9 @@ export default function CareerProfilePage() {
|
||||
<Typography variant="body2" color="text.secondary">{t("careerProfileImportBody")}</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
variant="outlined"
|
||||
startIcon={<UploadFileOutlinedIcon />}
|
||||
disabled={!canUseAi || !isLocal || uploadingCv || Boolean(activeCvRun)}
|
||||
disabled={!canUseAi || editorDisabled || uploadingCv || Boolean(activeCvRun)}
|
||||
onClick={() => cvInputRef.current?.click()}
|
||||
>
|
||||
{uploadingCv ? t("profileUploading") : t("profileUploadCv")}
|
||||
@@ -476,7 +487,7 @@ export default function CareerProfilePage() {
|
||||
{pendingCvRun ? <Alert severity="success" sx={{ mt: 1.5 }} action={<Button color="inherit" size="small" onClick={() => navigateWorkspace("import")}>{t("careerProfileImportReview")}</Button>}>
|
||||
{t("careerProfileImportReady")}
|
||||
</Alert> : null}
|
||||
{!canUseAi ? <Box sx={{ mt: 1.5 }}><ProFeatureNotice featureKey="career-ai" title={t("profileProImportTitle")}>{t("profileProImportBody")}</ProFeatureNotice></Box> : null}
|
||||
{!canUseAi ? <Box sx={{ mt: 1.5, "& .MuiAlert-root": { flexWrap: "wrap" }, "& .MuiAlert-message": { minWidth: 0, overflowWrap: "anywhere" } }}><ProFeatureNotice featureKey="career-ai" title={t("profileProImportTitle")}>{t("profileProImportBody")}</ProFeatureNotice></Box> : null}
|
||||
</Paper> : null}
|
||||
|
||||
{workspaceSection === "profile" ? <Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
|
||||
@@ -643,7 +654,7 @@ export default function CareerProfilePage() {
|
||||
helperText={t("profileCvTextHelp")}
|
||||
multiline
|
||||
minRows={12}
|
||||
disabled={!isLocal}
|
||||
disabled={editorDisabled}
|
||||
fullWidth
|
||||
/>
|
||||
<Box sx={{ mt: 1.5, display: "flex", justifyContent: "flex-end" }}>
|
||||
@@ -783,7 +794,7 @@ export default function CareerProfilePage() {
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
disabled={!isLocal || !profileCvText.trim() || parsingCvSections}
|
||||
disabled={editorDisabled || !profileCvText.trim() || parsingCvSections}
|
||||
onClick={async () => {
|
||||
setParsingCvSections(true);
|
||||
try {
|
||||
@@ -821,33 +832,32 @@ export default function CareerProfilePage() {
|
||||
)}
|
||||
</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 component="h3" variant="subtitle1" sx={{ fontWeight: 800 }}>{t("profileCvStructuredEditor")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructuredEditorHelp")}</Typography>
|
||||
</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} />
|
||||
<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} />
|
||||
<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 }))} />
|
||||
<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} />
|
||||
<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 }))} />
|
||||
<WorkExperienceSection value={structuredCv.jobs} onChange={(next) => editStructuredCv((prev) => ({ ...prev, jobs: next }))} disabled={editorDisabled} />
|
||||
|
||||
<EducationSection value={structuredCv.education} onChange={(next) => editStructuredCv((prev) => ({ ...prev, education: next }))} />
|
||||
<EducationSection value={structuredCv.education} onChange={(next) => editStructuredCv((prev) => ({ ...prev, education: next }))} disabled={editorDisabled} />
|
||||
|
||||
<OtherSectionsSection value={structuredCv.otherSections} onChange={(next) => editStructuredCv((prev) => ({ ...prev, otherSections: next }))} />
|
||||
<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" }}>
|
||||
@@ -860,12 +870,20 @@ export default function CareerProfilePage() {
|
||||
</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={!isLocal || loading}
|
||||
disabled={editorDisabled}
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
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
|
||||
@@ -875,15 +893,17 @@ export default function CareerProfilePage() {
|
||||
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");
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, "");
|
||||
setSaveError(message);
|
||||
toast(message || translationRef.current("profileUpdateFailed"), "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
profileMutationPending.current = false;
|
||||
setSaving(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("profileSaveChanges")}
|
||||
{saving ? t("saving") : t("profileSaveChanges")}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user