diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 4a8329f..97b0fc9 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -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", diff --git a/job-tracker-ui/src/profile-page.test.tsx b/job-tracker-ui/src/profile-page.test.tsx index d7caa2c..5b9d382 100644 --- a/job-tracker-ui/src/profile-page.test.tsx +++ b/job-tracker-ui/src/profile-page.test.tsx @@ -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('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('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); diff --git a/job-tracker-ui/src/views/CareerProfilePage.tsx b/job-tracker-ui/src/views/CareerProfilePage.tsx index 76edff9..db5e009 100644 --- a/job-tracker-ui/src/views/CareerProfilePage.tsx +++ b/job-tracker-ui/src/views/CareerProfilePage.tsx @@ -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("/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 ( @@ -353,6 +374,17 @@ export default function CareerProfilePage() { {workspaceSection === "evidence" ? : null} {workspaceSection === "profile" || workspaceSection === "import" ? + { + const file = event.target.files?.[0]; + event.target.value = ""; + if (file) void queueCvUpload(file); + }} + /> {workspaceSection === "profile" ? ) : null} + {workspaceSection === "profile" ? + + + {t("careerProfileImportTitle")} + {t("careerProfileImportBody")} + + + + {uploadingCv || activeCvRun ? + + + {typeof extractionProgress === "number" + ? t("careerProfileImportPercent", { percent: extractionProgress }) + : t("careerProfileImportProcessing")} + + : null} + {pendingCvRun ? navigateWorkspace("import")}>{t("careerProfileImportReview")}}> + {t("careerProfileImportReady")} + : null} + {!canUseAi ? {t("profileProImportBody")} : null} + : null} + {workspaceSection === "profile" ? @@ -478,29 +544,6 @@ export default function CareerProfilePage() { - { - 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("/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); - } - }} - />