refactor(career): Phase 1 increment 2 — extract editor sections, hide duplicate CV concepts

UI-only. No change to APIs, save payloads, extraction behaviour, or data
models. The parent CareerProfilePage still owns loading, state, saving,
and all extraction/import actions; the new sections are presentational
(value + onChange, plus a getMetadata callback for review chips).

Extracted into src/views/career/CareerProfileSections.tsx:
PersonalInformation, ProfessionalSummary, Skills, Interests, Languages,
WorkExperience, Education, OtherSections. FieldReviewNote + confidenceTone
moved there verbatim and shared with the parent. CareerProfilePage went
from 1376 to ~1200 lines.

No Projects/Certifications sections were created -- the editor never had
them (they are not editable structured fields here). Inventing them would
add functionality, which this refactor avoids; noted for a product
decision later.

Hid the duplicate CV concepts behind an "Advanced CV tools" toggle,
collapsed by default: the CV Structure Overview parse block and the
Template-driven CV builder. Both stay mounted and functional (gated with
display:none), so no tested functionality is removed -- the real CV
Builder at /career/builder is the single generation surface. Future
removal plan documented.

Tests: added "editing a field in an extracted section updates parent
state and flows into save" (render -> edit -> PUT /career/profile
{profile,cvText}); existing parse/rewrite tests reveal the advanced tools
first. The increment-1 save-invariant test still pins the payload.

