Files
jobtrackingapp/job-tracker-ui/src/components/DashboardView.tsx
T
2026-08-29 22:55:29 +02:00

682 lines
32 KiB
TypeScript

import React, { useCallback, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Box,
Button,
Card,
Checkbox,
Chip,
LinearProgress,
Menu,
MenuItem,
Stack,
Typography,
} from "@mui/material";
import useMediaQuery from "@mui/material/useMediaQuery";
import { alpha, useTheme } from "@mui/material/styles";
import TuneIcon from "@mui/icons-material/Tune";
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
import { api } from "../api";
import ViewStateNotice from "./ViewStateNotice";
import OnboardingChecklist from "./OnboardingChecklist";
import { getUserKeyFromToken } from "../themePrefs";
import { useI18n } from "../i18n/I18nProvider";
import { statusLabel } from "../pipeline";
import { buildWorkflowPath, getWorkflowAction } from "../jobWorkflowSignals";
import { JobApplication } from "../types";
import { useViewResource } from "../hooks/useViewResource";
interface JobStats {
total: number;
active: number;
deleted: number;
byStatus: Record<string, number>;
appliedLast30Days: number;
averageDaysSinceApplied: number;
}
type ReminderJob = JobApplication;
type AnalyticsPoint = { month: string; applied: number; responses: number };
type TagPoint = { tag: string; count: number };
type OverviewAnalytics = {
funnel: { label: string; count: number }[];
responseRateBySource: { label: string; total: number; responses: number; rate: number }[];
topCompanies: { companyId: number; company: string; count: number; responses: number; responseRate: number }[];
medianDaysToFirstResponse?: number | null;
totalResponses: number;
totalActive: number;
timeInStage?: { stage: string; medianDays: number; count: number }[];
salaryInsights?: { currency: string; period: string; count: number; minimum: number; maximum: number; averageMidpoint: number }[];
};
type TagTrendResponse = { months: string[]; series: { tag: string; counts: number[] }[] };
type Prefs = {
cards: boolean;
activity: boolean;
funnel: boolean;
companies: boolean;
skills: boolean;
};
function prefsKey() {
return `dashboardPrefs:${getUserKeyFromToken()}`;
}
function loadPrefs(): Prefs {
try {
const raw = window.localStorage.getItem(prefsKey());
if (!raw) return { cards: true, activity: true, funnel: true, companies: true, skills: true };
return { cards: true, activity: true, funnel: true, companies: true, skills: true, ...JSON.parse(raw) };
} catch {
return { cards: true, activity: true, funnel: true, companies: true, skills: true };
}
}
function savePrefs(next: Prefs) {
window.localStorage.setItem(prefsKey(), JSON.stringify(next));
}
function clamp(n: number, a: number, b: number) {
return Math.max(a, Math.min(b, n));
}
function buildLinePath(values: number[], width: number, height: number) {
if (!values.length) return "";
const min = Math.min(...values);
const max = Math.max(...values);
const step = width / Math.max(1, values.length - 1);
const yFor = (value: number) => {
const t = max === min ? 0.5 : (value - min) / (max - min);
return height - t * height;
};
return values
.map((value, index) => `${index === 0 ? "M" : "L"} ${Math.round(index * step)} ${Math.round(yFor(value))}`)
.join(" ");
}
// Mockup cards float on the grey page background purely via the theme's Card shadow/radius --
// this wraps MuiCard instead of Paper-with-hardcoded-shadow so every section stays in sync with
// theme.ts (see 02-dashboard.png).
function SectionCard({ children, sx = {} }: { children: React.ReactNode; sx?: any }) {
return (
<Card sx={{ p: "var(--app-card-padding)", ...sx }}>
{children}
</Card>
);
}
// "Needs your attention" row accent -- red for overdue follow-ups, amber for interview prep,
// blue/info for everything else (package work, readiness review). No "positive/offer" tier
// exists in the real reminder feed (see DashboardView report), so only these three appear.
function reminderTone(job: ReminderJob): "error" | "warning" | "info" {
const key = job.workflowSignal?.actionKey;
if (key === "follow-up") return "error";
if (key === "interview-prep") return "warning";
return "info";
}
export default function DashboardView() {
const theme = useTheme();
const isMobile = useMediaQuery("(max-width:767.95px)");
const navigate = useNavigate();
const { t } = useI18n();
const [months, setMonths] = useState<6 | 12 | 24>(12);
const [prefs, setPrefs] = useState<Prefs>(() => loadPrefs());
const [prefsAnchor, setPrefsAnchor] = useState<HTMLElement | null>(null);
const loadSummary = useCallback(async () => {
const [statsResponse, overviewResponse, remindersResponse] = await Promise.all([
api.get<JobStats>("/jobapplications/stats"),
api.get<OverviewAnalytics>("/jobapplications/analytics-overview"),
api.get<ReminderJob[]>("/jobapplications/reminders", { params: { upcomingDays: 14 } }),
]);
return {
stats: statsResponse.data,
overview: overviewResponse.data,
reminderJobs: Array.isArray(remindersResponse.data) ? remindersResponse.data : [],
};
}, []);
const summaryResource = useViewResource(
loadSummary,
{
initialData: { stats: null as JobStats | null, overview: null as OverviewAnalytics | null, reminderJobs: [] as ReminderJob[] },
errorMessage: "Unable to load dashboard summary data right now.",
deps: [],
},
);
const loadTrends = useCallback(async () => {
const params = { months };
const [analyticsResponse, tagsResponse, trendsResponse] = await Promise.all([
api.get<AnalyticsPoint[]>("/jobapplications/analytics", { params }),
api.get<TagPoint[]>("/jobapplications/tags", { params: { limit: 10, ...params } }),
api.get<TagTrendResponse>("/jobapplications/tag-trends", { params: { months, limit: 5 } }),
]);
return {
analytics: analyticsResponse.data ?? [],
tags: tagsResponse.data ?? [],
tagTrends: trendsResponse.data,
};
}, [months]);
const trendsResource = useViewResource(
loadTrends,
{
initialData: { analytics: [] as AnalyticsPoint[], tags: [] as TagPoint[], tagTrends: null as TagTrendResponse | null },
errorMessage: "Unable to load dashboard trends right now.",
deps: [months],
},
);
const stats = summaryResource.data.stats;
const overview = summaryResource.data.overview;
const reminderJobs = summaryResource.data.reminderJobs;
const analytics = trendsResource.data.analytics;
const tags = trendsResource.data.tags;
const tagTrends = trendsResource.data.tagTrends;
const tagColors = useMemo(
() => [theme.palette.primary.main, theme.palette.success.main, theme.palette.warning.main, theme.palette.info.main, theme.palette.error.main],
[theme.palette.error.main, theme.palette.info.main, theme.palette.primary.main, theme.palette.success.main, theme.palette.warning.main],
);
const summaryView = useMemo(() => {
const topSource = overview?.responseRateBySource?.[0];
const missingCvCount = reminderJobs.filter((job) => job.workflowSignal?.hasPackageGap).length;
const interviewCount = overview?.funnel?.find((item) => item.label === "Interview")?.count ?? 0;
const interviewPrepCount = reminderJobs.filter((job) => job.workflowSignal?.actionKey === "interview-prep").length;
const overdueFollowUpCount = reminderJobs.filter((job) => job.workflowSignal?.actionKey === "follow-up").length;
return {
funnelMax: overview?.funnel?.length ? Math.max(...overview.funnel.map((item) => item.count)) : 0,
topSource,
missingCvCount,
interviewCount,
interviewPrepCount,
overdueFollowUpCount,
priorityJobs: reminderJobs.slice(0, 5),
};
}, [overview, reminderJobs]);
const trendsView = useMemo(() => {
const appliedValues = analytics.map((x) => x.applied);
const responseValues = analytics.map((x) => x.responses);
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);
const maxTagCount = Math.max(...tags.map((item) => item.count), 1);
return {
appliedValues,
responseValues,
chartWidth,
chartHeight,
appliedPath: buildLinePath(appliedValues, chartWidth, chartHeight),
responsePath: buildLinePath(responseValues, chartWidth, chartHeight),
totalApplied,
totalResponses,
responseRate: totalApplied > 0 ? Math.round((totalResponses / totalApplied) * 100) : 0,
maxTagCount,
};
}, [analytics, isMobile, tags]);
// Mockup's 4 stat tiles (Active applications / Response rate / Interviews / Needs follow-up)
// -- every value below is derived from data this view already fetches, no new endpoints.
const metricCards = useMemo(() => {
const cards: { label: string; value: string | number; trend?: { text: string; tone: "success" | "error" }; caption?: string }[] = [
{
label: t("dashboardActiveApplications"),
value: stats?.active ?? 0,
trend: (stats?.appliedLast30Days ?? 0) > 0
? { text: `+${stats?.appliedLast30Days} ${t("dashboardNewApplications").toLowerCase()}`, tone: "success" }
: undefined,
},
{
label: t("dashboardResponseRateTile"),
value: `${trendsView.responseRate}%`,
caption: summaryView.topSource ? `${summaryView.topSource.label} · ${summaryView.topSource.rate}%` : t("dashboardResponseSources"),
},
{
label: t("dashboardInterviews"),
value: summaryView.interviewCount,
caption: summaryView.interviewPrepCount > 0 ? t("dashboardUpcomingCount", { count: summaryView.interviewPrepCount }) : undefined,
},
{
label: t("dashboardFollowUpsDue"),
value: reminderJobs.length,
trend: summaryView.overdueFollowUpCount > 0
? { text: t("dashboardOverdueCount", { count: summaryView.overdueFollowUpCount }), tone: "error" }
: undefined,
},
];
return cards;
}, [reminderJobs.length, stats?.active, stats?.appliedLast30Days, summaryView.interviewCount, summaryView.interviewPrepCount, summaryView.overdueFollowUpCount, summaryView.topSource, t, trendsView.responseRate]);
const togglePref = useCallback((key: keyof Prefs) => {
setPrefs((current) => {
const next = { ...current, [key]: !current[key] };
savePrefs(next);
return next;
});
}, []);
const getReminderAction = useCallback((job: ReminderJob) => getWorkflowAction(job, {
packageWork: t("jobTablePackageWork"),
followUp: t("jobTableFollowUp"),
interviewPrep: t("jobTableInterviewStage"),
readiness: t("jobTableReadiness"),
}), [t]);
const openReminderJob = useCallback((job: ReminderJob) => {
navigate(buildWorkflowPath(job));
}, [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} />
{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
sx={{
display: "flex",
gap: 1,
flexWrap: "wrap",
alignItems: "center",
width: { xs: "100%", sm: "auto" },
'& .MuiButton-root': {
flex: { xs: '1 1 calc(50% - 8px)', sm: '0 0 auto' },
},
}}
>
{([6, 12, 24] as const).map((m) => (
<Button key={m} size="small" variant={months === m ? "contained" : "outlined"} onClick={() => setMonths(m)}>
{t("dashboardMonthsShort", { count: m })}
</Button>
))}
<Button variant="outlined" startIcon={<TuneIcon />} onClick={(e) => setPrefsAnchor(e.currentTarget)}>
{t("dashboardCustomize")}
</Button>
<Menu anchorEl={prefsAnchor} open={Boolean(prefsAnchor)} onClose={() => setPrefsAnchor(null)}>
{[
["cards", t("dashboardSummaryCards")],
["activity", t("dashboardActivityChart")],
["funnel", t("dashboardConversionFunnel")],
["companies", t("dashboardTopCompanies")],
["skills", t("dashboardSkillsInsights")],
].map(([key, label]) => (
<MenuItem key={key} onClick={() => togglePref(key as keyof Prefs)}>
<Checkbox checked={prefs[key as keyof Prefs]} />
{label}
</MenuItem>
))}
</Menu>
</Box>
</Box> : null}
<ViewStateNotice
loading={summaryResource.loading}
error={summaryResource.error}
title={t("dashboardSummaryUnavailableTitle")}
description={t("dashboardSummaryUnavailableBody")}
onRetry={summaryResource.reload}
/>
<ViewStateNotice
loading={trendsResource.loading}
error={trendsResource.error}
title={t("dashboardTrendsUnavailableTitle")}
description={t("dashboardTrendsUnavailableBody")}
onRetry={trendsResource.reload}
compact
/>
{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
key={card.label}
sx={{
transition: "box-shadow .2s, transform .2s",
"&:hover": { boxShadow: 6, transform: "translateY(-2px)" },
}}
>
<Typography variant="overline" sx={{ color: "text.secondary" }}>{card.label}</Typography>
<Typography variant="h3" sx={{ mt: 0.5 }}>{card.value}</Typography>
{card.trend || card.caption ? (
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5, mt: 0.75 }}>
{card.trend ? (
<>
{card.trend.tone === "success" ? (
<ArrowUpwardIcon sx={{ fontSize: 14 }} color="success" />
) : (
<ArrowDownwardIcon sx={{ fontSize: 14 }} color="error" />
)}
<Typography variant="body2" sx={{ fontWeight: 700, color: card.trend.tone === "success" ? "success.main" : "error.main" }}>
{card.trend.text}
</Typography>
</>
) : (
<Typography variant="body2" sx={{ color: "text.secondary" }}>{card.caption}</Typography>
)}
</Box>
) : null}
</SectionCard>
))}
</Box>
) : 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 && hasActivityData && prefs.activity ? (
<SectionCard>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "flex-start" }}>
<Box>
<Typography variant="h6">{t("dashboardApplicationActivity")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("dashboardMonthlyApplicationsResponses")}</Typography>
</Box>
<Stack direction="row" spacing={2} alignItems="center" flexWrap="wrap">
<Stack direction="row" spacing={0.75} alignItems="center">
<Box sx={{ width: 10, height: 10, borderRadius: 0.75, backgroundColor: theme.palette.primary.main }} />
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("dashboardAppliedCount", { count: trendsView.totalApplied })}</Typography>
</Stack>
<Stack direction="row" spacing={0.75} alignItems="center">
<Box sx={{ width: 10, height: 10, borderRadius: 0.75, backgroundColor: theme.palette.success.main }} />
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("dashboardResponsesCount", { count: trendsView.totalResponses })}</Typography>
</Stack>
</Stack>
</Box>
<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}
x1="0"
x2={trendsView.chartWidth}
y1={Math.round(trendsView.chartHeight * tick)}
y2={Math.round(trendsView.chartHeight * tick)}
stroke={alpha(theme.palette.text.primary, 0.08)}
strokeDasharray="6 6"
/>
))}
{trendsView.responsePath ? <path d={trendsView.responsePath} fill="none" stroke={theme.palette.success.main} strokeWidth="3" strokeLinecap="round" /> : null}
{trendsView.appliedPath ? <path d={trendsView.appliedPath} fill="none" stroke={theme.palette.primary.main} strokeWidth="3" strokeLinecap="round" /> : null}
</svg>
<Box sx={{ display: "flex", justifyContent: "space-between", mt: 1 }}>
{analytics.map((point) => (
<Typography key={point.month} variant="caption" sx={{ width: `${100 / Math.max(1, analytics.length)}%`, textAlign: "center", color: "text.secondary" }}>
{point.month.slice(5)}
</Typography>
))}
</Box>
</Box>
</Box>
</SectionCard>
) : null}
{!summaryResource.loading && !summaryResource.error && (hasStageData || funnelItems.length > 0 || summaryView.topSource) ? (
<SectionCard>
{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}>
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 0.5, gap: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{statusLabel(t, item.stage)}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{t("dashboardTimeInStageValue", { days: item.medianDays, count: item.count })}
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={clamp((item.medianDays / timeInStageMax) * 100, 4, 100)}
sx={{
height: 8,
borderRadius: 999,
backgroundColor: alpha(theme.palette.text.primary, 0.06),
'& .MuiLinearProgress-bar': {
borderRadius: 999,
backgroundColor: tagColors[index % tagColors.length],
},
}}
/>
</Box>
))}
</Stack>
</>) : null}
{tags.length ? (
<Box sx={{ mt: 2.25 }}>
<Typography variant="body2" sx={{ fontWeight: 800, mb: 1 }}>{t("dashboardTopSkills")}</Typography>
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.75 }}>
{tags.slice(0, 4).map((tag) => (
<Chip
key={tag.tag}
size="small"
label={`${tag.tag} · ${tag.count}`}
sx={{ backgroundColor: alpha(theme.palette.primary.main, 0.1), color: "primary.main", fontWeight: 700 }}
/>
))}
</Box>
</Box>
) : null}
{funnelItems.length > 0 ? <>
<Typography variant="h6" sx={{ mt: hasStageData || hasSkills ? 2.25 : 0 }}>{t("dashboardConversionFunnelTitle")}</Typography>
<Stack spacing={1.2} sx={{ mt: 1 }}>
{funnelItems.map((item) => {
const width = summaryView.funnelMax ? clamp((item.count / summaryView.funnelMax) * 100, 0, 100) : 0;
return (
<Box key={item.label}>
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 0.5, gap: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{statusLabel(t, item.label)}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{item.count}</Typography>
</Box>
<LinearProgress
variant="determinate"
value={width}
sx={{
height: 10,
borderRadius: 999,
backgroundColor: alpha(theme.palette.primary.main, 0.08),
'& .MuiLinearProgress-bar': {
borderRadius: 999,
background: `linear-gradient(90deg, ${theme.palette.primary.main}, ${alpha(theme.palette.success.main, 0.85)})`,
},
}}
/>
</Box>
);
})}
</Stack>
</> : null}
{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> : null}
</SectionCard>
) : null}
</Box>
{(overview?.salaryInsights?.length ?? 0) > 0 ? (
<Box sx={{ mt: 2 }}>
<SectionCard>
<Typography variant="h6">{t("dashboardSalaryInsights")}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5, mb: 1.5 }}>{t("dashboardSalaryInsightsBody")}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)" }, gap: 1.5 }}>
{overview!.salaryInsights!.map((item) => {
const money = new Intl.NumberFormat(undefined, { style: "currency", currency: item.currency, maximumFractionDigits: 0 });
return (
<Box key={item.currency + item.period} sx={{ p: 1.5, borderRadius: 3, backgroundColor: alpha(theme.palette.primary.main, 0.05) }}>
<Typography variant="caption" color="text.secondary">{t("dashboardSalaryGroup", { currency: item.currency, period: item.period, count: item.count })}</Typography>
<Typography variant="h6" sx={{ mt: 0.5 }}>{money.format(item.averageMidpoint)}</Typography>
<Typography variant="body2" color="text.secondary">{t("dashboardSalaryRange", { minimum: money.format(item.minimum), maximum: money.format(item.maximum) })}</Typography>
</Box>
);
})}
</Box>
</SectionCard>
</Box>
) : null}
<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}>
{(overview?.topCompanies ?? []).map((item, index) => (
<Box key={item.companyId} sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: alpha(theme.palette.primary.main, index === 0 ? 0.05 : 0.02) }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
<Box sx={{ minWidth: 0 }}>
<Typography sx={{ fontWeight: 900, overflowWrap: "anywhere" }}>{item.company}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>{t("dashboardCompanyJobsResponses", { jobs: item.count, responses: item.responses })}</Typography>
</Box>
<Chip label={`${item.responseRate}%`} color={item.responseRate >= 50 ? "success" : item.responseRate >= 25 ? "warning" : "default"} variant="outlined" />
</Box>
</Box>
))}
</Stack>
</SectionCard>
) : null}
{!trendsResource.loading && !trendsResource.error && hasSkills && prefs.skills ? (
<SectionCard>
<Typography variant="h6" sx={{ mb: 1 }}>{t("dashboardTopSkills")}</Typography>
<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;
return (
<Box key={tag.tag}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 800, overflowWrap: "anywhere" }}>{tag.tag}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{tag.count}</Typography>
</Box>
<Box sx={{ height: 10, borderRadius: 999, bgcolor: alpha(theme.palette.text.primary, 0.08), overflow: "hidden" }}>
<Box sx={{ width: `${width}%`, height: "100%", borderRadius: 999, bgcolor: tagColors[index % tagColors.length] }} />
</Box>
</Box>
);
})}
</Stack>
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}>{t("dashboardSkillTrends")}</Typography>
{!tagTrends || tagTrends.series.length === 0 ? (
<Typography sx={{ color: "text.secondary" }}>{t("dashboardNoTagTrendData")}</Typography>
) : (
<Stack spacing={1.1}>
{tagTrends.series.map((series, index) => (
<Box key={series.tag}>
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 0.5, gap: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 800, overflowWrap: "anywhere" }}>{series.tag}</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>{t("dashboardTotalCount", { count: series.counts.reduce((sum, value) => sum + value, 0) })}</Typography>
</Box>
<Box sx={{ display: "grid", gridTemplateColumns: `repeat(${series.counts.length}, 1fr)`, gap: 0.5 }}>
{series.counts.map((count, i) => (
<Box
key={`${series.tag}-${i}`}
sx={{
height: 18,
borderRadius: 1.25,
backgroundColor: count > 0 ? alpha(tagColors[index % tagColors.length], 0.22 + Math.min(0.6, count / 10)) : alpha(theme.palette.text.primary, 0.05),
}}
title={`${tagTrends.months[i]}: ${count}`}
/>
))}
</Box>
</Box>
))}
</Stack>
)}
</SectionCard>
) : null}
</Box>
</Box>
);
}