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).
|
*Verified:* tsc clean, production build clean, 136 frontend tests pass (was 135; +1 invariant test).
|
||||||
Sidebar fix from the previous task still passes.
|
Sidebar fix from the previous task still passes.
|
||||||
|
|
||||||
**Remaining in Phase 1 (staged, needs the app running to click-verify each section's save
|
**Delivered 2026-07-20 (increment 2 — COMPLETED):**
|
||||||
round-trip):**
|
|
||||||
- Extract the editing sections into `PersonalInformationSection … LanguagesSection` components and a
|
|
||||||
`CareerProfileHeader`, keeping `structuredCv` + `setStructuredCv` + the save handler in the parent
|
|
||||||
(so behaviour stays identical). This is voluminous mechanical JSX movement through a 1376-line file.
|
|
||||||
- Hide the **template-driven CV builder** (rewrite templates + PDF carousel) and the **structure
|
|
||||||
overview** parse block from the user surface. Both are tested against live extraction behaviour, so
|
|
||||||
each removal must move its test coverage, not delete it — done incrementally with verification.
|
|
||||||
- Per-section actionable empty states ("No work experience added yet" → [Add experience]); the Add
|
|
||||||
affordances already exist, so this is copy + wiring.
|
|
||||||
|
|
||||||
Rationale for staging: these touch the live CV/extraction test surface and are best verified by
|
*Sections extracted* into `src/views/career/CareerProfileSections.tsx` (one file, one component per
|
||||||
driving the authenticated UI. Increment 1 is a clean, non-regressing checkpoint per the "reviewed and
|
export), plus `FieldReviewNote` + `confidenceTone` moved there verbatim and shared with the parent:
|
||||||
verified before Phase 2" instruction.
|
|
||||||
|
| 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
|
### Phase 2 — Import CV review screen
|
||||||
Add a post-upload "New information found" review (Experience / Skills / Languages / Education) with
|
Add a post-upload "New information found" review (Experience / Skills / Languages / Education) with
|
||||||
|
|||||||
@@ -222,6 +222,9 @@ test('profile page loads persisted structured cv and can re-parse it', async ()
|
|||||||
expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'true');
|
expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'true');
|
||||||
expect(await screen.findByLabelText(/profile cv \/ master resume text/i)).toHaveValue('Professional Summary\nBuilt backend systems');
|
expect(await screen.findByLabelText(/profile cv \/ master resume text/i)).toHaveValue('Professional Summary\nBuilt backend systems');
|
||||||
|
|
||||||
|
// 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 });
|
const analyzeButton = screen.getByRole('button', { name: /read sections/i });
|
||||||
await waitFor(() => expect(analyzeButton).toBeEnabled());
|
await waitFor(() => expect(analyzeButton).toBeEnabled());
|
||||||
fireEvent.click(analyzeButton);
|
fireEvent.click(analyzeButton);
|
||||||
@@ -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 () => {
|
test('profile page can reprocess from stored artifact history', async () => {
|
||||||
renderPage();
|
renderPage();
|
||||||
|
|
||||||
@@ -269,6 +299,9 @@ test('profile page keeps raw extraction collapsed until expanded', async () => {
|
|||||||
expect(await screen.findByText(/cv ready/i)).toBeInTheDocument();
|
expect(await screen.findByText(/cv ready/i)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/your career information stays front and center/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 });
|
const originalExtractionToggle = screen.getByRole('button', { name: /original import/i });
|
||||||
expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'false');
|
expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'false');
|
||||||
const copyButton = screen.getByRole('button', { name: /copy cv text/i });
|
const copyButton = screen.getByRole('button', { name: /copy cv text/i });
|
||||||
@@ -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 () => {
|
test('profile page rewrite tools use selected template and saved job context', async () => {
|
||||||
renderPage();
|
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();
|
expect(await screen.findByText(/template-driven cv builder/i)).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByText(/harvard/i));
|
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.' } });
|
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 SessionsSettingsCard from "../components/SessionsSettingsCard";
|
||||||
import CropImageDialog from "../components/CropImageDialog";
|
import CropImageDialog from "../components/CropImageDialog";
|
||||||
import ProfileCompleteness from "./career/ProfileCompleteness";
|
import ProfileCompleteness from "./career/ProfileCompleteness";
|
||||||
|
import {
|
||||||
|
EducationSection,
|
||||||
|
InterestsSection,
|
||||||
|
LanguagesSection,
|
||||||
|
OtherSectionsSection,
|
||||||
|
PersonalInformationSection,
|
||||||
|
ProfessionalSummarySection,
|
||||||
|
SkillsSection,
|
||||||
|
WorkExperienceSection,
|
||||||
|
} from "./career/CareerProfileSections";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
import { useI18n } from "../i18n/I18nProvider";
|
import { useI18n } from "../i18n/I18nProvider";
|
||||||
import {
|
import {
|
||||||
@@ -202,30 +212,7 @@ function initialsFrom(values: Array<string | undefined>) {
|
|||||||
return (joined[0][0] + joined[1][0]).toUpperCase();
|
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.
|
// 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 };
|
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 [structuredCv, setStructuredCv] = useState<StructuredCvProfile>(emptyStructuredCv());
|
||||||
const [completeness, setCompleteness] = useState<CareerCompleteness | null>(null);
|
const [completeness, setCompleteness] = useState<CareerCompleteness | null>(null);
|
||||||
const [versions, setVersions] = useState<CareerVersion[]>([]);
|
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 () => {
|
const loadVersions = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -383,6 +376,10 @@ export default function CareerProfilePage() {
|
|||||||
}
|
}
|
||||||
}, [extractionRuns, toast]);
|
}, [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 initials = useMemo(() => initialsFrom([me?.displayName, me?.firstName, me?.lastName, me?.userName, me?.email]), [me]);
|
||||||
const isLocal = me?.provider === "local";
|
const isLocal = me?.provider === "local";
|
||||||
const fullName = [me?.firstName, me?.lastName].filter(Boolean).join(" ");
|
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>
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvExtractionHistoryEmpty")}</Typography>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</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 sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.5 }}>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>{t("profileCvStructureOverview")}</Typography>
|
<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>
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructuredEditorHelp")}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
|
<PersonalInformationSection
|
||||||
<Box>
|
value={structuredCv.contact}
|
||||||
<TextField label={t("profileCvContactFullName")} value={structuredCv.contact.fullName ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, fullName: e.target.value || undefined } }))} fullWidth />
|
onChange={(next) => setStructuredCv((prev) => ({ ...prev, contact: next }))}
|
||||||
<FieldReviewNote metadata={getStructuredCvFieldMetadata(structuredCv, "contact.fullName")} />
|
getMetadata={metaFor}
|
||||||
</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>
|
|
||||||
|
|
||||||
<Box sx={{ mt: 2, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
|
<Box sx={{ mt: 2, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
|
||||||
<Box>
|
<ProfessionalSummarySection value={structuredCv.summary} onChange={(next) => setStructuredCv((prev) => ({ ...prev, summary: next }))} getMetadata={metaFor} />
|
||||||
<TextField
|
<SkillsSection value={structuredCv.skills} onChange={(next) => setStructuredCv((prev) => ({ ...prev, skills: next }))} getMetadata={metaFor} />
|
||||||
label={t("profileCvStructuredSummary")}
|
<InterestsSection value={structuredCv.interests} onChange={(next) => setStructuredCv((prev) => ({ ...prev, interests: next }))} getMetadata={metaFor} />
|
||||||
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>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ mt: 2 }}>
|
<LanguagesSection value={structuredCv.languages} onChange={(next) => setStructuredCv((prev) => ({ ...prev, languages: next }))} getMetadata={metaFor} />
|
||||||
<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>
|
|
||||||
|
|
||||||
<Box sx={{ mt: 2 }}>
|
<WorkExperienceSection value={structuredCv.jobs} onChange={(next) => setStructuredCv((prev) => ({ ...prev, jobs: next }))} />
|
||||||
<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>
|
|
||||||
|
|
||||||
<Box sx={{ mt: 2 }}>
|
<EducationSection value={structuredCv.education} onChange={(next) => setStructuredCv((prev) => ({ ...prev, education: next }))} />
|
||||||
<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>
|
|
||||||
|
|
||||||
<Box sx={{ mt: 2 }}>
|
<OtherSectionsSection value={structuredCv.otherSections} onChange={(next) => setStructuredCv((prev) => ({ ...prev, otherSections: next }))} />
|
||||||
<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>
|
</Box>
|
||||||
{structuredCv.otherSections.length === 0 ? <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructuredEmpty")}</Typography> : null}
|
<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" }}>
|
||||||
{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>
|
|
||||||
</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={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.75 }}>
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.75 }}>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 900 }}>Template-driven CV builder</Typography>
|
<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