Verified: tsc clean, production build clean, 137 frontend tests pass.
Backend untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-20 11:00:26 +02:00
parent 63473bae85
commit fe9cd4dda1
4 changed files with 361 additions and 181 deletions
+50 -13
View File
@@ -106,20 +106,57 @@ tests were re-pointed to the new labels; all behavioural assertions (save, parse
*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.
**Delivered 2026-07-20 (increment 2 — COMPLETED):**
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.
*Sections extracted* into `src/views/career/CareerProfileSections.tsx` (one file, one component per
export), plus `FieldReviewNote` + `confidenceTone` moved there verbatim and shared with the parent:
| Component | Slice | Props |
|---|---|---|
| `PersonalInformationSection` | `contact` | `value`, `onChange`, `getMetadata` |
| `ProfessionalSummarySection` | `summary` | `value`, `onChange`, `getMetadata` |
| `SkillsSection` | `skills` | `value`, `onChange`, `getMetadata` |
| `InterestsSection` | `interests` | `value`, `onChange`, `getMetadata` |
| `LanguagesSection` | `languages` | `value`, `onChange`, `getMetadata` |
| `WorkExperienceSection` | `jobs` | `value`, `onChange` |
| `EducationSection` | `education` | `value`, `onChange` |
| `OtherSectionsSection` | `otherSections` | `value`, `onChange` |
Each section is presentational: it receives its slice + `onChange(next)`; the parent still holds
`structuredCv`, loads, saves, and owns every extraction/import action. `getMetadata` is a callback
(`getStructuredCvFieldMetadata` over the whole profile) so sections stay decoupled from the full shape.
No section makes an API call. `CareerProfilePage` dropped from 1376 → ~1200 lines.
**No `ProjectsSection` / `CertificationsSection` were created** — the editor never had those sections
(`StructuredCvProfile` has no editable projects/certifications UI here; such content lives in "Other
sections"). Inventing them would add functionality, which this refactor explicitly avoids. Flagged for
a product decision in a later phase.
*Duplicate concepts hidden* (not deleted) behind an **"Advanced CV tools"** toggle on the Career
Profile page, collapsed by default:
- **CV Structure Overview** ("Profile sections" parse block)
- **Template-driven CV Builder** (rewrite templates + PDF carousel)
Both stay mounted and fully functional — gated with `display: none` via `showAdvancedCvTools`, so the
underlying flows (and their tests) are intact and reachable, just out of the default workflow. The
real CV Builder at `/career/builder` is the single CV-generation surface.
*Tests* (all green): existing profile-page tests re-pointed to reveal advanced tools before touching
those controls; added "editing a field in an extracted section updates parent state and flows into
save" (proves render → edit → `PUT /career/profile { profile, cvText }`). The save-invariant test from
increment 1 still pins the exact payload.
*Verified:* tsc clean, production build clean, **137 frontend tests pass**. No API, save-payload,
extraction, or data-model change.
**Future removal plan (later phase, not now):** once Phase 2 (Import CV review) and the real CV
Builder cover every flow the hidden blocks serve, delete the structure-overview parse block and the
template-driven builder from `CareerProfilePage` entirely, and drop the `showAdvancedCvTools` toggle.
Until then they remain behind the toggle so no tested functionality is lost.
**Still open for Phase 1 polish (optional, low priority):** a dedicated `CareerProfileHeader`
component and per-section actionable empty-state copy ("No work experience added yet" → the Add button
already present). Neither is blocking; both are cosmetic.
### Phase 2 — Import CV review screen
Add a post-upload "New information found" review (Experience / Skills / Languages / Education) with
+36
View File
@@ -222,6 +222,9 @@ test('profile page loads persisted structured cv and can re-parse it', async ()
expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'true');
expect(await screen.findByLabelText(/profile cv \/ master resume text/i)).toHaveValue('Professional Summary\nBuilt backend systems');
// Structure overview is hidden from the default workflow (Phase 1 increment 2); reveal it.
fireEvent.click(screen.getByRole('button', { name: /advanced cv tools/i }));
const analyzeButton = screen.getByRole('button', { name: /read sections/i });
await waitFor(() => expect(analyzeButton).toBeEnabled());
fireEvent.click(analyzeButton);
@@ -251,6 +254,33 @@ test('saving the career profile PUTs the structured profile and cv text unchange
});
});
test('editing a field in an extracted section updates parent state and flows into save (Phase 1 increment 2)', async () => {
renderPage();
// Renders existing data through the extracted PersonalInformationSection.
const nameField = await screen.findByLabelText(/full name/i);
expect(nameField).toHaveValue('Demo User');
// Editing the child field updates the parent's structuredCv (controlled input reflects it back).
fireEvent.change(nameField, { target: { value: 'Edited Name' } });
expect(nameField).toHaveValue('Edited Name');
const saveButton = screen.getByRole('button', { name: /save changes/i });
await waitFor(() => expect(saveButton).toBeEnabled());
fireEvent.click(saveButton);
// The edited value reaches the unchanged save path.
await waitFor(() => {
expect(mockedApi.put).toHaveBeenCalledWith(
'/career/profile',
expect.objectContaining({
profile: expect.objectContaining({ contact: expect.objectContaining({ fullName: 'Edited Name' }) }),
cvText: expect.any(String),
}),
);
});
});
test('profile page can reprocess from stored artifact history', async () => {
renderPage();
@@ -269,6 +299,9 @@ test('profile page keeps raw extraction collapsed until expanded', async () => {
expect(await screen.findByText(/cv ready/i)).toBeInTheDocument();
expect(screen.getByText(/your career information stays front and center/i)).toBeInTheDocument();
// Reveal the advanced tools (Phase 1 increment 2) — the template-builder copy button lives there.
fireEvent.click(screen.getByRole('button', { name: /advanced cv tools/i }));
const originalExtractionToggle = screen.getByRole('button', { name: /original import/i });
expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'false');
const copyButton = screen.getByRole('button', { name: /copy cv text/i });
@@ -285,6 +318,9 @@ test('profile page keeps raw extraction collapsed until expanded', async () => {
test('profile page rewrite tools use selected template and saved job context', async () => {
renderPage();
// Template-driven CV builder is hidden from the default workflow (Phase 1 increment 2); reveal it.
await screen.findByRole('button', { name: /advanced cv tools/i });
fireEvent.click(screen.getByRole('button', { name: /advanced cv tools/i }));
expect(await screen.findByText(/template-driven cv builder/i)).toBeInTheDocument();
fireEvent.click(screen.getByText(/harvard/i));
fireEvent.change(screen.getByLabelText(/prompt-based cv brief/i), { target: { value: 'Highlight backend platform ownership, distributed systems, and cross-team delivery.' } });
+39 -168
View File
@@ -15,6 +15,16 @@ import TwoFactorSettingsCard from "../components/TwoFactorSettingsCard";
import SessionsSettingsCard from "../components/SessionsSettingsCard";
import CropImageDialog from "../components/CropImageDialog";
import ProfileCompleteness from "./career/ProfileCompleteness";
import {
EducationSection,
InterestsSection,
LanguagesSection,
OtherSectionsSection,
PersonalInformationSection,
ProfessionalSummarySection,
SkillsSection,
WorkExperienceSection,
} from "./career/CareerProfileSections";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
import {
@@ -202,30 +212,7 @@ function initialsFrom(values: Array<string | undefined>) {
return (joined[0][0] + joined[1][0]).toUpperCase();
}
function confidenceTone(confidence?: number) {
if (typeof confidence !== "number") return { label: "Review", color: "default" as const };
if (confidence >= 0.8) return { label: `High ${Math.round(confidence * 100)}%`, color: "success" as const };
if (confidence >= 0.65) return { label: `Medium ${Math.round(confidence * 100)}%`, color: "warning" as const };
return { label: `Low ${Math.round(confidence * 100)}%`, color: "error" as const };
}
function FieldReviewNote({ metadata }: { metadata?: StructuredCvFieldMetadata }) {
if (!metadata) return null;
const tone = confidenceTone(metadata.confidence);
return (
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.75, alignItems: "center" }}>
<Chip size="small" color={tone.color} variant={tone.color === "default" ? "outlined" : "filled"} label={tone.label} />
{metadata.method ? <Chip size="small" variant="outlined" label={metadata.method} /> : null}
{metadata.sourceBlockId ? <Chip size="small" variant="outlined" label={metadata.sourceBlockId} /> : null}
{metadata.reviewState ? <Chip size="small" variant="outlined" label={metadata.reviewState} /> : null}
{metadata.sourceSnippet ? (
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{metadata.sourceSnippet}
</Typography>
) : null}
</Box>
);
}
// 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 };
@@ -275,6 +262,12 @@ export default function CareerProfilePage() {
const [structuredCv, setStructuredCv] = useState<StructuredCvProfile>(emptyStructuredCv());
const [completeness, setCompleteness] = useState<CareerCompleteness | null>(null);
const [versions, setVersions] = useState<CareerVersion[]>([]);
// Phase 1 increment 2: "Profile sections" (CV structure overview) and the "Template-driven CV
// builder" are duplicate/implementation concepts — the real CV Builder lives at /career/builder.
// Hidden from the default Career Profile workflow behind this toggle; the underlying functionality
// (and its tests) stay intact and reachable. Removal is planned for a later phase — see
// docs/career-workspace-ux-refactor.md.
const [showAdvancedCvTools, setShowAdvancedCvTools] = useState(false);
const loadVersions = useCallback(async () => {
try {
@@ -383,6 +376,10 @@ export default function CareerProfilePage() {
}
}, [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(" ");
@@ -763,7 +760,12 @@ export default function CareerProfilePage() {
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvExtractionHistoryEmpty")}</Typography>
)}
</Box>
<Box sx={{ mt: 2, p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
<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>
@@ -814,158 +816,27 @@ export default function CareerProfilePage() {
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructuredEditorHelp")}</Typography>
</Box>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
<Box>
<TextField label={t("profileCvContactFullName")} value={structuredCv.contact.fullName ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, fullName: e.target.value || undefined } }))} fullWidth />
<FieldReviewNote metadata={getStructuredCvFieldMetadata(structuredCv, "contact.fullName")} />
</Box>
<Box>
<TextField label={t("profileCvContactHeadline")} value={structuredCv.contact.headline ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, headline: e.target.value || undefined } }))} fullWidth />
<FieldReviewNote metadata={getStructuredCvFieldMetadata(structuredCv, "contact.headline")} />
</Box>
<Box>
<TextField label={t("profileCvContactEmail")} value={structuredCv.contact.email ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, email: e.target.value || undefined } }))} fullWidth />
<FieldReviewNote metadata={getStructuredCvFieldMetadata(structuredCv, "contact.email")} />
</Box>
<Box>
<TextField label={t("profileCvContactPhone")} value={structuredCv.contact.phone ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, phone: e.target.value || undefined } }))} fullWidth />
<FieldReviewNote metadata={getStructuredCvFieldMetadata(structuredCv, "contact.phone")} />
</Box>
<Box>
<TextField label={t("profileCvContactLocation")} value={structuredCv.contact.location ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, location: e.target.value || undefined } }))} fullWidth />
<FieldReviewNote metadata={getStructuredCvFieldMetadata(structuredCv, "contact.location")} />
</Box>
<TextField label={t("profileCvContactWebsite")} value={structuredCv.contact.website ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, website: e.target.value || undefined } }))} fullWidth />
<TextField label={t("profileCvContactLinkedIn")} value={structuredCv.contact.linkedIn ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, linkedIn: e.target.value || undefined } }))} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
</Box>
<PersonalInformationSection
value={structuredCv.contact}
onChange={(next) => setStructuredCv((prev) => ({ ...prev, contact: next }))}
getMetadata={metaFor}
/>
<Box sx={{ mt: 2, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
<Box>
<TextField
label={t("profileCvStructuredSummary")}
value={joinLines(structuredCv.summary)}
onChange={(e) => setStructuredCv((prev) => ({ ...prev, summary: splitLines(e.target.value) }))}
helperText={t("profileCvStructuredListHelp")}
multiline
minRows={5}
fullWidth
/>
<FieldReviewNote metadata={getStructuredCvFieldMetadata(structuredCv, "summary")} />
</Box>
<Box>
<TextField
label={t("profileCvStructuredSkills")}
value={joinLines(structuredCv.skills)}
onChange={(e) => setStructuredCv((prev) => ({ ...prev, skills: splitLines(e.target.value) }))}
helperText={t("profileCvStructuredListHelp")}
multiline
minRows={5}
fullWidth
/>
<FieldReviewNote metadata={getStructuredCvFieldMetadata(structuredCv, "skills")} />
</Box>
<Box>
<TextField
label={t("profileCvStructuredInterests")}
value={joinLines(structuredCv.interests)}
onChange={(e) => setStructuredCv((prev) => ({ ...prev, interests: splitLines(e.target.value) }))}
helperText={t("profileCvStructuredListHelp")}
multiline
minRows={4}
fullWidth
/>
<FieldReviewNote metadata={getStructuredCvFieldMetadata(structuredCv, "interests")} />
</Box>
<ProfessionalSummarySection value={structuredCv.summary} onChange={(next) => setStructuredCv((prev) => ({ ...prev, summary: next }))} getMetadata={metaFor} />
<SkillsSection value={structuredCv.skills} onChange={(next) => setStructuredCv((prev) => ({ ...prev, skills: next }))} getMetadata={metaFor} />
<InterestsSection value={structuredCv.interests} onChange={(next) => setStructuredCv((prev) => ({ ...prev, interests: next }))} getMetadata={metaFor} />
</Box>
<Box sx={{ mt: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("profileCvStructuredLanguages")}</Typography>
<Button variant="outlined" size="small" onClick={() => setStructuredCv((prev) => ({ ...prev, languages: [...prev.languages, { name: "", level: "", notes: "" }] }))}>{t("profileCvStructuredAddLanguage")}</Button>
</Box>
<FieldReviewNote metadata={getStructuredCvFieldMetadata(structuredCv, "languages")} />
{structuredCv.languages.length === 0 ? <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructuredEmpty")}</Typography> : null}
{structuredCv.languages.map((language, index) => (
<Box key={`language-${index}`} sx={{ p: 1.25, mb: 1, borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr auto" }, gap: 1 }}>
<TextField label={t("profileCvLanguageName")} value={language.name ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, languages: prev.languages.map((entry, entryIndex) => entryIndex === index ? { ...entry, name: e.target.value || undefined } : entry) }))} fullWidth />
<TextField label={t("profileCvLanguageLevel")} value={language.level ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, languages: prev.languages.map((entry, entryIndex) => entryIndex === index ? { ...entry, level: e.target.value || undefined } : entry) }))} fullWidth />
<TextField label={t("profileCvLanguageNotes")} value={language.notes ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, languages: prev.languages.map((entry, entryIndex) => entryIndex === index ? { ...entry, notes: e.target.value || undefined } : entry) }))} fullWidth />
<Button color="inherit" onClick={() => setStructuredCv((prev) => ({ ...prev, languages: prev.languages.filter((_, entryIndex) => entryIndex !== index) }))}>{t("profileCvStructuredRemove")}</Button>
</Box>
</Box>
))}
</Box>
<LanguagesSection value={structuredCv.languages} onChange={(next) => setStructuredCv((prev) => ({ ...prev, languages: next }))} getMetadata={metaFor} />
<Box sx={{ mt: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("profileCvStructuredJobs")}</Typography>
<Button variant="outlined" size="small" onClick={() => setStructuredCv((prev) => ({ ...prev, jobs: [...prev.jobs, { title: "", company: "", location: "", start: "", end: "", isCurrent: false, bullets: [], skills: [] }] }))}>{t("profileCvStructuredAddJob")}</Button>
</Box>
{structuredCv.jobs.length === 0 ? <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructuredEmpty")}</Typography> : null}
{structuredCv.jobs.map((job, index) => (
<Box key={`job-${index}`} sx={{ p: 1.25, mb: 1, borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1 }}>
<TextField label={t("profileCvJobTitle")} value={job.title ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, jobs: prev.jobs.map((entry, entryIndex) => entryIndex === index ? { ...entry, title: e.target.value || undefined } : entry) }))} fullWidth />
<TextField label={t("profileCvJobCompany")} value={job.company ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, jobs: prev.jobs.map((entry, entryIndex) => entryIndex === index ? { ...entry, company: e.target.value || undefined } : entry) }))} fullWidth />
<TextField label={t("profileCvJobLocation")} value={job.location ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, jobs: prev.jobs.map((entry, entryIndex) => entryIndex === index ? { ...entry, location: e.target.value || undefined } : entry) }))} fullWidth />
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
<TextField label={t("profileCvJobStart")} value={job.start ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, jobs: prev.jobs.map((entry, entryIndex) => entryIndex === index ? { ...entry, start: e.target.value || undefined } : entry) }))} fullWidth />
<TextField label={t("profileCvJobEnd")} value={job.end ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, jobs: prev.jobs.map((entry, entryIndex) => entryIndex === index ? { ...entry, end: e.target.value || undefined, isCurrent: /present|current/i.test(e.target.value) || entry.isCurrent } : entry) }))} fullWidth />
</Box>
<TextField label={t("profileCvJobBullets")} value={joinLines(job.bullets)} onChange={(e) => setStructuredCv((prev) => ({ ...prev, jobs: prev.jobs.map((entry, entryIndex) => entryIndex === index ? { ...entry, bullets: splitLines(e.target.value) } : entry) }))} helperText={t("profileCvStructuredListHelp")} multiline minRows={5} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
<TextField label={t("profileCvJobSkills")} value={joinLines(job.skills)} onChange={(e) => setStructuredCv((prev) => ({ ...prev, jobs: prev.jobs.map((entry, entryIndex) => entryIndex === index ? { ...entry, skills: splitLines(e.target.value) } : entry) }))} helperText={t("profileCvStructuredListHelp")} multiline minRows={3} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
<Box sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" }, display: "flex", justifyContent: "flex-end" }}>
<Button color="inherit" onClick={() => setStructuredCv((prev) => ({ ...prev, jobs: prev.jobs.filter((_, entryIndex) => entryIndex !== index) }))}>{t("profileCvStructuredRemove")}</Button>
</Box>
</Box>
</Box>
))}
</Box>
<WorkExperienceSection value={structuredCv.jobs} onChange={(next) => setStructuredCv((prev) => ({ ...prev, jobs: next }))} />
<Box sx={{ mt: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("profileCvStructuredEducation")}</Typography>
<Button variant="outlined" size="small" onClick={() => setStructuredCv((prev) => ({ ...prev, education: [...prev.education, { qualification: "", institution: "", location: "", start: "", end: "", details: [] }] }))}>{t("profileCvStructuredAddEducation")}</Button>
</Box>
{structuredCv.education.length === 0 ? <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructuredEmpty")}</Typography> : null}
{structuredCv.education.map((education, index) => (
<Box key={`education-${index}`} sx={{ p: 1.25, mb: 1, borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1 }}>
<TextField label={t("profileCvEducationQualification")} value={education.qualification ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, education: prev.education.map((entry, entryIndex) => entryIndex === index ? { ...entry, qualification: e.target.value || undefined } : entry) }))} fullWidth />
<TextField label={t("profileCvEducationInstitution")} value={education.institution ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, education: prev.education.map((entry, entryIndex) => entryIndex === index ? { ...entry, institution: e.target.value || undefined } : entry) }))} fullWidth />
<TextField label={t("profileCvEducationLocation")} value={education.location ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, education: prev.education.map((entry, entryIndex) => entryIndex === index ? { ...entry, location: e.target.value || undefined } : entry) }))} fullWidth />
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
<TextField label={t("profileCvEducationStart")} value={education.start ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, education: prev.education.map((entry, entryIndex) => entryIndex === index ? { ...entry, start: e.target.value || undefined } : entry) }))} fullWidth />
<TextField label={t("profileCvEducationEnd")} value={education.end ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, education: prev.education.map((entry, entryIndex) => entryIndex === index ? { ...entry, end: e.target.value || undefined } : entry) }))} fullWidth />
</Box>
<TextField label={t("profileCvEducationDetails")} value={joinLines(education.details)} onChange={(e) => setStructuredCv((prev) => ({ ...prev, education: prev.education.map((entry, entryIndex) => entryIndex === index ? { ...entry, details: splitLines(e.target.value) } : entry) }))} helperText={t("profileCvStructuredListHelp")} multiline minRows={4} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
<Box sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" }, display: "flex", justifyContent: "flex-end" }}>
<Button color="inherit" onClick={() => setStructuredCv((prev) => ({ ...prev, education: prev.education.filter((_, entryIndex) => entryIndex !== index) }))}>{t("profileCvStructuredRemove")}</Button>
</Box>
</Box>
</Box>
))}
</Box>
<EducationSection value={structuredCv.education} onChange={(next) => setStructuredCv((prev) => ({ ...prev, education: next }))} />
<Box sx={{ mt: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("profileCvStructuredOtherSections")}</Typography>
<Button variant="outlined" size="small" onClick={() => setStructuredCv((prev) => ({ ...prev, otherSections: [...prev.otherSections, { title: "", items: [] }] }))}>{t("profileCvStructuredAddOtherSection")}</Button>
</Box>
{structuredCv.otherSections.length === 0 ? <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructuredEmpty")}</Typography> : null}
{structuredCv.otherSections.map((section, index) => (
<Box key={`other-${index}`} sx={{ p: 1.25, mb: 1, borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr auto" }, gap: 1 }}>
<TextField label={t("profileCvOtherSectionTitle")} value={section.title ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, otherSections: prev.otherSections.map((entry, entryIndex) => entryIndex === index ? { ...entry, title: e.target.value || undefined } : entry) }))} fullWidth />
<Button color="inherit" onClick={() => setStructuredCv((prev) => ({ ...prev, otherSections: prev.otherSections.filter((_, entryIndex) => entryIndex !== index) }))}>{t("profileCvStructuredRemove")}</Button>
<TextField label={t("profileCvOtherSectionItems")} value={joinLines(section.items)} onChange={(e) => setStructuredCv((prev) => ({ ...prev, otherSections: prev.otherSections.map((entry, entryIndex) => entryIndex === index ? { ...entry, items: splitLines(e.target.value) } : entry) }))} helperText={t("profileCvStructuredListHelp")} multiline minRows={4} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
</Box>
</Box>
))}
</Box>
<OtherSectionsSection value={structuredCv.otherSections} onChange={(next) => setStructuredCv((prev) => ({ ...prev, otherSections: next }))} />
</Box>
<Box sx={{ mt: 2, p: 2.25, borderRadius: 4, border: "1px solid", borderColor: "divider", background: "linear-gradient(180deg, rgba(15,23,42,0.04) 0%, rgba(15,23,42,0) 100%)" }}>
<Box sx={{ mt: 2, p: 2.25, borderRadius: 4, border: "1px solid", borderColor: "divider", background: "linear-gradient(180deg, rgba(15,23,42,0.04) 0%, rgba(15,23,42,0) 100%)", display: showAdvancedCvTools ? "block" : "none" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.75 }}>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 900 }}>Template-driven CV builder</Typography>
@@ -0,0 +1,236 @@
import React from "react";
import { Box, Button, Chip, TextField, Typography } from "@mui/material";
import { useI18n } from "../../i18n/I18nProvider";
import {
joinLines,
splitLines,
StructuredCvContact,
StructuredCvEducation,
StructuredCvFieldMetadata,
StructuredCvJob,
StructuredCvLanguage,
StructuredCvOtherSection,
} from "../../profileCv";
// Career Profile editing sections, extracted from CareerProfilePage (Phase 1 increment 2).
//
// Each section is presentational: it receives its slice of the profile as `value` and reports edits
// through `onChange(next)`. The parent still owns `structuredCv`, all loading, the save
// (PUT /career/profile { profile, cvText }), and every extraction/import action. No section makes an
// API call or holds profile state — so save payloads and extraction behaviour are unchanged.
//
// `getMetadata` is the parent's field-review lookup (getStructuredCvFieldMetadata over the whole
// profile), passed as a callback so sections stay decoupled from the full profile shape.
type MetadataLookup = (path: string) => StructuredCvFieldMetadata | undefined;
// Field-review chip + tone, moved verbatim from CareerProfilePage so the sections and the parent
// share one definition. Behaviour (thresholds, labels, source snippet) is unchanged.
function confidenceTone(confidence?: number) {
if (typeof confidence !== "number") return { label: "Review", color: "default" as const };
if (confidence >= 0.8) return { label: `High ${Math.round(confidence * 100)}%`, color: "success" as const };
if (confidence >= 0.65) return { label: `Medium ${Math.round(confidence * 100)}%`, color: "warning" as const };
return { label: `Low ${Math.round(confidence * 100)}%`, color: "error" as const };
}
export function FieldReviewNote({ metadata }: { metadata?: StructuredCvFieldMetadata }) {
if (!metadata) return null;
const tone = confidenceTone(metadata.confidence);
return (
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.75, alignItems: "center" }}>
<Chip size="small" color={tone.color} variant={tone.color === "default" ? "outlined" : "filled"} label={tone.label} />
{metadata.method ? <Chip size="small" variant="outlined" label={metadata.method} /> : null}
{metadata.sourceBlockId ? <Chip size="small" variant="outlined" label={metadata.sourceBlockId} /> : null}
{metadata.reviewState ? <Chip size="small" variant="outlined" label={metadata.reviewState} /> : null}
{metadata.sourceSnippet ? (
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{metadata.sourceSnippet}
</Typography>
) : null}
</Box>
);
}
export function PersonalInformationSection({
value,
onChange,
getMetadata,
}: {
value: StructuredCvContact;
onChange: (next: StructuredCvContact) => void;
getMetadata: MetadataLookup;
}) {
const { t } = useI18n();
const set = (patch: Partial<StructuredCvContact>) => onChange({ ...value, ...patch });
return (
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
<Box>
<TextField label={t("profileCvContactFullName")} value={value.fullName ?? ""} onChange={(e) => set({ fullName: e.target.value || undefined })} fullWidth />
<FieldReviewNote metadata={getMetadata("contact.fullName")} />
</Box>
<Box>
<TextField label={t("profileCvContactHeadline")} value={value.headline ?? ""} onChange={(e) => set({ headline: e.target.value || undefined })} fullWidth />
<FieldReviewNote metadata={getMetadata("contact.headline")} />
</Box>
<Box>
<TextField label={t("profileCvContactEmail")} value={value.email ?? ""} onChange={(e) => set({ email: e.target.value || undefined })} fullWidth />
<FieldReviewNote metadata={getMetadata("contact.email")} />
</Box>
<Box>
<TextField label={t("profileCvContactPhone")} value={value.phone ?? ""} onChange={(e) => set({ phone: e.target.value || undefined })} fullWidth />
<FieldReviewNote metadata={getMetadata("contact.phone")} />
</Box>
<Box>
<TextField label={t("profileCvContactLocation")} value={value.location ?? ""} onChange={(e) => set({ location: e.target.value || undefined })} fullWidth />
<FieldReviewNote metadata={getMetadata("contact.location")} />
</Box>
<TextField label={t("profileCvContactWebsite")} value={value.website ?? ""} onChange={(e) => set({ website: e.target.value || undefined })} fullWidth />
<TextField label={t("profileCvContactLinkedIn")} value={value.linkedIn ?? ""} onChange={(e) => set({ linkedIn: e.target.value || undefined })} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
</Box>
);
}
// Shared free-text list editor (one item per line) for Summary / Skills / Interests.
function LinesField({ label, value, onChange, metadata, minRows }: { label: string; value: string[]; onChange: (next: string[]) => void; metadata?: StructuredCvFieldMetadata; minRows: number }) {
const { t } = useI18n();
return (
<Box>
<TextField
label={label}
value={joinLines(value)}
onChange={(e) => onChange(splitLines(e.target.value))}
helperText={t("profileCvStructuredListHelp")}
multiline
minRows={minRows}
fullWidth
/>
<FieldReviewNote metadata={metadata} />
</Box>
);
}
export function ProfessionalSummarySection({ value, onChange, getMetadata }: { value: string[]; onChange: (next: string[]) => void; getMetadata: MetadataLookup }) {
const { t } = useI18n();
return <LinesField label={t("profileCvStructuredSummary")} value={value} onChange={onChange} metadata={getMetadata("summary")} minRows={5} />;
}
export function SkillsSection({ value, onChange, getMetadata }: { value: string[]; onChange: (next: string[]) => void; getMetadata: MetadataLookup }) {
const { t } = useI18n();
return <LinesField label={t("profileCvStructuredSkills")} value={value} onChange={onChange} metadata={getMetadata("skills")} minRows={5} />;
}
export function InterestsSection({ value, onChange, getMetadata }: { value: string[]; onChange: (next: string[]) => void; getMetadata: MetadataLookup }) {
const { t } = useI18n();
return <LinesField label={t("profileCvStructuredInterests")} value={value} onChange={onChange} metadata={getMetadata("interests")} minRows={4} />;
}
export function LanguagesSection({ value, onChange, getMetadata }: { value: StructuredCvLanguage[]; onChange: (next: StructuredCvLanguage[]) => void; getMetadata: MetadataLookup }) {
const { t } = useI18n();
const update = (index: number, patch: Partial<StructuredCvLanguage>) => onChange(value.map((entry, i) => (i === index ? { ...entry, ...patch } : entry)));
return (
<Box sx={{ mt: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("profileCvStructuredLanguages")}</Typography>
<Button variant="outlined" size="small" onClick={() => onChange([...value, { name: "", level: "", notes: "" }])}>{t("profileCvStructuredAddLanguage")}</Button>
</Box>
<FieldReviewNote metadata={getMetadata("languages")} />
{value.length === 0 ? <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructuredEmpty")}</Typography> : null}
{value.map((language, index) => (
<Box key={`language-${index}`} sx={{ p: 1.25, mb: 1, borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr auto" }, gap: 1 }}>
<TextField label={t("profileCvLanguageName")} value={language.name ?? ""} onChange={(e) => update(index, { name: e.target.value || undefined })} fullWidth />
<TextField label={t("profileCvLanguageLevel")} value={language.level ?? ""} onChange={(e) => update(index, { level: e.target.value || undefined })} fullWidth />
<TextField label={t("profileCvLanguageNotes")} value={language.notes ?? ""} onChange={(e) => update(index, { notes: e.target.value || undefined })} fullWidth />
<Button color="inherit" onClick={() => onChange(value.filter((_, i) => i !== index))}>{t("profileCvStructuredRemove")}</Button>
</Box>
</Box>
))}
</Box>
);
}
export function WorkExperienceSection({ value, onChange }: { value: StructuredCvJob[]; onChange: (next: StructuredCvJob[]) => void }) {
const { t } = useI18n();
const update = (index: number, patch: Partial<StructuredCvJob>) => onChange(value.map((entry, i) => (i === index ? { ...entry, ...patch } : entry)));
return (
<Box sx={{ mt: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("profileCvStructuredJobs")}</Typography>
<Button variant="outlined" size="small" onClick={() => onChange([...value, { title: "", company: "", location: "", start: "", end: "", isCurrent: false, bullets: [], skills: [] }])}>{t("profileCvStructuredAddJob")}</Button>
</Box>
{value.length === 0 ? <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructuredEmpty")}</Typography> : null}
{value.map((job, index) => (
<Box key={`job-${index}`} sx={{ p: 1.25, mb: 1, borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1 }}>
<TextField label={t("profileCvJobTitle")} value={job.title ?? ""} onChange={(e) => update(index, { title: e.target.value || undefined })} fullWidth />
<TextField label={t("profileCvJobCompany")} value={job.company ?? ""} onChange={(e) => update(index, { company: e.target.value || undefined })} fullWidth />
<TextField label={t("profileCvJobLocation")} value={job.location ?? ""} onChange={(e) => update(index, { location: e.target.value || undefined })} fullWidth />
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
<TextField label={t("profileCvJobStart")} value={job.start ?? ""} onChange={(e) => update(index, { start: e.target.value || undefined })} fullWidth />
<TextField label={t("profileCvJobEnd")} value={job.end ?? ""} onChange={(e) => update(index, { end: e.target.value || undefined, isCurrent: /present|current/i.test(e.target.value) || job.isCurrent })} fullWidth />
</Box>
<TextField label={t("profileCvJobBullets")} value={joinLines(job.bullets)} onChange={(e) => update(index, { bullets: splitLines(e.target.value) })} helperText={t("profileCvStructuredListHelp")} multiline minRows={5} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
<TextField label={t("profileCvJobSkills")} value={joinLines(job.skills)} onChange={(e) => update(index, { skills: splitLines(e.target.value) })} helperText={t("profileCvStructuredListHelp")} multiline minRows={3} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
<Box sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" }, display: "flex", justifyContent: "flex-end" }}>
<Button color="inherit" onClick={() => onChange(value.filter((_, i) => i !== index))}>{t("profileCvStructuredRemove")}</Button>
</Box>
</Box>
</Box>
))}
</Box>
);
}
export function EducationSection({ value, onChange }: { value: StructuredCvEducation[]; onChange: (next: StructuredCvEducation[]) => void }) {
const { t } = useI18n();
const update = (index: number, patch: Partial<StructuredCvEducation>) => onChange(value.map((entry, i) => (i === index ? { ...entry, ...patch } : entry)));
return (
<Box sx={{ mt: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("profileCvStructuredEducation")}</Typography>
<Button variant="outlined" size="small" onClick={() => onChange([...value, { qualification: "", institution: "", location: "", start: "", end: "", details: [] }])}>{t("profileCvStructuredAddEducation")}</Button>
</Box>
{value.length === 0 ? <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructuredEmpty")}</Typography> : null}
{value.map((education, index) => (
<Box key={`education-${index}`} sx={{ p: 1.25, mb: 1, borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1 }}>
<TextField label={t("profileCvEducationQualification")} value={education.qualification ?? ""} onChange={(e) => update(index, { qualification: e.target.value || undefined })} fullWidth />
<TextField label={t("profileCvEducationInstitution")} value={education.institution ?? ""} onChange={(e) => update(index, { institution: e.target.value || undefined })} fullWidth />
<TextField label={t("profileCvEducationLocation")} value={education.location ?? ""} onChange={(e) => update(index, { location: e.target.value || undefined })} fullWidth />
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
<TextField label={t("profileCvEducationStart")} value={education.start ?? ""} onChange={(e) => update(index, { start: e.target.value || undefined })} fullWidth />
<TextField label={t("profileCvEducationEnd")} value={education.end ?? ""} onChange={(e) => update(index, { end: e.target.value || undefined })} fullWidth />
</Box>
<TextField label={t("profileCvEducationDetails")} value={joinLines(education.details)} onChange={(e) => update(index, { details: splitLines(e.target.value) })} helperText={t("profileCvStructuredListHelp")} multiline minRows={4} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
<Box sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" }, display: "flex", justifyContent: "flex-end" }}>
<Button color="inherit" onClick={() => onChange(value.filter((_, i) => i !== index))}>{t("profileCvStructuredRemove")}</Button>
</Box>
</Box>
</Box>
))}
</Box>
);
}
export function OtherSectionsSection({ value, onChange }: { value: StructuredCvOtherSection[]; onChange: (next: StructuredCvOtherSection[]) => void }) {
const { t } = useI18n();
const update = (index: number, patch: Partial<StructuredCvOtherSection>) => onChange(value.map((entry, i) => (i === index ? { ...entry, ...patch } : entry)));
return (
<Box sx={{ mt: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("profileCvStructuredOtherSections")}</Typography>
<Button variant="outlined" size="small" onClick={() => onChange([...value, { title: "", items: [] }])}>{t("profileCvStructuredAddOtherSection")}</Button>
</Box>
{value.length === 0 ? <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructuredEmpty")}</Typography> : null}
{value.map((section, index) => (
<Box key={`other-${index}`} sx={{ p: 1.25, mb: 1, borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr auto" }, gap: 1 }}>
<TextField label={t("profileCvOtherSectionTitle")} value={section.title ?? ""} onChange={(e) => update(index, { title: e.target.value || undefined })} fullWidth />
<Button color="inherit" onClick={() => onChange(value.filter((_, i) => i !== index))}>{t("profileCvStructuredRemove")}</Button>
<TextField label={t("profileCvOtherSectionItems")} value={joinLines(section.items)} onChange={(e) => update(index, { items: splitLines(e.target.value) })} helperText={t("profileCvStructuredListHelp")} multiline minRows={4} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
</Box>
</Box>
))}
</Box>
);
}