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:
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user