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:
cesnimda
2026-08-29 16:44:44 +02:00
parent 4d4af47651
commit 9edcbfc5de
29 changed files with 827 additions and 236 deletions
@@ -333,7 +333,7 @@ function OverviewSection({ overview, onGo, onReload, onEdit }: {
</Alert>
)}
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr 1fr", md: "repeat(5, 1fr)" }, gap: 1.5 }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))", lg: "repeat(5, minmax(0, 1fr))" }, gap: 1.5 }}>
{stats.map((s) => (
<Paper key={s.label} variant="outlined" role="button" tabIndex={0}
onClick={() => onGo(s.go)}
+69 -15
View File
@@ -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>
);
}
@@ -30,12 +30,13 @@ function isActive(run: CareerWorkspaceImportRun) {
: run.status === "queued" || run.status === "running";
}
function ActionCard({ title, body, href, label, icon }: {
function ActionCard({ title, body, href, label, icon, onNavigate }: {
title: string;
body: string;
href: string;
label: string;
icon: React.ReactNode;
onNavigate?: () => void;
}) {
return (
<Paper variant="outlined" component="article" sx={{ p: 2, borderRadius: 3, display: "flex", flexDirection: "column", gap: 1.25 }}>
@@ -44,16 +45,24 @@ function ActionCard({ title, body, href, label, icon }: {
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>{title}</Typography>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ flex: 1 }}>{body}</Typography>
<Button href={href} variant="text" sx={{ alignSelf: "flex-start", px: 0.5 }}>{label}</Button>
<Button
href={href}
onClick={onNavigate ? (event) => { event.preventDefault(); onNavigate(); } : undefined}
variant="text"
sx={{ alignSelf: "flex-start", px: 0.5 }}
>
{label}
</Button>
</Paper>
);
}
export default function CareerWorkspaceOverview({ completeness, runs, loading, loadError }: {
export default function CareerWorkspaceOverview({ completeness, runs, loading, loadError, onNavigate }: {
completeness: CareerWorkspaceCompleteness | null;
runs: CareerWorkspaceImportRun[];
loading: boolean;
loadError: string | null;
onNavigate?: (section: "profile" | "import") => void;
}) {
const { language, t } = useI18n();
const [recentCvs, setRecentCvs] = useState<CvVariantSummary[]>([]);
@@ -93,11 +102,11 @@ export default function CareerWorkspaceOverview({ completeness, runs, loading, l
const isFirstRun = !loading && !recentLoading && !loadError && (completeness?.percent ?? 0) === 0 && runs.length === 0 && recentCvs.length === 0;
return (
<Box component="section" aria-labelledby="career-workspace-title" sx={{ display: "grid", gap: 2 }}>
<Box component="section" aria-labelledby="career-workspace-overview-title" sx={{ display: "grid", gap: 2 }}>
<Paper sx={{ p: { xs: 2, sm: 2.5 }, borderRadius: 4 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 2, flexWrap: "wrap" }}>
<Box>
<Typography id="career-workspace-title" component="h1" variant="h5" sx={{ fontWeight: 900 }}>{t("careerOverviewTitle")}</Typography>
<Typography id="career-workspace-overview-title" component="h2" variant="h5" sx={{ fontWeight: 900 }}>{t("careerWorkspaceOverviewTab")}</Typography>
<Typography color="text.secondary">{t("careerOverviewSubtitle")}</Typography>
</Box>
<Button variant="contained" startIcon={<DescriptionOutlinedIcon />} href="/career/builder">{t("careerOverviewOpenBuilder")}</Button>
@@ -116,20 +125,20 @@ export default function CareerWorkspaceOverview({ completeness, runs, loading, l
{!loading && completeness?.missing.length ? (
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>{t("careerOverviewAddNext", { items: completeness.missing.slice(0, 3).join(", ") })}</Typography>
) : null}
<Button href="#career-profile-editor" size="small" sx={{ mt: 1, px: 0.5 }}>{t("careerOverviewImproveProfile")}</Button>
<Button href="/career?section=profile" onClick={onNavigate ? (event) => { event.preventDefault(); onNavigate("profile"); } : undefined} size="small" sx={{ mt: 1, px: 0.5 }}>{t("careerOverviewImproveProfile")}</Button>
</Box>
<Alert severity={importState.severity} sx={{ borderRadius: 3, alignItems: "flex-start" }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>{t("careerOverviewCvImport")}</Typography>
<Typography variant="body2">{importState.message}</Typography>
<Button href="#career-cv-import" color="inherit" size="small" sx={{ mt: 0.75, px: 0.5 }}>{importState.label}</Button>
<Button href="/career?section=import" onClick={onNavigate ? (event) => { event.preventDefault(); onNavigate("import"); } : undefined} color="inherit" size="small" sx={{ mt: 0.75, px: 0.5 }}>{importState.label}</Button>
</Alert>
</Box>
</Paper>
<Box component="nav" aria-label={t("careerOverviewActions")} sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))", xl: "repeat(4, minmax(0, 1fr))" }, gap: 1.5 }}>
<ActionCard title={t("careerOverviewCareerProfile")} body={t("careerOverviewCareerProfileBody")} href="#career-profile-editor" label={t("careerOverviewEditProfile")} icon={<PersonOutlineIcon />} />
<ActionCard title={t("careerOverviewImportCv")} body={t("careerOverviewImportBody")} href="#career-cv-import" label={importState.label} icon={<UploadFileOutlinedIcon />} />
<ActionCard title={t("careerOverviewCareerProfile")} body={t("careerOverviewCareerProfileBody")} href="/career?section=profile" onNavigate={onNavigate ? () => onNavigate("profile") : undefined} label={t("careerOverviewEditProfile")} icon={<PersonOutlineIcon />} />
<ActionCard title={t("careerOverviewImportCv")} body={t("careerOverviewImportBody")} href="/career?section=import" onNavigate={onNavigate ? () => onNavigate("import") : undefined} label={importState.label} icon={<UploadFileOutlinedIcon />} />
<ActionCard title={t("careerOverviewGeneralCv")} body={t("careerOverviewGeneralCvBody")} href="/career/builder" label={t("careerOverviewCreateGeneralCv")} icon={<DescriptionOutlinedIcon />} />
<ActionCard title={t("careerOverviewJobCv")} body={t("careerOverviewJobCvBody")} href="/jobs" label={t("careerOverviewChooseJob")} icon={<WorkOutlineIcon />} />
</Box>