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:
@@ -104,7 +104,7 @@ function buildLinePath(values: number[], width: number, height: number) {
|
||||
// theme.ts (see 02-dashboard.png).
|
||||
function SectionCard({ children, sx = {} }: { children: React.ReactNode; sx?: any }) {
|
||||
return (
|
||||
<Card sx={{ p: { xs: 1.5, sm: 2.25 }, ...sx }}>
|
||||
<Card sx={{ p: "var(--app-card-padding)", ...sx }}>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
@@ -208,7 +208,7 @@ export default function DashboardView() {
|
||||
const trendsView = useMemo(() => {
|
||||
const appliedValues = analytics.map((x) => x.applied);
|
||||
const responseValues = analytics.map((x) => x.responses);
|
||||
const chartWidth = isMobile ? Math.max(420, analytics.length * 70) : 860;
|
||||
const chartWidth = 860;
|
||||
const chartHeight = isMobile ? 210 : 250;
|
||||
const totalApplied = appliedValues.reduce((sum, value) => sum + value, 0);
|
||||
const totalResponses = responseValues.reduce((sum, value) => sum + value, 0);
|
||||
@@ -280,29 +280,80 @@ export default function DashboardView() {
|
||||
}, [navigate]);
|
||||
|
||||
const timeInStageMax = overview?.timeInStage?.length ? Math.max(...overview.timeInStage.map((item) => item.medianDays), 1) : 1;
|
||||
const hasJobs = (stats?.total ?? 0) > 0;
|
||||
const hasActivityData = analytics.some((item) => item.applied > 0 || item.responses > 0);
|
||||
const funnelItems = (overview?.funnel ?? []).filter((item) => item.count > 0);
|
||||
const hasStageData = Boolean(overview?.timeInStage?.length);
|
||||
const hasCompanies = Boolean(overview?.topCompanies?.length);
|
||||
const hasSkills = tags.length > 0;
|
||||
|
||||
const priorityPanel = !summaryResource.loading && !summaryResource.error && hasJobs ? (
|
||||
<SectionCard>
|
||||
<Typography variant="h6" sx={{ mb: 0.5 }}>{t("dashboardTodayTitle")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("dashboardTodayBody")}</Typography>
|
||||
{summaryView.priorityJobs.length === 0 ? (
|
||||
<Typography sx={{ color: "text.secondary" }}>{t("remindersNothing")}</Typography>
|
||||
) : (
|
||||
<Stack spacing={1.1}>
|
||||
{summaryView.priorityJobs.map((job) => {
|
||||
const action = getReminderAction(job);
|
||||
const tone = reminderTone(job);
|
||||
const toneColor = theme.palette[tone].main;
|
||||
const urgent = tone === "error";
|
||||
return (
|
||||
<Box
|
||||
key={job.id}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
pl: 2,
|
||||
borderRadius: 2,
|
||||
borderLeft: `4px solid ${toneColor}`,
|
||||
backgroundColor: alpha(toneColor, 0.07),
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
gap: 2,
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ minWidth: 0, display: "flex", gap: 1.25, alignItems: "flex-start" }}>
|
||||
<Box sx={{ width: 8, height: 8, mt: 0.75, borderRadius: "50%", backgroundColor: toneColor, flexShrink: 0 }} />
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography sx={{ fontWeight: 900, overflowWrap: "anywhere" }}>{job.company?.name ?? t("jobTableCompany")} • {job.jobTitle}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>{action?.detail ?? job.workflowSignal?.reason ?? job.followUpReason ?? t("remindersFollowUpLabel")}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
disableElevation
|
||||
onClick={() => openReminderJob(job)}
|
||||
sx={urgent
|
||||
? { width: { xs: "100%", sm: "auto" }, bgcolor: "text.primary", color: "background.paper", boxShadow: "none", "&:hover": { bgcolor: "text.primary", boxShadow: "none" } }
|
||||
: { width: { xs: "100%", sm: "auto" }, bgcolor: alpha(toneColor, 0.15), color: toneColor, boxShadow: "none", "&:hover": { bgcolor: alpha(toneColor, 0.24), boxShadow: "none" } }}
|
||||
>
|
||||
{action?.label ?? t("remindersOpen")}
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
<Button variant="text" onClick={() => navigate('/reminders')}>{t("reminders")}</Button>
|
||||
</Box>
|
||||
</SectionCard>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<OnboardingChecklist hasJobs={(stats?.total ?? 0) > 0} />
|
||||
|
||||
<Box sx={{ mb: 2, display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "flex-start" }}>
|
||||
<Box sx={{ maxWidth: 760 }}>
|
||||
<Typography variant="overline" sx={{ color: theme.palette.primary.main, fontWeight: 800 }}>
|
||||
{t("dashboardHeroLabel")}
|
||||
</Typography>
|
||||
<Typography variant="h3" sx={{ mt: 0.5, color: "text.primary", overflowWrap: "anywhere" }}>
|
||||
{t("dashboardOverviewTitle")}
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ color: "text.secondary", mt: 1 }}>
|
||||
{t("dashboardOverviewBody")}
|
||||
</Typography>
|
||||
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={1.25} sx={{ mt: 2, flexWrap: "wrap" }}>
|
||||
{hasJobs ? <Box sx={{ mb: 2, display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={1.25} sx={{ flexWrap: "wrap" }}>
|
||||
<Chip color="primary" variant="outlined" label={t("dashboardResponseRate", { rate: trendsView.responseRate })} />
|
||||
<Chip variant="outlined" label={`${summaryView.missingCvCount} ${t("dashboardMissingTailoredCv").toLowerCase()}`} />
|
||||
<Chip variant="outlined" label={summaryView.topSource ? `${summaryView.topSource.label}: ${summaryView.topSource.rate}%` : t("dashboardResponseSources")} />
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
@@ -339,25 +390,27 @@ export default function DashboardView() {
|
||||
))}
|
||||
</Menu>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box> : null}
|
||||
|
||||
<ViewStateNotice
|
||||
loading={summaryResource.loading}
|
||||
error={summaryResource.error}
|
||||
title="Unable to load dashboard summary"
|
||||
description="The dashboard summary is unavailable right now."
|
||||
title={t("dashboardSummaryUnavailableTitle")}
|
||||
description={t("dashboardSummaryUnavailableBody")}
|
||||
onRetry={summaryResource.reload}
|
||||
/>
|
||||
<ViewStateNotice
|
||||
loading={trendsResource.loading}
|
||||
error={trendsResource.error}
|
||||
title="Unable to load dashboard trends"
|
||||
description="Charts and trend panels could not reach the API."
|
||||
title={t("dashboardTrendsUnavailableTitle")}
|
||||
description={t("dashboardTrendsUnavailableBody")}
|
||||
onRetry={trendsResource.reload}
|
||||
compact
|
||||
/>
|
||||
|
||||
{!summaryResource.loading && !summaryResource.error && prefs.cards ? (
|
||||
{priorityPanel ? <Box sx={{ mt: 2 }}>{priorityPanel}</Box> : null}
|
||||
|
||||
{!summaryResource.loading && !summaryResource.error && hasJobs && prefs.cards ? (
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(2, 1fr)", xl: "repeat(4, 1fr)" }, gap: 2, mt: 2 }}>
|
||||
{metricCards.map((card) => (
|
||||
<SectionCard
|
||||
@@ -393,7 +446,7 @@ export default function DashboardView() {
|
||||
) : null}
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "minmax(0, 1.8fr) minmax(320px, 0.9fr)" }, gap: 2, mt: 2 }}>
|
||||
{!summaryResource.loading && !summaryResource.error && prefs.activity ? (
|
||||
{!summaryResource.loading && !summaryResource.error && hasActivityData && prefs.activity ? (
|
||||
<SectionCard>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "flex-start" }}>
|
||||
<Box>
|
||||
@@ -412,9 +465,9 @@ export default function DashboardView() {
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 2, overflowX: "auto", mx: isMobile ? -0.5 : 0, px: isMobile ? 0.5 : 0 }}>
|
||||
<Box sx={{ minWidth: trendsView.chartWidth }}>
|
||||
<svg width={trendsView.chartWidth} height={trendsView.chartHeight} viewBox={`0 0 ${trendsView.chartWidth} ${trendsView.chartHeight}`}>
|
||||
<Box sx={{ mt: 2, minWidth: 0 }}>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<svg width="100%" height={trendsView.chartHeight} viewBox={`0 0 ${trendsView.chartWidth} ${trendsView.chartHeight}`} preserveAspectRatio="none" style={{ display: "block", maxWidth: "100%" }}>
|
||||
{[0.2, 0.4, 0.6, 0.8].map((tick) => (
|
||||
<line
|
||||
key={tick}
|
||||
@@ -441,10 +494,10 @@ export default function DashboardView() {
|
||||
</SectionCard>
|
||||
) : null}
|
||||
|
||||
{!summaryResource.loading && !summaryResource.error ? (
|
||||
{!summaryResource.loading && !summaryResource.error && (hasStageData || funnelItems.length > 0 || summaryView.topSource) ? (
|
||||
<SectionCard>
|
||||
<Typography variant="h6">{t("dashboardTimeInStageTitle")}</Typography>
|
||||
{overview?.timeInStage?.length ? (
|
||||
{overview?.timeInStage?.length ? (<>
|
||||
<Typography variant="h6">{t("dashboardTimeInStageTitle")}</Typography>
|
||||
<Stack spacing={1.25} sx={{ mt: 1.5 }}>
|
||||
{overview.timeInStage.map((item, index) => (
|
||||
<Box key={item.stage}>
|
||||
@@ -470,9 +523,7 @@ export default function DashboardView() {
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>{t("dashboardNoTagsYet")}</Typography>
|
||||
)}
|
||||
</>) : null}
|
||||
|
||||
{tags.length ? (
|
||||
<Box sx={{ mt: 2.25 }}>
|
||||
@@ -490,9 +541,10 @@ export default function DashboardView() {
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<Typography variant="h6" sx={{ mt: 2.25 }}>{t("dashboardConversionFunnelTitle")}</Typography>
|
||||
{funnelItems.length > 0 ? <>
|
||||
<Typography variant="h6" sx={{ mt: hasStageData || hasSkills ? 2.25 : 0 }}>{t("dashboardConversionFunnelTitle")}</Typography>
|
||||
<Stack spacing={1.2} sx={{ mt: 1 }}>
|
||||
{(overview?.funnel ?? []).map((item) => {
|
||||
{funnelItems.map((item) => {
|
||||
const width = summaryView.funnelMax ? clamp((item.count / summaryView.funnelMax) * 100, 0, 100) : 0;
|
||||
return (
|
||||
<Box key={item.label}>
|
||||
@@ -517,14 +569,15 @@ export default function DashboardView() {
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</> : null}
|
||||
|
||||
<Box sx={{ mt: 2.25, p: 1.5, borderRadius: 3, backgroundColor: alpha(theme.palette.primary.main, 0.05) }}>
|
||||
{summaryView.topSource ? <Box sx={{ mt: 2.25, p: 1.5, borderRadius: 3, backgroundColor: alpha(theme.palette.primary.main, 0.05) }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 800 }}>{summaryView.topSource?.label ?? t("dashboardResponseSources")}</Typography>
|
||||
<Typography variant="h5" sx={{ mt: 0.5 }}>{summaryView.topSource ? `${summaryView.topSource.rate}%` : "—"}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.5 }}>
|
||||
{summaryView.topSource ? t("dashboardResponseConversion", { responses: summaryView.topSource.responses, total: summaryView.topSource.total }) : t("dashboardNoSourceData")}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box> : null}
|
||||
</SectionCard>
|
||||
) : null}
|
||||
</Box>
|
||||
@@ -551,65 +604,8 @@ export default function DashboardView() {
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "1.15fr 0.85fr" }, gap: 2, mt: 2 }}>
|
||||
{!summaryResource.loading && !summaryResource.error ? (
|
||||
<SectionCard>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>{t("remindersTitle")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("remindersSubtitle")}</Typography>
|
||||
{summaryView.priorityJobs.length === 0 ? (
|
||||
<Typography sx={{ color: "text.secondary" }}>{t("remindersNothing")}</Typography>
|
||||
) : (
|
||||
<Stack spacing={1.1}>
|
||||
{summaryView.priorityJobs.map((job) => {
|
||||
const action = getReminderAction(job);
|
||||
const tone = reminderTone(job);
|
||||
const toneColor = theme.palette[tone].main;
|
||||
const urgent = tone === "error";
|
||||
return (
|
||||
<Box
|
||||
key={job.id}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
pl: 2,
|
||||
borderRadius: 2,
|
||||
borderLeft: `4px solid ${toneColor}`,
|
||||
backgroundColor: alpha(toneColor, 0.07),
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
gap: 2,
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ minWidth: 0, display: "flex", gap: 1.25, alignItems: "flex-start" }}>
|
||||
<Box sx={{ width: 8, height: 8, mt: 0.75, borderRadius: "50%", backgroundColor: toneColor, flexShrink: 0 }} />
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography sx={{ fontWeight: 900, overflowWrap: "anywhere" }}>{job.company?.name ?? t("jobTableCompany")} • {job.jobTitle}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>{action?.detail ?? job.workflowSignal?.reason ?? job.followUpReason ?? t("remindersFollowUpLabel")}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
disableElevation
|
||||
onClick={() => openReminderJob(job)}
|
||||
sx={urgent
|
||||
? { width: { xs: "100%", sm: "auto" }, bgcolor: "text.primary", color: "background.paper", boxShadow: "none", "&:hover": { bgcolor: "text.primary", boxShadow: "none" } }
|
||||
: { width: { xs: "100%", sm: "auto" }, bgcolor: alpha(toneColor, 0.15), color: toneColor, boxShadow: "none", "&:hover": { bgcolor: alpha(toneColor, 0.24), boxShadow: "none" } }}
|
||||
>
|
||||
{action?.label ?? t("remindersOpen")}
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
<Button variant="text" onClick={() => navigate('/reminders')}>{t("reminders")}</Button>
|
||||
</Box>
|
||||
</SectionCard>
|
||||
) : null}
|
||||
|
||||
{!summaryResource.loading && !summaryResource.error && prefs.companies ? (
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "1fr 1fr" }, gap: 2, mt: 2 }}>
|
||||
{!summaryResource.loading && !summaryResource.error && hasCompanies && prefs.companies ? (
|
||||
<SectionCard>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>{t("dashboardTopCompaniesByActivity")}</Typography>
|
||||
<Stack spacing={1.25}>
|
||||
@@ -628,13 +624,10 @@ export default function DashboardView() {
|
||||
</SectionCard>
|
||||
) : null}
|
||||
|
||||
{!trendsResource.loading && !trendsResource.error && prefs.skills ? (
|
||||
{!trendsResource.loading && !trendsResource.error && hasSkills && prefs.skills ? (
|
||||
<SectionCard>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>{t("dashboardTopSkills")}</Typography>
|
||||
{tags.length === 0 ? (
|
||||
<Typography sx={{ color: "text.secondary" }}>{t("dashboardNoTagsYet")}</Typography>
|
||||
) : (
|
||||
<Stack spacing={1.15}>
|
||||
<Stack spacing={1.15}>
|
||||
{tags.slice(0, 8).map((tag, index) => {
|
||||
const max = Math.max(...tags.map((item) => item.count), 1);
|
||||
const width = (tag.count / max) * 100;
|
||||
@@ -650,8 +643,7 @@ export default function DashboardView() {
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}>{t("dashboardSkillTrends")}</Typography>
|
||||
{!tagTrends || tagTrends.series.length === 0 ? (
|
||||
|
||||
Reference in New Issue
Block a user