feat(career): upload CV from profile
CI and Deploy / test (push) Successful in 6m42s
CI and Deploy / deploy (push) Successful in 1m23s

This commit is contained in:
cesnimda
2026-09-01 16:42:06 +02:00
parent e912b72110
commit 3ec9ac1dad
3 changed files with 126 additions and 23 deletions
+14
View File
@@ -836,6 +836,13 @@ export const translations = {
careerOverviewCareerProfileBody: "Add or improve the facts used in every CV.",
careerOverviewEditProfile: "Edit career profile",
careerOverviewImportBody: "Extract information, then review every proposed change before applying it.",
careerProfileImportTitle: "Import your existing CV",
careerProfileImportBody: "Upload a PDF, DOCX, text file, or image. We will extract the details and let you review them before updating your career profile.",
careerProfileImportProgress: "CV extraction progress",
careerProfileImportProcessing: "Extracting and structuring your CV…",
careerProfileImportPercent: "Extracting and structuring your CV… {percent}%",
careerProfileImportReady: "Your CV details are ready to review.",
careerProfileImportReview: "Review details",
careerOverviewGeneralCv: "General CV",
careerOverviewGeneralCvBody: "Create and manage reusable CV documents from your profile.",
careerOverviewCreateGeneralCv: "Create a general CV",
@@ -3258,6 +3265,13 @@ export const translations = {
careerOverviewCareerProfileBody: "Legg til eller forbedre fakta som brukes i alle CV-er.",
careerOverviewEditProfile: "Rediger karriereprofil",
careerOverviewImportBody: "Trekk ut informasjon, og se deretter gjennom alle foreslåtte endringer før de brukes.",
careerProfileImportTitle: "Importer den eksisterende CV-en din",
careerProfileImportBody: "Last opp PDF, DOCX, tekstfil eller bilde. Vi trekker ut opplysningene og lar deg se gjennom dem før karriereprofilen oppdateres.",
careerProfileImportProgress: "Fremdrift for CV-uttrekk",
careerProfileImportProcessing: "Trekker ut og strukturerer CV-en…",
careerProfileImportPercent: "Trekker ut og strukturerer CV-en… {percent} %",
careerProfileImportReady: "CV-opplysningene er klare til gjennomgang.",
careerProfileImportReview: "Se gjennom opplysninger",
careerOverviewGeneralCv: "Generell CV",
careerOverviewGeneralCvBody: "Opprett og administrer gjenbrukbare CV-dokumenter fra profilen din.",
careerOverviewCreateGeneralCv: "Opprett en generell CV",
+46
View File
@@ -416,6 +416,52 @@ test('/career shows the profile completeness overview', async () => {
expect(await screen.findByText(/70%/)).toBeInTheDocument();
});
test('/career profile exposes extraction progress', async () => {
extractionRunsResponse = [{
id: 15,
trigger: 'upload',
status: 'running',
artifactFileName: 'career.pdf',
startedAtUtc: '2026-03-28T12:00:00Z',
parserVersion: 'm005-s01',
normalizerVersion: 'm005-s01',
llmPromptVersion: 'm005-s01',
operation: {
id: '00000000-0000-0000-0000-000000000015',
taskType: 'cv.process',
status: 'running',
createdAtUtc: '2026-03-28T12:00:00Z',
progressPercent: 42,
canCancel: true,
canRetry: false,
},
}];
const { container } = renderPage('profile');
expect(await screen.findByRole('progressbar', { name: /cv extraction progress/i })).toHaveAttribute('aria-valuenow', '42');
expect(screen.getByText(/42%/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /upload cv/i })).toBeDisabled();
extractionRunsResponse = [];
const fileInput = container.querySelector<HTMLInputElement>('input[type="file"][accept*=".pdf"]');
expect(fileInput).not.toBeNull();
});
test('/career profile can queue a CV upload without visiting import review first', async () => {
extractionRunsResponse = [];
mockedApi.post.mockResolvedValueOnce({ data: { queued: true, extractionRunId: 22, status: 'queued', created: true } } as any);
const { container } = renderPage('profile');
expect(await screen.findByRole('button', { name: /upload cv/i })).toBeEnabled();
const fileInput = container.querySelector<HTMLInputElement>('input[type="file"][accept*=".pdf"]')!;
fireEvent.change(fileInput, { target: { files: [new File(['resume'], 'resume.pdf', { type: 'application/pdf' })] } });
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/profile-cv/upload', expect.any(FormData), {
headers: { 'Content-Type': 'multipart/form-data' },
}));
});
test('/career localizes completeness and review confidence without changing CV data', async () => {
window.localStorage.setItem('uiLanguage', 'nb');
renderWith(CareerProfilePage);
+66 -23
View File
@@ -5,6 +5,7 @@ import { Accordion, AccordionDetails, AccordionSummary, Alert, Avatar, Box, Butt
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import PhotoCameraOutlinedIcon from "@mui/icons-material/PhotoCameraOutlined";
import UploadFileOutlinedIcon from "@mui/icons-material/UploadFileOutlined";
import { api, getApiErrorMessage } from "../api";
import CropImageDialog from "../components/CropImageDialog";
@@ -223,6 +224,21 @@ export default function CareerProfilePage() {
}
}, []);
const queueCvUpload = useCallback(async (file: File) => {
const formData = new FormData();
formData.append("file", file);
setUploadingCv(true);
try {
const res = await api.post<QueuedCvRunResponse>("/profile-cv/upload", formData, { headers: { "Content-Type": "multipart/form-data" } });
await loadExtractionRuns();
toast(t("profileCvQueuedUpload", { id: res.data.extractionRunId }), "info");
} catch (error) {
toast(getApiErrorMessage(error, t("profileCvUploadFailed")), "error");
} finally {
setUploadingCv(false);
}
}, [loadExtractionRuns, t, toast]);
const loadProfile = useCallback(async () => {
setLoading(true);
try {
@@ -310,6 +326,11 @@ export default function CareerProfilePage() {
: t("profileGoogleNotLinked");
const cvLabel = profileCvText.trim() ? t("profileCvReady", { count: cvWordCount }) : t("profileCvMissing");
const latestRun = extractionRuns[0];
const activeCvRun = extractionRuns.find((run) => run.operation
? activeOperationStatuses.has(run.operation.status)
: run.status === "queued" || run.status === "running");
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" }}>
@@ -353,6 +374,17 @@ export default function CareerProfilePage() {
{workspaceSection === "evidence" ? <CareerEvidenceBank /> : 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 }}>
<input
ref={cvInputRef}
type="file"
accept={CV_UPLOAD_ACCEPT}
style={{ display: "none" }}
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) void queueCvUpload(file);
}}
/>
{workspaceSection === "profile" ? <ProfileCompleteness
completeness={completeness}
versions={versions}
@@ -395,6 +427,40 @@ export default function CareerProfilePage() {
</Alert>
) : null}
{workspaceSection === "profile" ? <Paper variant="outlined" sx={{ mb: 2.5, p: { xs: 1.5, sm: 2 }, borderRadius: 3 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
<Box sx={{ minWidth: 0 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>{t("careerProfileImportTitle")}</Typography>
<Typography variant="body2" color="text.secondary">{t("careerProfileImportBody")}</Typography>
</Box>
<Button
variant="contained"
startIcon={<UploadFileOutlinedIcon />}
disabled={!canUseAi || !isLocal || uploadingCv || Boolean(activeCvRun)}
onClick={() => cvInputRef.current?.click()}
>
{uploadingCv ? t("profileUploading") : t("profileUploadCv")}
</Button>
</Box>
{uploadingCv || activeCvRun ? <Box sx={{ mt: 1.5 }}>
<LinearProgress
variant={typeof extractionProgress === "number" ? "determinate" : "indeterminate"}
value={extractionProgress ?? undefined}
aria-label={t("careerProfileImportProgress")}
sx={{ height: 8, borderRadius: 999 }}
/>
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 0.75 }}>
{typeof extractionProgress === "number"
? t("careerProfileImportPercent", { percent: extractionProgress })
: t("careerProfileImportProcessing")}
</Typography>
</Box> : null}
{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}
</Paper> : null}
{workspaceSection === "profile" ? <Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 1 }}>
@@ -478,29 +544,6 @@ export default function CareerProfilePage() {
</Typography>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<input
ref={cvInputRef}
type="file"
accept={CV_UPLOAD_ACCEPT}
style={{ display: "none" }}
onChange={async (event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
const formData = new FormData();
formData.append("file", file);
setUploadingCv(true);
try {
const res = await api.post<QueuedCvRunResponse>("/profile-cv/upload", formData, { headers: { "Content-Type": "multipart/form-data" } });
await loadExtractionRuns();
toast(t("profileCvQueuedUpload", { id: res.data.extractionRunId }), "info");
} catch (e: any) {
toast(String(e?.response?.data || e?.message || t("profileCvUploadFailed")), "error");
} finally {
setUploadingCv(false);
}
}}
/>
<Button variant="outlined" disabled={!canUseAi || !isLocal || uploadingCv || improvingCv || rebuildingCv} onClick={() => cvInputRef.current?.click()}>
{uploadingCv ? t("profileUploading") : t("profileUploadCv")}
</Button>