refactor(career): Phase 1 increment — user-facing terminology + component split
UI-only restructuring of the Career Profile surface. No change to
database models, CareerProfiles schema, CvVariants, extraction APIs, AI
services, CV rendering, or public CV.
Terminology -> user-facing (i18n strings):
- "Structured CV editor" -> "Career information"
- "CV structure overview" -> "Profile sections"
- "Summary bullets" -> "Professional summary"
- "Core skills" -> "Skills"
- "Analyze sections" -> "Read sections"
- "Original extraction" -> "Original import"
- hardcoded "Master career profile" -> "Career profile"
Help text de-jargoned; the Career information help now frames it as the
source the CV Builder consumes.
Component split (first step): extract ProfileCompleteness (completeness
meter + missing chips + version history) into src/views/career/. Display
only, props in, no state or API.
Save path untouched: api.put("/career/profile", { profile, cvText }). A
new test pins that exact call as the refactor invariant so the remaining
section extraction cannot silently change save behaviour. Existing
profile-page tests re-pointed to the new labels; every behavioural
assertion (save, parse, field values) kept.
Verified: tsc clean, production build clean, 136 frontend tests pass
(135 + 1 invariant). Sidebar fix from the previous task still passes.
Backend untouched.
The remaining Phase 1 work (per-section editor components, hiding the
template-driven builder and structure-overview blocks, actionable
per-section empty states) is staged in docs/career-workspace-ux-refactor.md
because it touches the live extraction test surface and is best verified
by driving the authenticated UI. This increment is a clean, non-regressing
checkpoint.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -76,11 +76,50 @@ all others are inactive. A child route never activates a parent nav item.
|
|||||||
- Tests: `sidebar-active-nav.test.ts` — asserts exactly one active item for `/career`,
|
- Tests: `sidebar-active-nav.test.ts` — asserts exactly one active item for `/career`,
|
||||||
`/career/builder`, `/career/builder/{id}`, and that no item double-highlights.
|
`/career/builder`, `/career/builder/{id}`, and that no item double-highlights.
|
||||||
|
|
||||||
### Phase 1 — Career Profile editor sections (frontend)
|
### Phase 1 — terminology + first component split (IN PROGRESS)
|
||||||
Reorganise `CareerProfilePage` presentation into the eight named sections with user-facing labels;
|
|
||||||
strip internal vocabulary from headings/help. No data-model or API change — the same
|
**Delivered 2026-07-20 (increment 1):**
|
||||||
`StructuredCvProfile` shape is read and saved. Ship behind the existing page; verify save/load of each
|
|
||||||
section against the existing profile API.
|
*Terminology → user-facing* (`src/i18n/translations.ts`, no structural change):
|
||||||
|
| Internal term (before) | User-facing (after) |
|
||||||
|
|---|---|
|
||||||
|
| "Structured CV editor" | "Career information" |
|
||||||
|
| "CV structure overview" | "Profile sections" |
|
||||||
|
| "Summary bullets" | "Professional summary" |
|
||||||
|
| "Core skills" | "Skills" |
|
||||||
|
| "Analyze sections" | "Read sections" |
|
||||||
|
| "Original extraction" | "Original import" |
|
||||||
|
| hardcoded "Master career profile" | "Career profile" |
|
||||||
|
Help text de-jargoned; the "Career information" help now says *"The CV Builder uses this information
|
||||||
|
to create documents."*
|
||||||
|
|
||||||
|
*Component extracted:* `src/views/career/ProfileCompleteness.tsx` — the completeness meter + missing
|
||||||
|
chips + version-history accordion, pulled out of `CareerProfilePage`. Display-only, props in, no state
|
||||||
|
or API — the first step of the split.
|
||||||
|
|
||||||
|
*No API / data / model change.* The save path is untouched:
|
||||||
|
`api.put("/career/profile", { profile: structuredCv, cvText })`. A new test
|
||||||
|
(`profile-page.test.tsx` → "saving the career profile PUTs … unchanged (Phase 1 refactor invariant)")
|
||||||
|
pins exactly that call so the remaining extraction can't silently change it. Existing profile-page
|
||||||
|
tests were re-pointed to the new labels; all behavioural assertions (save, parse, field values) kept.
|
||||||
|
|
||||||
|
*Verified:* tsc clean, production build clean, 136 frontend tests pass (was 135; +1 invariant test).
|
||||||
|
Sidebar fix from the previous task still passes.
|
||||||
|
|
||||||
|
**Remaining in Phase 1 (staged, needs the app running to click-verify each section's save
|
||||||
|
round-trip):**
|
||||||
|
- Extract the editing sections into `PersonalInformationSection … LanguagesSection` components and a
|
||||||
|
`CareerProfileHeader`, keeping `structuredCv` + `setStructuredCv` + the save handler in the parent
|
||||||
|
(so behaviour stays identical). This is voluminous mechanical JSX movement through a 1376-line file.
|
||||||
|
- Hide the **template-driven CV builder** (rewrite templates + PDF carousel) and the **structure
|
||||||
|
overview** parse block from the user surface. Both are tested against live extraction behaviour, so
|
||||||
|
each removal must move its test coverage, not delete it — done incrementally with verification.
|
||||||
|
- Per-section actionable empty states ("No work experience added yet" → [Add experience]); the Add
|
||||||
|
affordances already exist, so this is copy + wiring.
|
||||||
|
|
||||||
|
Rationale for staging: these touch the live CV/extraction test surface and are best verified by
|
||||||
|
driving the authenticated UI. Increment 1 is a clean, non-regressing checkpoint per the "reviewed and
|
||||||
|
verified before Phase 2" instruction.
|
||||||
|
|
||||||
### Phase 2 — Import CV review screen
|
### Phase 2 — Import CV review screen
|
||||||
Add a post-upload "New information found" review (Experience / Skills / Languages / Education) with
|
Add a post-upload "New information found" review (Experience / Skills / Languages / Education) with
|
||||||
|
|||||||
@@ -218,20 +218,20 @@ export const translations = {
|
|||||||
profileCvUploadFailed: "Failed to upload CV.",
|
profileCvUploadFailed: "Failed to upload CV.",
|
||||||
profileCvTextLabel: "Profile CV / master resume text",
|
profileCvTextLabel: "Profile CV / master resume text",
|
||||||
profileCvTextHelp: "Keep this updated and specific. Include recent roles, tools, achievements, measurable outcomes, and the work you want to be hired for next. If extraction misses something, edit it here manually.",
|
profileCvTextHelp: "Keep this updated and specific. Include recent roles, tools, achievements, measurable outcomes, and the work you want to be hired for next. If extraction misses something, edit it here manually.",
|
||||||
profileCvStructuredDefaultHint: "The structured CV stays front and center. Open the original extraction only when you need to verify or clean up parser output.",
|
profileCvStructuredDefaultHint: "Your career information stays front and center. Open the original import only when you need to check or clean up what was read from your file.",
|
||||||
profileCvRawPanelTitle: "Original extraction",
|
profileCvRawPanelTitle: "Original import",
|
||||||
profileCvRawPanelHelp: "Usually messy, but useful for checking what the parser actually pulled from the uploaded file.",
|
profileCvRawPanelHelp: "Usually messy, but useful for checking what was actually read from the uploaded file.",
|
||||||
profileCvPreferredUploads: "Supported uploads: PDF, DOCX, TXT, MD, PNG, JPG, JPEG, WEBP.",
|
profileCvPreferredUploads: "Supported uploads: PDF, DOCX, TXT, MD, PNG, JPG, JPEG, WEBP.",
|
||||||
profileCvSectionTools: "Section rewrite tools",
|
profileCvSectionTools: "Section rewrite tools",
|
||||||
profileCvStructureOverview: "CV structure overview",
|
profileCvStructureOverview: "Profile sections",
|
||||||
profileCvStructureOverviewHelp: "Parse your current CV text into reusable sections so you can spot missing structure before tailoring.",
|
profileCvStructureOverviewHelp: "Read your current CV text into sections so you can see what your profile will contain.",
|
||||||
profileCvStructureParse: "Analyze sections",
|
profileCvStructureParse: "Read sections",
|
||||||
profileCvStructureParsing: "Analyzing sections...",
|
profileCvStructureParsing: "Reading sections...",
|
||||||
profileCvStructureParsed: "CV structure analyzed.",
|
profileCvStructureParsed: "Sections read from your CV.",
|
||||||
profileCvStructureParseFailed: "Failed to analyze CV structure.",
|
profileCvStructureParseFailed: "Couldn't read sections from your CV.",
|
||||||
profileCvStructureEmpty: "No parsed sections yet.",
|
profileCvStructureEmpty: "No sections read yet.",
|
||||||
profileCvStructuredEditor: "Structured CV editor",
|
profileCvStructuredEditor: "Career information",
|
||||||
profileCvStructuredEditorHelp: "Edit reusable CV data directly so generators and matching can work from stable fields instead of raw text alone.",
|
profileCvStructuredEditorHelp: "Your career facts. The CV Builder uses this information to create documents — edit it here and every CV stays up to date.",
|
||||||
profileCvExtractionHistory: "Extraction history",
|
profileCvExtractionHistory: "Extraction history",
|
||||||
profileCvExtractionHistoryHelp: "See which parser run produced the current structured profile and reprocess from the stored source artifact when needed.",
|
profileCvExtractionHistoryHelp: "See which parser run produced the current structured profile and reprocess from the stored source artifact when needed.",
|
||||||
profileCvExtractionHistoryEmpty: "No extraction runs yet.",
|
profileCvExtractionHistoryEmpty: "No extraction runs yet.",
|
||||||
@@ -249,8 +249,8 @@ export const translations = {
|
|||||||
profileCvContactLocation: "Location",
|
profileCvContactLocation: "Location",
|
||||||
profileCvContactWebsite: "Website",
|
profileCvContactWebsite: "Website",
|
||||||
profileCvContactLinkedIn: "LinkedIn",
|
profileCvContactLinkedIn: "LinkedIn",
|
||||||
profileCvStructuredSummary: "Summary bullets",
|
profileCvStructuredSummary: "Professional summary",
|
||||||
profileCvStructuredSkills: "Core skills",
|
profileCvStructuredSkills: "Skills",
|
||||||
profileCvStructuredInterests: "Interests",
|
profileCvStructuredInterests: "Interests",
|
||||||
profileCvStructuredLanguages: "Languages",
|
profileCvStructuredLanguages: "Languages",
|
||||||
profileCvStructuredJobs: "Work experience",
|
profileCvStructuredJobs: "Work experience",
|
||||||
|
|||||||
@@ -205,13 +205,13 @@ test('profile page loads persisted structured cv and can re-parse it', async ()
|
|||||||
renderPage();
|
renderPage();
|
||||||
|
|
||||||
expect(await screen.findByText(/cv ready/i)).toBeInTheDocument();
|
expect(await screen.findByText(/cv ready/i)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/cv structure overview/i)).toBeInTheDocument();
|
expect(screen.getByText(/profile sections/i)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/structured cv editor/i)).toBeInTheDocument();
|
expect(screen.getAllByText(/career information/i).length).toBeGreaterThan(0);
|
||||||
expect(screen.getByText(/extraction history/i)).toBeInTheDocument();
|
expect(screen.getByText(/extraction history/i)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/resume.pdf/i)).toBeInTheDocument();
|
expect(screen.getByText(/resume.pdf/i)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/current run/i)).toBeInTheDocument();
|
expect(screen.getByText(/current run/i)).toBeInTheDocument();
|
||||||
expect(screen.getAllByText(/original extraction/i).length).toBeGreaterThan(0);
|
expect(screen.getAllByText(/original import/i).length).toBeGreaterThan(0);
|
||||||
const originalExtractionToggle = screen.getByRole('button', { name: /original extraction/i });
|
const originalExtractionToggle = screen.getByRole('button', { name: /original import/i });
|
||||||
expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'false');
|
expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'false');
|
||||||
expect(screen.getAllByText(/professional summary/i).length).toBeGreaterThan(0);
|
expect(screen.getAllByText(/professional summary/i).length).toBeGreaterThan(0);
|
||||||
expect(screen.getByLabelText(/full name/i)).toHaveValue('Demo User');
|
expect(screen.getByLabelText(/full name/i)).toHaveValue('Demo User');
|
||||||
@@ -222,7 +222,7 @@ test('profile page loads persisted structured cv and can re-parse it', async ()
|
|||||||
expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'true');
|
expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'true');
|
||||||
expect(await screen.findByLabelText(/profile cv \/ master resume text/i)).toHaveValue('Professional Summary\nBuilt backend systems');
|
expect(await screen.findByLabelText(/profile cv \/ master resume text/i)).toHaveValue('Professional Summary\nBuilt backend systems');
|
||||||
|
|
||||||
const analyzeButton = screen.getByRole('button', { name: /analyze sections/i });
|
const analyzeButton = screen.getByRole('button', { name: /read sections/i });
|
||||||
await waitFor(() => expect(analyzeButton).toBeEnabled());
|
await waitFor(() => expect(analyzeButton).toBeEnabled());
|
||||||
fireEvent.click(analyzeButton);
|
fireEvent.click(analyzeButton);
|
||||||
|
|
||||||
@@ -233,6 +233,24 @@ test('profile page loads persisted structured cv and can re-parse it', async ()
|
|||||||
expect(screen.getAllByText(/core skills/i).length).toBeGreaterThan(0);
|
expect(screen.getAllByText(/core skills/i).length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('saving the career profile PUTs the structured profile and cv text unchanged (Phase 1 refactor invariant)', async () => {
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
const saveButton = await screen.findByRole('button', { name: /save changes/i });
|
||||||
|
await waitFor(() => expect(saveButton).toBeEnabled());
|
||||||
|
fireEvent.click(saveButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockedApi.put).toHaveBeenCalledWith(
|
||||||
|
'/career/profile',
|
||||||
|
expect.objectContaining({
|
||||||
|
profile: expect.objectContaining({ contact: expect.objectContaining({ fullName: 'Demo User' }) }),
|
||||||
|
cvText: 'Professional Summary\nBuilt backend systems',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('profile page can reprocess from stored artifact history', async () => {
|
test('profile page can reprocess from stored artifact history', async () => {
|
||||||
renderPage();
|
renderPage();
|
||||||
|
|
||||||
@@ -249,9 +267,9 @@ test('profile page keeps raw extraction collapsed until expanded', async () => {
|
|||||||
renderPage();
|
renderPage();
|
||||||
|
|
||||||
expect(await screen.findByText(/cv ready/i)).toBeInTheDocument();
|
expect(await screen.findByText(/cv ready/i)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/the structured cv stays front and center/i)).toBeInTheDocument();
|
expect(screen.getByText(/your career information stays front and center/i)).toBeInTheDocument();
|
||||||
|
|
||||||
const originalExtractionToggle = screen.getByRole('button', { name: /original extraction/i });
|
const originalExtractionToggle = screen.getByRole('button', { name: /original import/i });
|
||||||
expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'false');
|
expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'false');
|
||||||
const copyButton = screen.getByRole('button', { name: /copy cv text/i });
|
const copyButton = screen.getByRole('button', { name: /copy cv text/i });
|
||||||
expect(copyButton).toBeDisabled();
|
expect(copyButton).toBeDisabled();
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import AuthStatusCard from "../components/AuthStatusCard";
|
|||||||
import TwoFactorSettingsCard from "../components/TwoFactorSettingsCard";
|
import TwoFactorSettingsCard from "../components/TwoFactorSettingsCard";
|
||||||
import SessionsSettingsCard from "../components/SessionsSettingsCard";
|
import SessionsSettingsCard from "../components/SessionsSettingsCard";
|
||||||
import CropImageDialog from "../components/CropImageDialog";
|
import CropImageDialog from "../components/CropImageDialog";
|
||||||
|
import ProfileCompleteness from "./career/ProfileCompleteness";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
import { useI18n } from "../i18n/I18nProvider";
|
import { useI18n } from "../i18n/I18nProvider";
|
||||||
import {
|
import {
|
||||||
@@ -498,51 +499,12 @@ export default function CareerProfilePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper sx={{ mt: 0, p: 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)" }}>
|
<Paper sx={{ mt: 0, p: 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)" }}>
|
||||||
{completeness ? (
|
<ProfileCompleteness
|
||||||
<Box sx={{ mb: 2.5, p: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
completeness={completeness}
|
||||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 2, flexWrap: "wrap", mb: 1 }}>
|
versions={versions}
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 900 }}>Profile completeness</Typography>
|
loading={loading}
|
||||||
<Typography variant="h6" sx={{ fontWeight: 900 }}>{completeness.percent}%</Typography>
|
onRestore={(version) => void restoreVersion(version)}
|
||||||
</Box>
|
|
||||||
<LinearProgress
|
|
||||||
variant="determinate"
|
|
||||||
value={completeness.percent}
|
|
||||||
color={completeness.percent >= 80 ? "success" : completeness.percent >= 40 ? "primary" : "warning"}
|
|
||||||
sx={{ height: 8, borderRadius: 999 }}
|
|
||||||
/>
|
/>
|
||||||
{completeness.missing.length > 0 ? (
|
|
||||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", alignItems: "center", mt: 1.25 }}>
|
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>Missing:</Typography>
|
|
||||||
{completeness.missing.map((label) => (
|
|
||||||
<Chip key={label} size="small" label={label} sx={{ height: 22, fontWeight: 700 }} />
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
<Typography variant="body2" sx={{ color: "success.main", mt: 1.25, fontWeight: 700 }}>Your master profile is complete.</Typography>
|
|
||||||
)}
|
|
||||||
{versions.length > 1 ? (
|
|
||||||
<Accordion disableGutters elevation={0} sx={{ mt: 1.5, "&:before": { display: "none" }, backgroundColor: "transparent" }}>
|
|
||||||
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ px: 0, minHeight: 0 }}>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>Version history ({versions.length})</Typography>
|
|
||||||
</AccordionSummary>
|
|
||||||
<AccordionDetails sx={{ px: 0, pt: 0 }}>
|
|
||||||
<Box sx={{ display: "grid", gap: 0.75 }}>
|
|
||||||
{versions.slice(0, 12).map((v) => (
|
|
||||||
<Box key={v.version} sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
|
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
|
||||||
v{v.version} · {v.source} · {new Date(v.createdAtUtc).toLocaleString()}{v.isCurrent ? " · current" : ""}
|
|
||||||
</Typography>
|
|
||||||
{!v.isCurrent ? (
|
|
||||||
<Button size="small" variant="text" disabled={loading} onClick={() => void restoreVersion(v.version)}>Restore</Button>
|
|
||||||
) : null}
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</AccordionDetails>
|
|
||||||
</Accordion>
|
|
||||||
) : null}
|
|
||||||
</Box>
|
|
||||||
) : null}
|
|
||||||
<CropImageDialog
|
<CropImageDialog
|
||||||
open={cropOpen}
|
open={cropOpen}
|
||||||
file={avatarFile}
|
file={avatarFile}
|
||||||
@@ -626,7 +588,7 @@ export default function CareerProfilePage() {
|
|||||||
</Box>
|
</Box>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="h5" sx={{ fontWeight: 900 }}>
|
<Typography variant="h5" sx={{ fontWeight: 900 }}>
|
||||||
{careerOnly ? "Master career profile" : t("profileTitle")}
|
{careerOnly ? "Career profile" : t("profileTitle")}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ color: "text.secondary" }}>{me?.userName || me?.displayName || fullName || me?.email || "-"}</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>
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>{headline || t("profileHeadlinePlaceholder")}</Typography>
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Accordion, AccordionDetails, AccordionSummary, Box, Button, Chip, LinearProgress, Typography } from "@mui/material";
|
||||||
|
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||||
|
|
||||||
|
// Display-only slice of the Career Profile page: the completeness meter, the "missing" chips, and the
|
||||||
|
// version-history accordion. Extracted from CareerProfilePage as the first step of the Phase 1
|
||||||
|
// component split. It owns no state and makes no API calls — the parent still holds `completeness`
|
||||||
|
// and `versions` and performs the restore — so save/load behaviour is unchanged.
|
||||||
|
export type CareerCompletenessView = {
|
||||||
|
percent: number;
|
||||||
|
missing: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CareerVersionView = {
|
||||||
|
version: number;
|
||||||
|
source: string;
|
||||||
|
createdAtUtc: string;
|
||||||
|
isCurrent: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ProfileCompleteness({
|
||||||
|
completeness,
|
||||||
|
versions,
|
||||||
|
loading,
|
||||||
|
onRestore,
|
||||||
|
}: {
|
||||||
|
completeness: CareerCompletenessView | null;
|
||||||
|
versions: CareerVersionView[];
|
||||||
|
loading: boolean;
|
||||||
|
onRestore: (version: number) => void;
|
||||||
|
}) {
|
||||||
|
if (!completeness) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ mb: 2.5, p: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 2, flexWrap: "wrap", mb: 1 }}>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 900 }}>Profile completeness</Typography>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 900 }}>{completeness.percent}%</Typography>
|
||||||
|
</Box>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={completeness.percent}
|
||||||
|
color={completeness.percent >= 80 ? "success" : completeness.percent >= 40 ? "primary" : "warning"}
|
||||||
|
sx={{ height: 8, borderRadius: 999 }}
|
||||||
|
/>
|
||||||
|
{completeness.missing.length > 0 ? (
|
||||||
|
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", alignItems: "center", mt: 1.25 }}>
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>Missing:</Typography>
|
||||||
|
{completeness.missing.map((label) => (
|
||||||
|
<Chip key={label} size="small" label={label} sx={{ height: 22, fontWeight: 700 }} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" sx={{ color: "success.main", mt: 1.25, fontWeight: 700 }}>Your career profile is complete.</Typography>
|
||||||
|
)}
|
||||||
|
{versions.length > 1 ? (
|
||||||
|
<Accordion disableGutters elevation={0} sx={{ mt: 1.5, "&:before": { display: "none" }, backgroundColor: "transparent" }}>
|
||||||
|
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ px: 0, minHeight: 0 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>Version history ({versions.length})</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails sx={{ px: 0, pt: 0 }}>
|
||||||
|
<Box sx={{ display: "grid", gap: 0.75 }}>
|
||||||
|
{versions.slice(0, 12).map((v) => (
|
||||||
|
<Box key={v.version} sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||||
|
v{v.version} · {v.source} · {new Date(v.createdAtUtc).toLocaleString()}{v.isCurrent ? " · current" : ""}
|
||||||
|
</Typography>
|
||||||
|
{!v.isCurrent ? (
|
||||||
|
<Button size="small" variant="text" disabled={loading} onClick={() => onRestore(v.version)}>Restore</Button>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user