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
+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>