feat(career): upload CV from profile
This commit is contained in:
@@ -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