feat(workspace): refine career workflows
Make job/CV comparisons language-aware and filter recruitment noise. Improve responsive career navigation, shared spacing, dashboard priorities, settings, localized workspace controls, and portable browser tests.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { Accordion, AccordionDetails, AccordionSummary, Alert, Avatar, Box, Button, Checkbox, Chip, Divider, FormControlLabel, LinearProgress, Paper, TextField, Typography } from "@mui/material";
|
||||
import { Accordion, AccordionDetails, AccordionSummary, Alert, Avatar, Box, Button, Checkbox, Chip, FormControl, FormControlLabel, InputLabel, LinearProgress, MenuItem, Paper, Select, Tab, Tabs, TextField, Typography } from "@mui/material";
|
||||
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
@@ -137,6 +137,13 @@ type CareerSectionStatus = { key: string; label: string; complete: boolean; coun
|
||||
type CareerCompleteness = { percent: number; missing: string[]; sections: CareerSectionStatus[] };
|
||||
type CareerProfileResponse = { profile: StructuredCvProfile; completeness: CareerCompleteness; cvText?: string | null };
|
||||
type CareerVersion = { version: number; source: string; createdAtUtc: string; isCurrent: boolean };
|
||||
type CareerWorkspaceSection = "overview" | "profile" | "import";
|
||||
|
||||
function initialWorkspaceSection(): CareerWorkspaceSection {
|
||||
if (typeof window === "undefined") return "overview";
|
||||
const section = new URLSearchParams(window.location.search).get("section");
|
||||
return section === "profile" || section === "import" ? section : "overview";
|
||||
}
|
||||
|
||||
// CareerProfilePage backs /career: the master career profile — the single editable source of
|
||||
// truth for all future generated documents. Split out from ProfilePage in Phase 2.2; wired to the
|
||||
@@ -169,6 +176,16 @@ export default function CareerProfilePage() {
|
||||
const [versions, setVersions] = useState<CareerVersion[]>([]);
|
||||
// The raw import/section parser remains available as an advanced recovery tool.
|
||||
const [showAdvancedCvTools, setShowAdvancedCvTools] = useState(false);
|
||||
const [workspaceSection, setWorkspaceSection] = useState<CareerWorkspaceSection>(initialWorkspaceSection);
|
||||
|
||||
const navigateWorkspace = useCallback((next: CareerWorkspaceSection) => {
|
||||
setWorkspaceSection(next);
|
||||
if (typeof window === "undefined") return;
|
||||
const url = new URL(window.location.href);
|
||||
if (next === "overview") url.searchParams.delete("section");
|
||||
else url.searchParams.set("section", next);
|
||||
window.history.replaceState(window.history.state, "", `${url.pathname}${url.search}${url.hash}`);
|
||||
}, []);
|
||||
|
||||
const loadVersions = useCallback(async () => {
|
||||
try {
|
||||
@@ -303,16 +320,51 @@ export default function CareerProfilePage() {
|
||||
const latestRun = extractionRuns[0];
|
||||
return (
|
||||
<Box sx={{ display: "grid", gap: 2 }}>
|
||||
<CareerWorkspaceOverview completeness={completeness} runs={extractionRuns} loading={loading} loadError={loadError} />
|
||||
<Paper id="career-profile-editor" 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 }}>
|
||||
<ProfileCompleteness
|
||||
<Paper component="nav" aria-label={t("careerWorkspaceNavigation")} variant="outlined" sx={{ borderRadius: 3, overflow: "hidden" }}>
|
||||
<FormControl size="small" fullWidth sx={{ display: { xs: "flex", sm: "none" }, p: 1.25 }}>
|
||||
<InputLabel id="career-workspace-section-label" sx={{ ml: 1.25 }}>{t("careerWorkspaceNavigation")}</InputLabel>
|
||||
<Select
|
||||
labelId="career-workspace-section-label"
|
||||
value={workspaceSection}
|
||||
label={t("careerWorkspaceNavigation")}
|
||||
onChange={(event) => navigateWorkspace(event.target.value as CareerWorkspaceSection)}
|
||||
>
|
||||
<MenuItem value="overview">{t("careerWorkspaceOverviewTab")}</MenuItem>
|
||||
<MenuItem value="profile">{t("careerWorkspaceProfileTab")}</MenuItem>
|
||||
<MenuItem value="import">{t("careerWorkspaceImportTab")}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Tabs
|
||||
value={workspaceSection}
|
||||
onChange={(_, value: CareerWorkspaceSection) => navigateWorkspace(value)}
|
||||
aria-label={t("careerWorkspaceNavigation")}
|
||||
sx={{ display: { xs: "none", sm: "flex" }, px: 1 }}
|
||||
>
|
||||
<Tab value="overview" label={t("careerWorkspaceOverviewTab")} />
|
||||
<Tab value="profile" label={t("careerWorkspaceProfileTab")} />
|
||||
<Tab value="import" label={t("careerWorkspaceImportTab")} />
|
||||
</Tabs>
|
||||
</Paper>
|
||||
|
||||
{workspaceSection === "overview" ? (
|
||||
<CareerWorkspaceOverview
|
||||
completeness={completeness}
|
||||
runs={extractionRuns}
|
||||
loading={loading}
|
||||
loadError={loadError}
|
||||
onNavigate={navigateWorkspace}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{workspaceSection !== "overview" ? <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" ? <ProfileCompleteness
|
||||
completeness={completeness}
|
||||
versions={versions}
|
||||
loading={loading}
|
||||
onRestore={(version) => void restoreVersion(version)}
|
||||
showSummary={false}
|
||||
/>
|
||||
<CropImageDialog
|
||||
/> : null}
|
||||
{workspaceSection === "profile" ? <CropImageDialog
|
||||
open={cropOpen}
|
||||
file={avatarFile}
|
||||
onClose={() => {
|
||||
@@ -338,7 +390,7 @@ export default function CareerProfilePage() {
|
||||
setUploadingAvatar(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
/> : null}
|
||||
|
||||
{loadError ? (
|
||||
<Alert severity="error" sx={{ mb: 2, borderRadius: 2.5 }} action={<Button color="inherit" size="small" onClick={() => void loadProfile()}>Retry</Button>}>
|
||||
@@ -347,7 +399,7 @@ export default function CareerProfilePage() {
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<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", flexDirection: "column", alignItems: "center", gap: 1 }}>
|
||||
<Avatar src={me?.avatarImageDataUrl || undefined} sx={{ width: 84, height: 84, fontWeight: 900, fontSize: 28 }}>{initials}</Avatar>
|
||||
@@ -395,7 +447,7 @@ export default function CareerProfilePage() {
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 900 }}>
|
||||
{careerOnly ? "Career profile" : t("profileTitle")}
|
||||
{careerOnly ? t("careerWorkspaceProfileTab") : t("profileTitle")}
|
||||
</Typography>
|
||||
<Typography sx={{ color: "text.secondary" }}>{me?.userName || me?.displayName || fullName || me?.email || "-"}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{headline || t("profileHeadlinePlaceholder")}</Typography>
|
||||
@@ -406,13 +458,14 @@ export default function CareerProfilePage() {
|
||||
<Chip label={googleLabel} color={me?.googleLink?.linked ? "success" : "default"} variant={me?.googleLink?.linked ? "filled" : "outlined"} />
|
||||
<Chip label={cvLabel} color={profileCvText.trim() ? "success" : "warning"} variant={profileCvText.trim() ? "filled" : "outlined"} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box> : null}
|
||||
|
||||
|
||||
<Box sx={{ mt: 3, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
||||
|
||||
|
||||
<Box id="career-cv-import" sx={{ gridColumn: "1 / -1", p: { xs: 1.5, sm: 2 }, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default", display: careerOnly ? "block" : "none", scrollMarginTop: 96 }}>
|
||||
<Box sx={{ gridColumn: "1 / -1", p: workspaceSection === "import" ? { xs: 1.5, sm: 2 } : 0, borderRadius: 3, border: workspaceSection === "import" ? "1px solid" : "none", borderColor: "divider", backgroundColor: workspaceSection === "import" ? "background.default" : "transparent", display: careerOnly ? "block" : "none" }}>
|
||||
<Box sx={{ display: workspaceSection === "import" ? "block" : "none" }}>
|
||||
{!canUseAi && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<ProFeatureNotice featureKey="career-ai" title="Build your Career Profile faster with Pro.">
|
||||
@@ -710,7 +763,8 @@ export default function CareerProfilePage() {
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructureEmpty")}</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ mt: 2, p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
|
||||
</Box>
|
||||
<Box sx={{ mt: workspaceSection === "profile" ? 2 : 0, p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper", display: workspaceSection === "profile" ? "block" : "none" }}>
|
||||
<Box sx={{ mb: 1.5 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>{t("profileCvStructuredEditor")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructuredEditorHelp")}</Typography>
|
||||
@@ -738,7 +792,7 @@ export default function CareerProfilePage() {
|
||||
|
||||
<OtherSectionsSection value={structuredCv.otherSections} onChange={(next) => editStructuredCv((prev) => ({ ...prev, otherSections: next }))} />
|
||||
</Box>
|
||||
<Box sx={{ mt: 1, display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
|
||||
<Box sx={{ mt: 1, display: workspaceSection === "profile" ? "flex" : "none", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{cvWordCount} words
|
||||
</Typography>
|
||||
@@ -748,7 +802,7 @@ export default function CareerProfilePage() {
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end", gap: 2, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<Box sx={{ gridColumn: "1 / -1", display: workspaceSection === "profile" ? "flex" : "none", justifyContent: "flex-end", gap: 2, flexWrap: "wrap", alignItems: "center" }}>
|
||||
{profileDirty ? <Chip size="small" color="warning" variant="outlined" label="Unsaved changes" /> : null}
|
||||
<Button
|
||||
variant="contained"
|
||||
@@ -779,7 +833,7 @@ export default function CareerProfilePage() {
|
||||
|
||||
</Box>
|
||||
|
||||
</Paper>
|
||||
</Paper> : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user