From fe9cd4dda19aa2d07b56742ef6a22b1b2e7d43e7 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Mon, 20 Jul 2026 11:00:26 +0200 Subject: [PATCH] =?UTF-8?q?refactor(career):=20Phase=201=20increment=202?= =?UTF-8?q?=20=E2=80=94=20extract=20editor=20sections,=20hide=20duplicate?= =?UTF-8?q?=20CV=20concepts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/career-workspace-ux-refactor.md | 63 ++++- job-tracker-ui/src/profile-page.test.tsx | 36 +++ .../src/views/CareerProfilePage.tsx | 207 +++------------ .../views/career/CareerProfileSections.tsx | 236 ++++++++++++++++++ 4 files changed, 361 insertions(+), 181 deletions(-) create mode 100644 job-tracker-ui/src/views/career/CareerProfileSections.tsx diff --git a/docs/career-workspace-ux-refactor.md b/docs/career-workspace-ux-refactor.md index f9fa96f..0bfa8d7 100644 --- a/docs/career-workspace-ux-refactor.md +++ b/docs/career-workspace-ux-refactor.md @@ -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 diff --git a/job-tracker-ui/src/profile-page.test.tsx b/job-tracker-ui/src/profile-page.test.tsx index 1438bee..8c30bf5 100644 --- a/job-tracker-ui/src/profile-page.test.tsx +++ b/job-tracker-ui/src/profile-page.test.tsx @@ -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.' } }); diff --git a/job-tracker-ui/src/views/CareerProfilePage.tsx b/job-tracker-ui/src/views/CareerProfilePage.tsx index 244377e..faa4aa2 100644 --- a/job-tracker-ui/src/views/CareerProfilePage.tsx +++ b/job-tracker-ui/src/views/CareerProfilePage.tsx @@ -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) { 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 ( - - - {metadata.method ? : null} - {metadata.sourceBlockId ? : null} - {metadata.reviewState ? : null} - {metadata.sourceSnippet ? ( - - {metadata.sourceSnippet} - - ) : null} - - ); -} // 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(emptyStructuredCv()); const [completeness, setCompleteness] = useState(null); const [versions, setVersions] = useState([]); + // 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() { {t("profileCvExtractionHistoryEmpty")} )} - + + + + {t("profileCvStructureOverview")} @@ -814,158 +816,27 @@ export default function CareerProfilePage() { {t("profileCvStructuredEditorHelp")} - - - setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, fullName: e.target.value || undefined } }))} fullWidth /> - - - - setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, headline: e.target.value || undefined } }))} fullWidth /> - - - - setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, email: e.target.value || undefined } }))} fullWidth /> - - - - setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, phone: e.target.value || undefined } }))} fullWidth /> - - - - setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, location: e.target.value || undefined } }))} fullWidth /> - - - setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, website: e.target.value || undefined } }))} fullWidth /> - setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, linkedIn: e.target.value || undefined } }))} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} /> - + setStructuredCv((prev) => ({ ...prev, contact: next }))} + getMetadata={metaFor} + /> - - setStructuredCv((prev) => ({ ...prev, summary: splitLines(e.target.value) }))} - helperText={t("profileCvStructuredListHelp")} - multiline - minRows={5} - fullWidth - /> - - - - setStructuredCv((prev) => ({ ...prev, skills: splitLines(e.target.value) }))} - helperText={t("profileCvStructuredListHelp")} - multiline - minRows={5} - fullWidth - /> - - - - setStructuredCv((prev) => ({ ...prev, interests: splitLines(e.target.value) }))} - helperText={t("profileCvStructuredListHelp")} - multiline - minRows={4} - fullWidth - /> - - + setStructuredCv((prev) => ({ ...prev, summary: next }))} getMetadata={metaFor} /> + setStructuredCv((prev) => ({ ...prev, skills: next }))} getMetadata={metaFor} /> + setStructuredCv((prev) => ({ ...prev, interests: next }))} getMetadata={metaFor} /> - - - {t("profileCvStructuredLanguages")} - - - - {structuredCv.languages.length === 0 ? {t("profileCvStructuredEmpty")} : null} - {structuredCv.languages.map((language, index) => ( - - - setStructuredCv((prev) => ({ ...prev, languages: prev.languages.map((entry, entryIndex) => entryIndex === index ? { ...entry, name: e.target.value || undefined } : entry) }))} fullWidth /> - setStructuredCv((prev) => ({ ...prev, languages: prev.languages.map((entry, entryIndex) => entryIndex === index ? { ...entry, level: e.target.value || undefined } : entry) }))} fullWidth /> - setStructuredCv((prev) => ({ ...prev, languages: prev.languages.map((entry, entryIndex) => entryIndex === index ? { ...entry, notes: e.target.value || undefined } : entry) }))} fullWidth /> - - - - ))} - + setStructuredCv((prev) => ({ ...prev, languages: next }))} getMetadata={metaFor} /> - - - {t("profileCvStructuredJobs")} - - - {structuredCv.jobs.length === 0 ? {t("profileCvStructuredEmpty")} : null} - {structuredCv.jobs.map((job, index) => ( - - - setStructuredCv((prev) => ({ ...prev, jobs: prev.jobs.map((entry, entryIndex) => entryIndex === index ? { ...entry, title: e.target.value || undefined } : entry) }))} fullWidth /> - setStructuredCv((prev) => ({ ...prev, jobs: prev.jobs.map((entry, entryIndex) => entryIndex === index ? { ...entry, company: e.target.value || undefined } : entry) }))} fullWidth /> - setStructuredCv((prev) => ({ ...prev, jobs: prev.jobs.map((entry, entryIndex) => entryIndex === index ? { ...entry, location: e.target.value || undefined } : entry) }))} fullWidth /> - - setStructuredCv((prev) => ({ ...prev, jobs: prev.jobs.map((entry, entryIndex) => entryIndex === index ? { ...entry, start: e.target.value || undefined } : entry) }))} fullWidth /> - 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 /> - - 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" } }} /> - 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" } }} /> - - - - - - ))} - + setStructuredCv((prev) => ({ ...prev, jobs: next }))} /> - - - {t("profileCvStructuredEducation")} - - - {structuredCv.education.length === 0 ? {t("profileCvStructuredEmpty")} : null} - {structuredCv.education.map((education, index) => ( - - - setStructuredCv((prev) => ({ ...prev, education: prev.education.map((entry, entryIndex) => entryIndex === index ? { ...entry, qualification: e.target.value || undefined } : entry) }))} fullWidth /> - setStructuredCv((prev) => ({ ...prev, education: prev.education.map((entry, entryIndex) => entryIndex === index ? { ...entry, institution: e.target.value || undefined } : entry) }))} fullWidth /> - setStructuredCv((prev) => ({ ...prev, education: prev.education.map((entry, entryIndex) => entryIndex === index ? { ...entry, location: e.target.value || undefined } : entry) }))} fullWidth /> - - setStructuredCv((prev) => ({ ...prev, education: prev.education.map((entry, entryIndex) => entryIndex === index ? { ...entry, start: e.target.value || undefined } : entry) }))} fullWidth /> - setStructuredCv((prev) => ({ ...prev, education: prev.education.map((entry, entryIndex) => entryIndex === index ? { ...entry, end: e.target.value || undefined } : entry) }))} fullWidth /> - - 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" } }} /> - - - - - - ))} - + setStructuredCv((prev) => ({ ...prev, education: next }))} /> - - - {t("profileCvStructuredOtherSections")} - - - {structuredCv.otherSections.length === 0 ? {t("profileCvStructuredEmpty")} : null} - {structuredCv.otherSections.map((section, index) => ( - - - setStructuredCv((prev) => ({ ...prev, otherSections: prev.otherSections.map((entry, entryIndex) => entryIndex === index ? { ...entry, title: e.target.value || undefined } : entry) }))} fullWidth /> - - 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" } }} /> - - - ))} - + setStructuredCv((prev) => ({ ...prev, otherSections: next }))} /> - + Template-driven CV builder diff --git a/job-tracker-ui/src/views/career/CareerProfileSections.tsx b/job-tracker-ui/src/views/career/CareerProfileSections.tsx new file mode 100644 index 0000000..01227d9 --- /dev/null +++ b/job-tracker-ui/src/views/career/CareerProfileSections.tsx @@ -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 ( + + + {metadata.method ? : null} + {metadata.sourceBlockId ? : null} + {metadata.reviewState ? : null} + {metadata.sourceSnippet ? ( + + {metadata.sourceSnippet} + + ) : null} + + ); +} + +export function PersonalInformationSection({ + value, + onChange, + getMetadata, +}: { + value: StructuredCvContact; + onChange: (next: StructuredCvContact) => void; + getMetadata: MetadataLookup; +}) { + const { t } = useI18n(); + const set = (patch: Partial) => onChange({ ...value, ...patch }); + return ( + + + set({ fullName: e.target.value || undefined })} fullWidth /> + + + + set({ headline: e.target.value || undefined })} fullWidth /> + + + + set({ email: e.target.value || undefined })} fullWidth /> + + + + set({ phone: e.target.value || undefined })} fullWidth /> + + + + set({ location: e.target.value || undefined })} fullWidth /> + + + set({ website: e.target.value || undefined })} fullWidth /> + set({ linkedIn: e.target.value || undefined })} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} /> + + ); +} + +// 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 ( + + onChange(splitLines(e.target.value))} + helperText={t("profileCvStructuredListHelp")} + multiline + minRows={minRows} + fullWidth + /> + + + ); +} + +export function ProfessionalSummarySection({ value, onChange, getMetadata }: { value: string[]; onChange: (next: string[]) => void; getMetadata: MetadataLookup }) { + const { t } = useI18n(); + return ; +} + +export function SkillsSection({ value, onChange, getMetadata }: { value: string[]; onChange: (next: string[]) => void; getMetadata: MetadataLookup }) { + const { t } = useI18n(); + return ; +} + +export function InterestsSection({ value, onChange, getMetadata }: { value: string[]; onChange: (next: string[]) => void; getMetadata: MetadataLookup }) { + const { t } = useI18n(); + return ; +} + +export function LanguagesSection({ value, onChange, getMetadata }: { value: StructuredCvLanguage[]; onChange: (next: StructuredCvLanguage[]) => void; getMetadata: MetadataLookup }) { + const { t } = useI18n(); + const update = (index: number, patch: Partial) => onChange(value.map((entry, i) => (i === index ? { ...entry, ...patch } : entry))); + return ( + + + {t("profileCvStructuredLanguages")} + + + + {value.length === 0 ? {t("profileCvStructuredEmpty")} : null} + {value.map((language, index) => ( + + + update(index, { name: e.target.value || undefined })} fullWidth /> + update(index, { level: e.target.value || undefined })} fullWidth /> + update(index, { notes: e.target.value || undefined })} fullWidth /> + + + + ))} + + ); +} + +export function WorkExperienceSection({ value, onChange }: { value: StructuredCvJob[]; onChange: (next: StructuredCvJob[]) => void }) { + const { t } = useI18n(); + const update = (index: number, patch: Partial) => onChange(value.map((entry, i) => (i === index ? { ...entry, ...patch } : entry))); + return ( + + + {t("profileCvStructuredJobs")} + + + {value.length === 0 ? {t("profileCvStructuredEmpty")} : null} + {value.map((job, index) => ( + + + update(index, { title: e.target.value || undefined })} fullWidth /> + update(index, { company: e.target.value || undefined })} fullWidth /> + update(index, { location: e.target.value || undefined })} fullWidth /> + + update(index, { start: e.target.value || undefined })} fullWidth /> + update(index, { end: e.target.value || undefined, isCurrent: /present|current/i.test(e.target.value) || job.isCurrent })} fullWidth /> + + update(index, { bullets: splitLines(e.target.value) })} helperText={t("profileCvStructuredListHelp")} multiline minRows={5} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} /> + update(index, { skills: splitLines(e.target.value) })} helperText={t("profileCvStructuredListHelp")} multiline minRows={3} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} /> + + + + + + ))} + + ); +} + +export function EducationSection({ value, onChange }: { value: StructuredCvEducation[]; onChange: (next: StructuredCvEducation[]) => void }) { + const { t } = useI18n(); + const update = (index: number, patch: Partial) => onChange(value.map((entry, i) => (i === index ? { ...entry, ...patch } : entry))); + return ( + + + {t("profileCvStructuredEducation")} + + + {value.length === 0 ? {t("profileCvStructuredEmpty")} : null} + {value.map((education, index) => ( + + + update(index, { qualification: e.target.value || undefined })} fullWidth /> + update(index, { institution: e.target.value || undefined })} fullWidth /> + update(index, { location: e.target.value || undefined })} fullWidth /> + + update(index, { start: e.target.value || undefined })} fullWidth /> + update(index, { end: e.target.value || undefined })} fullWidth /> + + update(index, { details: splitLines(e.target.value) })} helperText={t("profileCvStructuredListHelp")} multiline minRows={4} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} /> + + + + + + ))} + + ); +} + +export function OtherSectionsSection({ value, onChange }: { value: StructuredCvOtherSection[]; onChange: (next: StructuredCvOtherSection[]) => void }) { + const { t } = useI18n(); + const update = (index: number, patch: Partial) => onChange(value.map((entry, i) => (i === index ? { ...entry, ...patch } : entry))); + return ( + + + {t("profileCvStructuredOtherSections")} + + + {value.length === 0 ? {t("profileCvStructuredEmpty")} : null} + {value.map((section, index) => ( + + + update(index, { title: e.target.value || undefined })} fullWidth /> + + update(index, { items: splitLines(e.target.value) })} helperText={t("profileCvStructuredListHelp")} multiline minRows={4} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} /> + + + ))} + + ); +}