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.",
|
||||
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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user