feat(ui): migrate Career Profile structured editor

This commit is contained in:
cesnimda
2026-10-12 13:32:55 +02:00
parent 704637ab37
commit f7f6b774b9
17 changed files with 8314 additions and 158 deletions
+58 -38
View File
@@ -27,6 +27,7 @@ import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
import { useAccountPlan } from "../accountPlan";
import ProFeatureNotice from "../components/ProFeatureNotice";
import { PageSection } from "../layout/PageLayout";
import type { UserOperation } from "../types";
import {
emptyStructuredCv,
@@ -147,11 +148,16 @@ export default function CareerProfilePage() {
const careerOnly = true;
const { toast } = useToast();
const { language, t } = useI18n();
const translationRef = useRef(t);
useEffect(() => { translationRef.current = t; }, [t]);
const cvInputRef = useRef<HTMLInputElement | null>(null);
const avatarInputRef = useRef<HTMLInputElement | null>(null);
const [me, setMe] = useState<MeResponse | null>(null);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const profileMutationPending = useRef(false);
const [uploadingCv, setUploadingCv] = useState(false);
const [improvingCv, setImprovingCv] = useState(false);
const [rebuildingCv, setRebuildingCv] = useState(false);
@@ -192,6 +198,8 @@ export default function CareerProfilePage() {
}, []);
const restoreVersion = useCallback(async (version: number) => {
if (profileMutationPending.current || me?.provider !== "local" || loading) return;
profileMutationPending.current = true;
setLoading(true);
try {
const r = await api.post<CareerProfileResponse>(`/career/profile/versions/${version}/restore`);
@@ -203,9 +211,10 @@ export default function CareerProfilePage() {
} catch (e: any) {
toast(String(e?.response?.data || e?.message || t("profileUpdateFailed")), "error");
} finally {
profileMutationPending.current = false;
setLoading(false);
}
}, [loadVersions, t, toast]);
}, [loadVersions, loading, me?.provider, t, toast]);
const [extractionRuns, setExtractionRuns] = useState<ExtractionRun[]>([]);
const [runsLoading, setRunsLoading] = useState(true);
const [runsLoadError, setRunsLoadError] = useState<string | null>(null);
@@ -269,13 +278,13 @@ export default function CareerProfilePage() {
setProfileDirty(false);
setHeadline(profile.contact.headline ?? "");
setLoadError(null);
} catch (error: any) {
} catch (error) {
setMe(null);
setLoadError(String(error?.response?.data || error?.message || t("profileLoadFailed")));
setLoadError(getApiErrorMessage(error, ""));
} finally {
setLoading(false);
}
}, [t]);
}, []);
useEffect(() => {
void loadProfile();
@@ -328,6 +337,8 @@ export default function CareerProfilePage() {
const initials = useMemo(() => initialsFrom([me?.displayName, me?.firstName, me?.lastName, me?.userName, me?.email]), [me]);
const isLocal = me?.provider === "local";
const editorDisabled = !isLocal || loading || saving;
const loadErrorMessage = loadError === null ? null : loadError || t("profileLoadFailed");
const fullName = [me?.firstName, me?.lastName].filter(Boolean).join(" ");
const cvWordCount = profileCvText.trim() ? profileCvText.trim().split(/\s+/).length : 0;
@@ -345,8 +356,8 @@ export default function CareerProfilePage() {
const pendingCvRun = extractionRuns.find((run) => run.status === "pending_review");
const extractionProgress = activeCvRun?.operation?.progressPercent;
return (
<Box sx={{ display: "grid", gap: 2 }}>
<Paper component="nav" aria-label={t("careerWorkspaceNavigation")} variant="outlined" sx={{ borderRadius: 3, overflow: "hidden" }}>
<Box sx={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr)", gap: 2, minWidth: 0 }}>
<Paper component="nav" aria-label={t("careerWorkspaceNavigation")} variant="outlined" sx={{ borderRadius: 1, minWidth: 0, overflow: "hidden" }}>
<FormControl size="small" fullWidth sx={{ display: { xs: "flex", sm: "none" }, m: 1.25, width: "auto" }}>
<InputLabel id="career-workspace-section-label">{t("careerWorkspaceNavigation")}</InputLabel>
<Select
@@ -378,8 +389,8 @@ export default function CareerProfilePage() {
<CareerWorkspaceOverview
completeness={completeness}
runs={extractionRuns}
loading={loading}
loadError={loadError}
loading={loading || saving}
loadError={loadErrorMessage}
runsLoading={runsLoading}
runsLoadError={runsLoadError}
onRetryRuns={() => void loadExtractionRuns()}
@@ -391,7 +402,7 @@ export default function CareerProfilePage() {
{evidenceVisited ? <Box hidden={workspaceSection !== "evidence"}><CareerEvidenceBank /></Box> : null}
{workspaceSection === "profile" || workspaceSection === "import" ? <Paper id={workspaceSection === "profile" ? "career-profile-editor" : "career-cv-import"} sx={{ mt: 0, p: { xs: 1.5, sm: 2.5 }, borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", scrollMarginTop: 96 }}>
{workspaceSection === "profile" || workspaceSection === "import" ? <Paper id={workspaceSection === "profile" ? "career-profile-editor" : "career-cv-import"} sx={{ mt: 0, p: { xs: 1.5, sm: 2.5 }, borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", scrollMarginTop: 96, "& .MuiFormHelperText-root.Mui-disabled": { color: "text.secondary" } }}>
<input
ref={cvInputRef}
type="file"
@@ -406,7 +417,7 @@ export default function CareerProfilePage() {
{workspaceSection === "profile" ? <ProfileCompleteness
completeness={completeness}
versions={versions}
loading={loading}
loading={editorDisabled}
onRestore={(version) => void restoreVersion(version)}
showSummary={false}
/> : null}
@@ -438,10 +449,10 @@ export default function CareerProfilePage() {
}}
/> : null}
{loadError ? (
<Alert severity="error" sx={{ mb: 2, borderRadius: 2.5 }} action={<Button color="inherit" size="small" onClick={() => void loadProfile()}>{t("retry")}</Button>}>
{loadErrorMessage ? (
<Alert severity="error" sx={{ mb: 2 }} action={<Button color="inherit" size="small" disabled={profileDirty || loading || saving} onClick={() => { if (!profileDirty && !saving) void loadProfile(); }}>{t("retry")}</Button>}>
{t("profileLoadFailed")}
<Typography variant="body2" sx={{ mt: 0.5 }}>{loadError}</Typography>
{loadError ? <Typography variant="body2" sx={{ mt: 0.5 }}>{loadError}</Typography> : null}
</Alert>
) : null}
@@ -452,9 +463,9 @@ export default function CareerProfilePage() {
<Typography variant="body2" color="text.secondary">{t("careerProfileImportBody")}</Typography>
</Box>
<Button
variant="contained"
variant="outlined"
startIcon={<UploadFileOutlinedIcon />}
disabled={!canUseAi || !isLocal || uploadingCv || Boolean(activeCvRun)}
disabled={!canUseAi || editorDisabled || uploadingCv || Boolean(activeCvRun)}
onClick={() => cvInputRef.current?.click()}
>
{uploadingCv ? t("profileUploading") : t("profileUploadCv")}
@@ -476,7 +487,7 @@ export default function CareerProfilePage() {
{pendingCvRun ? <Alert severity="success" sx={{ mt: 1.5 }} action={<Button color="inherit" size="small" onClick={() => navigateWorkspace("import")}>{t("careerProfileImportReview")}</Button>}>
{t("careerProfileImportReady")}
</Alert> : null}
{!canUseAi ? <Box sx={{ mt: 1.5 }}><ProFeatureNotice featureKey="career-ai" title={t("profileProImportTitle")}>{t("profileProImportBody")}</ProFeatureNotice></Box> : null}
{!canUseAi ? <Box sx={{ mt: 1.5, "& .MuiAlert-root": { flexWrap: "wrap" }, "& .MuiAlert-message": { minWidth: 0, overflowWrap: "anywhere" } }}><ProFeatureNotice featureKey="career-ai" title={t("profileProImportTitle")}>{t("profileProImportBody")}</ProFeatureNotice></Box> : null}
</Paper> : null}
{workspaceSection === "profile" ? <Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
@@ -643,7 +654,7 @@ export default function CareerProfilePage() {
helperText={t("profileCvTextHelp")}
multiline
minRows={12}
disabled={!isLocal}
disabled={editorDisabled}
fullWidth
/>
<Box sx={{ mt: 1.5, display: "flex", justifyContent: "flex-end" }}>
@@ -783,7 +794,7 @@ export default function CareerProfilePage() {
</Box>
<Button
variant="outlined"
disabled={!isLocal || !profileCvText.trim() || parsingCvSections}
disabled={editorDisabled || !profileCvText.trim() || parsingCvSections}
onClick={async () => {
setParsingCvSections(true);
try {
@@ -821,33 +832,32 @@ export default function CareerProfilePage() {
)}
</Box>
</Box>
<Box sx={{ mt: workspaceSection === "profile" ? 2 : 0, p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper", display: workspaceSection === "profile" ? "block" : "none" }}>
<Box sx={{ mb: 1.5 }}>
<Typography component="h3" variant="subtitle1" sx={{ fontWeight: 800 }}>{t("profileCvStructuredEditor")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructuredEditorHelp")}</Typography>
</Box>
<Box sx={{ mt: workspaceSection === "profile" ? 2 : 0, display: workspaceSection === "profile" ? "block" : "none" }}>
<PageSection title={t("profileCvStructuredEditor")} description={t("profileCvStructuredEditorHelp")} surface>
<PersonalInformationSection
value={structuredCv.contact}
onChange={(next) => editStructuredCv((prev) => ({ ...prev, contact: next }))}
getMetadata={metaFor}
disabled={editorDisabled}
/>
<Box sx={{ mt: 2, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
<ProfessionalSummarySection value={structuredCv.summary} onChange={(next) => editStructuredCv((prev) => ({ ...prev, summary: next }))} getMetadata={metaFor} />
<SkillsSection value={structuredCv.skills} onChange={(next) => editStructuredCv((prev) => ({ ...prev, skills: next }))} getMetadata={metaFor} />
<InterestsSection value={structuredCv.interests} onChange={(next) => editStructuredCv((prev) => ({ ...prev, interests: next }))} getMetadata={metaFor} />
<ProfessionalSummarySection value={structuredCv.summary} onChange={(next) => editStructuredCv((prev) => ({ ...prev, summary: next }))} getMetadata={metaFor} disabled={editorDisabled} />
<SkillsSection value={structuredCv.skills} onChange={(next) => editStructuredCv((prev) => ({ ...prev, skills: next }))} getMetadata={metaFor} disabled={editorDisabled} />
<InterestsSection value={structuredCv.interests} onChange={(next) => editStructuredCv((prev) => ({ ...prev, interests: next }))} getMetadata={metaFor} disabled={editorDisabled} />
</Box>
<LongTailSections values={{ awards: structuredCv.awards, publications: structuredCv.publications, organisations: structuredCv.organisations, references: structuredCv.references }} onChange={(key, next) => editStructuredCv((prev) => ({ ...prev, [key]: next }))} />
<LongTailSections values={{ awards: structuredCv.awards, publications: structuredCv.publications, organisations: structuredCv.organisations, references: structuredCv.references }} onChange={(key, next) => editStructuredCv((prev) => ({ ...prev, [key]: next }))} disabled={editorDisabled} />
<LanguagesSection value={structuredCv.languages} onChange={(next) => editStructuredCv((prev) => ({ ...prev, languages: next }))} getMetadata={metaFor} />
<LanguagesSection value={structuredCv.languages} onChange={(next) => editStructuredCv((prev) => ({ ...prev, languages: next }))} getMetadata={metaFor} disabled={editorDisabled} />
<WorkExperienceSection value={structuredCv.jobs} onChange={(next) => editStructuredCv((prev) => ({ ...prev, jobs: next }))} />
<WorkExperienceSection value={structuredCv.jobs} onChange={(next) => editStructuredCv((prev) => ({ ...prev, jobs: next }))} disabled={editorDisabled} />
<EducationSection value={structuredCv.education} onChange={(next) => editStructuredCv((prev) => ({ ...prev, education: next }))} />
<EducationSection value={structuredCv.education} onChange={(next) => editStructuredCv((prev) => ({ ...prev, education: next }))} disabled={editorDisabled} />
<OtherSectionsSection value={structuredCv.otherSections} onChange={(next) => editStructuredCv((prev) => ({ ...prev, otherSections: next }))} />
<OtherSectionsSection value={structuredCv.otherSections} onChange={(next) => editStructuredCv((prev) => ({ ...prev, otherSections: next }))} disabled={editorDisabled} />
</PageSection>
</Box>
<Box sx={{ mt: 1, display: workspaceSection === "profile" ? "flex" : "none", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
@@ -860,12 +870,20 @@ export default function CareerProfilePage() {
</Box>
<Box sx={{ gridColumn: "1 / -1", display: workspaceSection === "profile" ? "flex" : "none", justifyContent: "flex-end", gap: 2, flexWrap: "wrap", alignItems: "center" }}>
<Typography variant="body2" color="text.secondary" sx={{ width: "100%" }}>{t("profileCvStructuredEditorHelp")}</Typography>
{saveError !== null ? <Alert severity="error" sx={{ width: "100%" }}>
{t("profileUpdateFailed")}
{saveError ? <Typography variant="body2" sx={{ mt: 0.5 }}>{saveError}</Typography> : null}
</Alert> : null}
{profileDirty ? <Chip size="small" color="warning" variant="outlined" label={t("assetsUnsavedChanges")} /> : null}
<Button
variant="contained"
disabled={!isLocal || loading}
disabled={editorDisabled}
onClick={async () => {
setLoading(true);
if (profileMutationPending.current || editorDisabled) return;
profileMutationPending.current = true;
setSaving(true);
setSaveError(null);
try {
// Save the master profile through the relational source of truth. The endpoint
// persists the structured children + version and keeps the legacy blob projection
@@ -875,15 +893,17 @@ export default function CareerProfilePage() {
setCompleteness(saved.data?.completeness ?? null);
setProfileDirty(false);
toast(t("profileUpdated"), "success");
} catch (e: any) {
const msg = e?.response?.data || e?.message || t("profileUpdateFailed");
toast(String(msg), "error");
} catch (error) {
const message = getApiErrorMessage(error, "");
setSaveError(message);
toast(message || translationRef.current("profileUpdateFailed"), "error");
} finally {
setLoading(false);
profileMutationPending.current = false;
setSaving(false);
}
}}
>
{t("profileSaveChanges")}
{saving ? t("saving") : t("profileSaveChanges")}
</Button>
</Box>
@@ -5,6 +5,7 @@ import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import RichTextField from "../../components/RichTextField";
import { useI18n } from "../../i18n/I18nProvider";
import { PageSection } from "../../layout/PageLayout";
import {
joinLines,
splitLines,
@@ -62,190 +63,182 @@ export function PersonalInformationSection({
value,
onChange,
getMetadata,
disabled,
}: {
value: StructuredCvContact;
onChange: (next: StructuredCvContact) => void;
getMetadata: MetadataLookup;
disabled: boolean;
}) {
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 }}>
<PageSection title={t("careerProfileContactSection")} level="h3">
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "minmax(0, 1fr) minmax(0, 1fr)" }, gap: 1.5 }}>
<Box>
<TextField label={t("profileCvContactFullName")} value={value.fullName ?? ""} onChange={(e) => set({ fullName: e.target.value || undefined })} fullWidth />
<TextField disabled={disabled} 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 />
<TextField disabled={disabled} 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 />
<TextField disabled={disabled} 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 />
<TextField disabled={disabled} 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 />
<TextField disabled={disabled} 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" } }} />
<TextField label="GitHub" value={value.gitHub ?? ""} onChange={(e) => set({ gitHub: e.target.value || undefined })} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
<TextField disabled={disabled} label={t("profileCvContactWebsite")} value={value.website ?? ""} onChange={(e) => set({ website: e.target.value || undefined })} fullWidth />
<TextField disabled={disabled} label={t("profileCvContactLinkedIn")} value={value.linkedIn ?? ""} onChange={(e) => set({ linkedIn: e.target.value || undefined })} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
<TextField disabled={disabled} label="GitHub" value={value.gitHub ?? ""} onChange={(e) => set({ gitHub: e.target.value || undefined })} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
<Box sx={{ gridColumn: "1 / -1" }}>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1 }}><Typography component="h4" variant="subtitle2">{t("profileCvOtherLinks")}</Typography><Button size="small" startIcon={<AddIcon />} onClick={() => set({ links: [...(value.links ?? []), { label: "", url: "" }] })}>{t("profileCvAddLink")}</Button></Stack>
<Stack spacing={1}>{(value.links ?? []).map((link, index) => <Stack key={index} direction={{ xs: "column", sm: "row" }} spacing={1} alignItems={{ sm: "center" }}><TextField size="small" label={t("profileCvLinkLabel")} value={link.label ?? ""} onChange={(event) => set({ links: (value.links ?? []).map((item, itemIndex) => itemIndex === index ? { ...item, label: event.target.value || undefined } : item) })} sx={{ flex: "0 1 180px" }} /><TextField size="small" label={t("profileCvLinkUrl")} value={link.url ?? ""} onChange={(event) => set({ links: (value.links ?? []).map((item, itemIndex) => itemIndex === index ? { ...item, url: event.target.value || undefined } : item) })} fullWidth /><IconButton aria-label={t("profileCvDeleteCustomLink", { index: index + 1 })} onClick={() => set({ links: (value.links ?? []).filter((_, itemIndex) => itemIndex !== index) })}><DeleteOutlineIcon fontSize="small" /></IconButton></Stack>)}</Stack>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1 }}><Typography component="h4" variant="subtitle2">{t("profileCvOtherLinks")}</Typography><Button disabled={disabled} size="small" startIcon={<AddIcon />} onClick={() => set({ links: [...(value.links ?? []), { label: "", url: "" }] })}>{t("profileCvAddLink")}</Button></Stack>
<Stack spacing={1}>{(value.links ?? []).map((link, index) => <Stack key={index} direction={{ xs: "column", sm: "row" }} spacing={1} alignItems={{ sm: "center" }}><TextField disabled={disabled} size="small" label={t("profileCvLinkLabel")} value={link.label ?? ""} onChange={(event) => set({ links: (value.links ?? []).map((item, itemIndex) => itemIndex === index ? { ...item, label: event.target.value || undefined } : item) })} sx={{ flex: { xs: "0 1 auto", sm: "0 1 180px" } }} /><TextField disabled={disabled} size="small" label={t("profileCvLinkUrl")} value={link.url ?? ""} onChange={(event) => set({ links: (value.links ?? []).map((item, itemIndex) => itemIndex === index ? { ...item, url: event.target.value || undefined } : item) })} fullWidth /><IconButton disabled={disabled} aria-label={t("profileCvDeleteCustomLink", { index: index + 1 })} onClick={() => set({ links: (value.links ?? []).filter((_, itemIndex) => itemIndex !== index) })}><DeleteOutlineIcon fontSize="small" /></IconButton></Stack>)}</Stack>
</Box>
</Box>
</PageSection>
);
}
// 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 }) {
function LinesField({ label, value, onChange, metadata, minRows, disabled }: { label: string; value: string[]; onChange: (next: string[]) => void; metadata?: StructuredCvFieldMetadata; minRows: number; disabled: boolean }) {
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 />
<TextField disabled={disabled} 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 }) {
export function ProfessionalSummarySection({ value, onChange, getMetadata, disabled }: { value: string[]; onChange: (next: string[]) => void; getMetadata: MetadataLookup; disabled: boolean }) {
const { t } = useI18n();
return (
<Box>
<RichTextField label={t("profileCvStructuredSummary")} value={joinLines(value)} onChange={(text) => onChange(splitLines(text))} minRows={5} />
<RichTextField disabled={disabled} label={t("profileCvStructuredSummary")} value={joinLines(value)} onChange={(text) => onChange(splitLines(text))} minRows={5} />
<FieldReviewNote metadata={getMetadata("summary")} />
</Box>
);
}
export function SkillsSection({ value, onChange, getMetadata }: { value: string[]; onChange: (next: string[]) => void; getMetadata: MetadataLookup }) {
export function SkillsSection({ value, onChange, getMetadata, disabled }: { value: string[]; onChange: (next: string[]) => void; getMetadata: MetadataLookup; disabled: boolean }) {
const { t } = useI18n();
return <LinesField label={t("profileCvStructuredSkills")} value={value} onChange={onChange} metadata={getMetadata("skills")} minRows={5} />;
return <LinesField label={t("profileCvStructuredSkills")} value={value} onChange={onChange} metadata={getMetadata("skills")} minRows={5} disabled={disabled} />;
}
export function InterestsSection({ value, onChange, getMetadata }: { value: string[]; onChange: (next: string[]) => void; getMetadata: MetadataLookup }) {
export function InterestsSection({ value, onChange, getMetadata, disabled }: { value: string[]; onChange: (next: string[]) => void; getMetadata: MetadataLookup; disabled: boolean }) {
const { t } = useI18n();
return <LinesField label={t("profileCvStructuredInterests")} value={value} onChange={onChange} metadata={getMetadata("interests")} minRows={4} />;
return <LinesField label={t("profileCvStructuredInterests")} value={value} onChange={onChange} metadata={getMetadata("interests")} minRows={4} disabled={disabled} />;
}
export function LongTailSections({ values, onChange }: { values: Record<"awards" | "publications" | "organisations" | "references", string[]>; onChange: (key: keyof typeof values, next: string[]) => void }) {
export function LongTailSections({ values, onChange, disabled }: { values: Record<"awards" | "publications" | "organisations" | "references", string[]>; onChange: (key: keyof typeof values, next: string[]) => void; disabled: boolean }) {
const { t } = useI18n();
const labels = { awards: t("profileCvStructuredAwards"), publications: t("profileCvStructuredPublications"), organisations: t("profileCvStructuredOrganisations"), references: t("profileCvStructuredReferences") };
return <Box sx={{ mt: 2, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>{(Object.keys(values) as (keyof typeof values)[]).map((key) => <LinesField key={key} label={labels[key]} value={values[key]} onChange={(next) => onChange(key, next)} minRows={3} />)}</Box>;
return <Box sx={{ mt: 2, display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "minmax(0, 1fr) minmax(0, 1fr)" }, gap: 1.5 }}>{(Object.keys(values) as (keyof typeof values)[]).map((key) => <LinesField key={key} label={labels[key]} value={values[key]} onChange={(next) => onChange(key, next)} minRows={3} disabled={disabled} />)}</Box>;
}
export function LanguagesSection({ value, onChange, getMetadata }: { value: StructuredCvLanguage[]; onChange: (next: StructuredCvLanguage[]) => void; getMetadata: MetadataLookup }) {
export function LanguagesSection({ value, onChange, getMetadata, disabled }: { value: StructuredCvLanguage[]; onChange: (next: StructuredCvLanguage[]) => void; getMetadata: MetadataLookup; disabled: boolean }) {
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 component="h4" variant="subtitle2" sx={{ fontWeight: 800 }}>{t("profileCvStructuredLanguages")}</Typography>
<Button variant="outlined" size="small" onClick={() => onChange([...value, { name: "", level: "", notes: "" }])}>{t("profileCvStructuredAddLanguage")}</Button>
</Box>
<PageSection title={t("profileCvStructuredLanguages")} level="h3" actions={<Button disabled={disabled} variant="outlined" size="small" onClick={() => onChange([...value, { name: "", level: "", notes: "" }])}>{t("profileCvStructuredAddLanguage")}</Button>}>
<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 key={`language-${index}`} sx={{ p: "var(--app-card-padding)", mt: 1.5, borderRadius: 1, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) auto" }, gap: 1 }}>
<TextField disabled={disabled} label={t("profileCvLanguageName")} value={language.name ?? ""} onChange={(e) => update(index, { name: e.target.value || undefined })} fullWidth />
<TextField disabled={disabled} label={t("profileCvLanguageLevel")} value={language.level ?? ""} onChange={(e) => update(index, { level: e.target.value || undefined })} fullWidth />
<TextField disabled={disabled} label={t("profileCvLanguageNotes")} value={language.notes ?? ""} onChange={(e) => update(index, { notes: e.target.value || undefined })} fullWidth />
<Button disabled={disabled} color="inherit" onClick={() => onChange(value.filter((_, i) => i !== index))} aria-label={t("careerProfileRemoveLanguage", { index: index + 1 })}>{t("profileCvStructuredRemove")}</Button>
</Box>
</Box>
))}
</Box>
</PageSection>
);
}
export function WorkExperienceSection({ value, onChange }: { value: StructuredCvJob[]; onChange: (next: StructuredCvJob[]) => void }) {
export function WorkExperienceSection({ value, onChange, disabled }: { value: StructuredCvJob[]; onChange: (next: StructuredCvJob[]) => void; disabled: boolean }) {
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 component="h4" 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>
<PageSection title={t("profileCvStructuredJobs")} level="h3" actions={<Button disabled={disabled} variant="outlined" size="small" onClick={() => onChange([...value, { title: "", company: "", location: "", start: "", end: "", isCurrent: false, bullets: [], skills: [] }])}>{t("profileCvStructuredAddJob")}</Button>}>
{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 key={`job-${index}`} sx={{ p: "var(--app-card-padding)", mt: 1.5, borderRadius: 1, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "minmax(0, 1fr) minmax(0, 1fr)" }, gap: 1 }}>
<TextField disabled={disabled} label={t("profileCvJobTitle")} value={job.title ?? ""} onChange={(e) => update(index, { title: e.target.value || undefined })} fullWidth />
<TextField disabled={disabled} label={t("profileCvJobCompany")} value={job.company ?? ""} onChange={(e) => update(index, { company: e.target.value || undefined })} fullWidth />
<TextField disabled={disabled} label={t("profileCvJobLocation")} value={job.location ?? ""} onChange={(e) => update(index, { location: e.target.value || undefined })} fullWidth />
<Box sx={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr) minmax(0, 1fr)", gap: 1 }}>
<TextField disabled={disabled} label={t("profileCvJobStart")} value={job.start ?? ""} onChange={(e) => update(index, { start: e.target.value || undefined })} fullWidth />
<TextField disabled={disabled} 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>
<Box sx={{ gridColumn: "1 / -1" }}><RichTextField label={t("profileCvJobBullets")} value={joinLines(job.bullets)} onChange={(text) => update(index, { bullets: splitLines(text) })} minRows={5} /></Box>
<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: "1 / -1" }}><RichTextField disabled={disabled} label={t("profileCvJobBullets")} value={joinLines(job.bullets)} onChange={(text) => update(index, { bullets: splitLines(text) })} minRows={5} /></Box>
<TextField disabled={disabled} 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>
<Button disabled={disabled} color="inherit" onClick={() => onChange(value.filter((_, i) => i !== index))} aria-label={t("careerProfileRemoveJob", { index: index + 1 })}>{t("profileCvStructuredRemove")}</Button>
</Box>
</Box>
</Box>
))}
</Box>
</PageSection>
);
}
export function EducationSection({ value, onChange }: { value: StructuredCvEducation[]; onChange: (next: StructuredCvEducation[]) => void }) {
export function EducationSection({ value, onChange, disabled }: { value: StructuredCvEducation[]; onChange: (next: StructuredCvEducation[]) => void; disabled: boolean }) {
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 component="h4" 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>
<PageSection title={t("profileCvStructuredEducation")} level="h3" actions={<Button disabled={disabled} variant="outlined" size="small" onClick={() => onChange([...value, { qualification: "", institution: "", location: "", start: "", end: "", details: [] }])}>{t("profileCvStructuredAddEducation")}</Button>}>
{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 key={`education-${index}`} sx={{ p: "var(--app-card-padding)", mt: 1.5, borderRadius: 1, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "minmax(0, 1fr) minmax(0, 1fr)" }, gap: 1 }}>
<TextField disabled={disabled} label={t("profileCvEducationQualification")} value={education.qualification ?? ""} onChange={(e) => update(index, { qualification: e.target.value || undefined })} fullWidth />
<TextField disabled={disabled} label={t("profileCvEducationInstitution")} value={education.institution ?? ""} onChange={(e) => update(index, { institution: e.target.value || undefined })} fullWidth />
<TextField disabled={disabled} label={t("profileCvEducationLocation")} value={education.location ?? ""} onChange={(e) => update(index, { location: e.target.value || undefined })} fullWidth />
<Box sx={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr) minmax(0, 1fr)", gap: 1 }}>
<TextField disabled={disabled} label={t("profileCvEducationStart")} value={education.start ?? ""} onChange={(e) => update(index, { start: e.target.value || undefined })} fullWidth />
<TextField disabled={disabled} label={t("profileCvEducationEnd")} value={education.end ?? ""} onChange={(e) => update(index, { end: e.target.value || undefined })} fullWidth />
</Box>
<Box sx={{ gridColumn: "1 / -1" }}><RichTextField label={t("profileCvEducationDetails")} value={joinLines(education.details)} onChange={(text) => update(index, { details: splitLines(text) })} minRows={4} /></Box>
<Box sx={{ gridColumn: "1 / -1" }}><RichTextField disabled={disabled} label={t("profileCvEducationDetails")} value={joinLines(education.details)} onChange={(text) => update(index, { details: splitLines(text) })} minRows={4} /></Box>
<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>
<Button disabled={disabled} color="inherit" onClick={() => onChange(value.filter((_, i) => i !== index))} aria-label={t("careerProfileRemoveEducation", { index: index + 1 })}>{t("profileCvStructuredRemove")}</Button>
</Box>
</Box>
</Box>
))}
</Box>
</PageSection>
);
}
export function OtherSectionsSection({ value, onChange }: { value: StructuredCvOtherSection[]; onChange: (next: StructuredCvOtherSection[]) => void }) {
export function OtherSectionsSection({ value, onChange, disabled }: { value: StructuredCvOtherSection[]; onChange: (next: StructuredCvOtherSection[]) => void; disabled: boolean }) {
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 component="h4" variant="subtitle2" sx={{ fontWeight: 800 }}>{t("profileCvStructuredOtherSections")}</Typography>
<Button variant="outlined" size="small" onClick={() => onChange([...value, { title: "", items: [] }])}>{t("profileCvStructuredAddOtherSection")}</Button>
</Box>
<PageSection title={t("profileCvStructuredOtherSections")} level="h3" actions={<Button disabled={disabled} variant="outlined" size="small" onClick={() => onChange([...value, { title: "", items: [] }])}>{t("profileCvStructuredAddOtherSection")}</Button>}>
{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>
<Box sx={{ gridColumn: "1 / -1" }}><RichTextField label={t("profileCvOtherSectionItems")} value={joinLines(section.items)} onChange={(text) => update(index, { items: splitLines(text) })} minRows={4} /></Box>
<Box key={`other-${index}`} sx={{ p: "var(--app-card-padding)", mt: 1.5, borderRadius: 1, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "minmax(0, 1fr) auto" }, gap: 1 }}>
<TextField disabled={disabled} label={t("profileCvOtherSectionTitle")} value={section.title ?? ""} onChange={(e) => update(index, { title: e.target.value || undefined })} fullWidth />
<Button disabled={disabled} color="inherit" onClick={() => onChange(value.filter((_, i) => i !== index))} aria-label={t("careerProfileRemoveOtherSection", { index: index + 1 })}>{t("profileCvStructuredRemove")}</Button>
<Box sx={{ gridColumn: "1 / -1" }}><RichTextField disabled={disabled} label={t("profileCvOtherSectionItems")} value={joinLines(section.items)} onChange={(text) => update(index, { items: splitLines(text) })} minRows={4} /></Box>
</Box>
</Box>
))}
</Box>
</PageSection>
);
}