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:
@@ -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
|
||||
|
||||
@@ -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.' } });
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user