feat(career): upload CV from profile
This commit is contained in:
@@ -836,6 +836,13 @@ export const translations = {
|
|||||||
careerOverviewCareerProfileBody: "Add or improve the facts used in every CV.",
|
careerOverviewCareerProfileBody: "Add or improve the facts used in every CV.",
|
||||||
careerOverviewEditProfile: "Edit career profile",
|
careerOverviewEditProfile: "Edit career profile",
|
||||||
careerOverviewImportBody: "Extract information, then review every proposed change before applying it.",
|
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",
|
careerOverviewGeneralCv: "General CV",
|
||||||
careerOverviewGeneralCvBody: "Create and manage reusable CV documents from your profile.",
|
careerOverviewGeneralCvBody: "Create and manage reusable CV documents from your profile.",
|
||||||
careerOverviewCreateGeneralCv: "Create a general CV",
|
careerOverviewCreateGeneralCv: "Create a general CV",
|
||||||
@@ -3258,6 +3265,13 @@ export const translations = {
|
|||||||
careerOverviewCareerProfileBody: "Legg til eller forbedre fakta som brukes i alle CV-er.",
|
careerOverviewCareerProfileBody: "Legg til eller forbedre fakta som brukes i alle CV-er.",
|
||||||
careerOverviewEditProfile: "Rediger karriereprofil",
|
careerOverviewEditProfile: "Rediger karriereprofil",
|
||||||
careerOverviewImportBody: "Trekk ut informasjon, og se deretter gjennom alle foreslåtte endringer før de brukes.",
|
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",
|
careerOverviewGeneralCv: "Generell CV",
|
||||||
careerOverviewGeneralCvBody: "Opprett og administrer gjenbrukbare CV-dokumenter fra profilen din.",
|
careerOverviewGeneralCvBody: "Opprett og administrer gjenbrukbare CV-dokumenter fra profilen din.",
|
||||||
careerOverviewCreateGeneralCv: "Opprett en generell CV",
|
careerOverviewCreateGeneralCv: "Opprett en generell CV",
|
||||||
|
|||||||
@@ -416,6 +416,52 @@ test('/career shows the profile completeness overview', async () => {
|
|||||||
expect(await screen.findByText(/70%/)).toBeInTheDocument();
|
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 () => {
|
test('/career localizes completeness and review confidence without changing CV data', async () => {
|
||||||
window.localStorage.setItem('uiLanguage', 'nb');
|
window.localStorage.setItem('uiLanguage', 'nb');
|
||||||
renderWith(CareerProfilePage);
|
renderWith(CareerProfilePage);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Accordion, AccordionDetails, AccordionSummary, Alert, Avatar, Box, Butt
|
|||||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||||
import PhotoCameraOutlinedIcon from "@mui/icons-material/PhotoCameraOutlined";
|
import PhotoCameraOutlinedIcon from "@mui/icons-material/PhotoCameraOutlined";
|
||||||
|
import UploadFileOutlinedIcon from "@mui/icons-material/UploadFileOutlined";
|
||||||
|
|
||||||
import { api, getApiErrorMessage } from "../api";
|
import { api, getApiErrorMessage } from "../api";
|
||||||
import CropImageDialog from "../components/CropImageDialog";
|
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 () => {
|
const loadProfile = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -310,6 +326,11 @@ export default function CareerProfilePage() {
|
|||||||
: t("profileGoogleNotLinked");
|
: t("profileGoogleNotLinked");
|
||||||
const cvLabel = profileCvText.trim() ? t("profileCvReady", { count: cvWordCount }) : t("profileCvMissing");
|
const cvLabel = profileCvText.trim() ? t("profileCvReady", { count: cvWordCount }) : t("profileCvMissing");
|
||||||
const latestRun = extractionRuns[0];
|
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 (
|
return (
|
||||||
<Box sx={{ display: "grid", gap: 2 }}>
|
<Box sx={{ display: "grid", gap: 2 }}>
|
||||||
<Paper component="nav" aria-label={t("careerWorkspaceNavigation")} variant="outlined" sx={{ borderRadius: 3, overflow: "hidden" }}>
|
<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 === "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 }}>
|
{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
|
{workspaceSection === "profile" ? <ProfileCompleteness
|
||||||
completeness={completeness}
|
completeness={completeness}
|
||||||
versions={versions}
|
versions={versions}
|
||||||
@@ -395,6 +427,40 @@ export default function CareerProfilePage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : 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" }}>
|
{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", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 1 }}>
|
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 1 }}>
|
||||||
@@ -478,29 +544,6 @@ export default function CareerProfilePage() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
<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()}>
|
<Button variant="outlined" disabled={!canUseAi || !isLocal || uploadingCv || improvingCv || rebuildingCv} onClick={() => cvInputRef.current?.click()}>
|
||||||
{uploadingCv ? t("profileUploading") : t("profileUploadCv")}
|
{uploadingCv ? t("profileUploading") : t("profileUploadCv")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
Reference in New Issue
Block a user