248 lines
18 KiB
TypeScript
248 lines
18 KiB
TypeScript
import React from "react";
|
|
import { Box, Button, Chip, IconButton, Stack, TextField, Typography } from "@mui/material";
|
|
import AddIcon from "@mui/icons-material/Add";
|
|
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
|
|
|
import RichTextField from "../../components/RichTextField";
|
|
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" } }} />
|
|
<TextField 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 variant="subtitle2">Other links</Typography><Button size="small" startIcon={<AddIcon />} onClick={() => set({ links: [...(value.links ?? []), { label: "", url: "" }] })}>Add link</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="Label" 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="URL" 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={`Delete custom link ${index + 1}`} onClick={() => set({ links: (value.links ?? []).filter((_, itemIndex) => itemIndex !== index) })}><DeleteOutlineIcon fontSize="small" /></IconButton></Stack>)}</Stack>
|
|
</Box>
|
|
</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 (
|
|
<Box>
|
|
<RichTextField 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 }) {
|
|
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 LongTailSections({ values, onChange }: { values: Record<"awards" | "publications" | "organisations" | "references", string[]>; onChange: (key: keyof typeof values, next: string[]) => void }) {
|
|
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>;
|
|
}
|
|
|
|
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>
|
|
<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: { 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>
|
|
<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: { 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>
|
|
<Box sx={{ gridColumn: "1 / -1" }}><RichTextField label={t("profileCvOtherSectionItems")} value={joinLines(section.items)} onChange={(text) => update(index, { items: splitLines(text) })} minRows={4} /></Box>
|
|
</Box>
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
);
|
|
}
|