Files
jobtrackingapp/job-tracker-ui/src/components/DashboardView.tsx
T

396 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useCallback, useMemo, useState } from "react";
import { Link as RouterLink, useNavigate, useSearchParams } from "react-router-dom";
import {
Box,
Button,
Menu,
MenuItem,
Stack,
Link,
Table, TableBody, TableCell, TableHead, TableRow,
Typography,
} from "@mui/material";
import { useTheme } from "@mui/material/styles";
import TuneIcon from "@mui/icons-material/Tune";
import CheckBoxIcon from "@mui/icons-material/CheckBox";
import CheckBoxOutlineBlankIcon from "@mui/icons-material/CheckBoxOutlineBlank";
import { api } from "../api";
import ViewStateNotice from "./ViewStateNotice";
import OnboardingChecklist from "./OnboardingChecklist";
import { getUserKeyFromToken } from "../themePrefs";
import { useI18n } from "../i18n/I18nProvider";
import { statusLabel, statusTone } from "../pipeline";
import { PageSection } from "../layout/PageLayout";
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));
}
// "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({ mode = "dashboard" }: { mode?: "dashboard" | "analytics" }) {
const theme = useTheme();
const navigate = useNavigate();
const { t, language } = useI18n();
const reporting = mode === "analytics";
const [query, setQuery] = useSearchParams();
const [dashboardMonths, setDashboardMonths] = useState<6 | 12 | 24>(12);
const requestedMonths = Number(query.get("months"));
const months = reporting ? (requestedMonths === 6 || requestedMonths === 24 ? requestedMonths : 12) : dashboardMonths;
const setMonths = (value: 6 | 12 | 24) => {
if (!reporting) { setDashboardMonths(value); return; }
const next = new URLSearchParams(query);
next.set("months", String(value));
setQuery(next);
};
const [dashboardPrefs, setPrefs] = useState<Prefs>(() => reporting ? { cards: true, activity: true, funnel: true, companies: true, skills: true } : loadPrefs());
const prefs = reporting ? { cards: true, activity: true, funnel: true, companies: true, skills: true } : dashboardPrefs;
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"),
reporting ? Promise.resolve({ data: [] as ReminderJob[] }) : api.get<ReminderJob[]>("/jobapplications/reminders", { params: { upcomingDays: 14 } }),
]);
return {
stats: statsResponse.data,
overview: overviewResponse.data,
reminderJobs: Array.isArray(remindersResponse.data) ? remindersResponse.data : [],
};
}, [reporting]);
const summaryResource = useViewResource(
loadSummary,
{
initialData: { stats: null as JobStats | null, overview: null as OverviewAnalytics | null, reminderJobs: [] as ReminderJob[] },
errorMessage: t(reporting ? "analyticsSnapshotUnavailableTitle" : "dashboardSummaryUnavailableTitle"),
deps: [t, reporting],
},
);
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 } }),
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: t(reporting ? "analyticsTrendsUnavailableTitle" : "dashboardTrendsUnavailableTitle"),
deps: [months, t],
},
);
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 locale = language === "nb" ? "nb-NO" : "en-GB";
const palette = theme.vars?.palette ?? theme.palette;
const number = (value: number) => new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value);
const monthLabel = (key: string) => new Intl.DateTimeFormat(locale, { month: "long", year: "numeric", timeZone: "UTC" }).format(new Date(key + "-01T12:00:00Z"));
const summaryReady = !summaryResource.loading && !summaryResource.refreshing && !summaryResource.error;
const trendsReady = !trendsResource.loading && !trendsResource.refreshing && !trendsResource.error;
const hasJobs = ((reporting ? stats?.active : stats?.total) ?? 0) > 0;
const hasActivity = analytics.some(point => point.applied > 0 || point.responses > 0);
const totalApplied = analytics.reduce((sum, point) => sum + point.applied, 0);
const totalResponses = analytics.reduce((sum, point) => sum + point.responses, 0);
const funnel = (overview?.funnel ?? []).filter(item => item.count > 0);
const allHidden = !Object.values(prefs).some(Boolean);
const grid = { display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", lg: "minmax(0, 1.4fr) minmax(0, 1fr)" }, gap: 3, alignItems: "start" };
const monthSpan = analytics.length ? `${monthLabel(analytics[0].month)} – ${monthLabel(analytics[analytics.length - 1].month)}` : t("dashboardNoWindowData");
const stageCount = (stage: string) => overview?.funnel.find(item => item.label === stage)?.count ?? 0;
const togglePref = useCallback((key: keyof Prefs) => {
setPrefs(current => {
const next = { ...current, [key]: !current[key] };
savePrefs(next);
return next;
});
}, []);
// Both series share a zero baseline and maximum; independent scaling implies false equality.
const chart = useMemo(() => {
const max = Math.max(1, ...analytics.flatMap(point => [point.applied, point.responses]));
const path = (key: "applied" | "responses") => analytics.map((point, index) =>
`${index ? "L" : "M"} ${20 + index * 720 / Math.max(1, analytics.length - 1)} ${180 - point[key] / max * 160}`).join(" ");
return { max, applied: path("applied"), responses: path("responses") };
}, [analytics]);
const bar = (value: number, max: number, color: string) => <Box aria-hidden="true" sx={{ height: 6, mt: 0.75, bgcolor: "action.hover", borderRadius: 1 }}>
<Box sx={{ width: `${Math.max(0, Math.min(100, value / Math.max(1, max) * 100))}%`, height: "100%", bgcolor: color, borderRadius: 1 }} />
</Box>;
const detailsSx = { mt: 2, "& summary": { cursor: "pointer", color: "text.primary", fontWeight: 600, py: 1 }, "& th, & td": { px: 1, py: 0.75, overflowWrap: "anywhere" } };
const prefsButton = <Button variant="outlined" startIcon={<TuneIcon />} aria-haspopup="menu" aria-expanded={Boolean(prefsAnchor)} onClick={event => setPrefsAnchor(event.currentTarget)}>{t("dashboardCustomize")}</Button>;
const pipelineReport = <PageSection surface title={t("dashboardCurrentPipeline")} description={t("dashboardPipelineBody")}>
<Box sx={reporting ? grid : undefined}>
<Stack spacing={1.5}>
{funnel.map(item => <Box key={item.label}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}><Typography variant="body2">{statusLabel(t, item.label)}</Typography><Typography variant="body2" sx={{ fontWeight: 700 }}>{number(item.count)}</Typography></Box>
{bar(item.count, stats?.active ?? 0, statusTone(item.label) === "default" ? "text.secondary" : `${statusTone(item.label)}.main`)}
</Box>)}
{!funnel.length ? <Typography color="text.secondary">{t("dashboardNoPipelineData")}</Typography> : null}
</Stack>
<Box component="details" open={reporting || undefined} sx={{ ...detailsSx, mt: reporting ? 0 : 2 }}>
<Box component="summary" tabIndex={0}>{t("dashboardRecordedStatuses")}</Box>
<Stack spacing={1}>
{Object.entries(stats?.byStatus ?? {}).map(([status, count]) => <Box key={status} sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}>
<Link component={RouterLink} to={`/jobs?${new URLSearchParams({ status })}`} sx={{ overflowWrap: "anywhere", display: "inline-flex", alignItems: "center", minWidth: 24, minHeight: 24 }}>{statusLabel(t, status)}</Link>
<Typography variant="body2">{number(count)}</Typography>
</Box>)}
</Stack>
</Box>
</Box>
</PageSection>;
return (
<Box sx={{ display: "grid", gap: 3, minWidth: 0, "& p, & h2, & h3": { overflowWrap: "anywhere" } }}>
{!reporting ? <OnboardingChecklist hasJobs={summaryReady ? hasJobs : null} /> : null}
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
<Typography variant="body2" color="text.secondary">{t(reporting ? "analyticsScope" : "dashboardSnapshotScope")}</Typography>
{reporting ? <Button component={RouterLink} to="/dashboard">{t("goToDashboard")}</Button> : prefsButton}
</Box>
<Menu anchorEl={prefsAnchor} open={Boolean(prefsAnchor)} onClose={() => setPrefsAnchor(null)}>
{([
["cards", "dashboardSummaryCards"], ["activity", "dashboardActivityChart"],
["funnel", "dashboardConversionFunnel"], ["companies", "dashboardTopCompanies"], ["skills", "dashboardSkillsInsights"],
] as const).map(([key, label]) => (
<MenuItem key={key} role="menuitemcheckbox" aria-checked={prefs[key]} onClick={() => togglePref(key)}>
{prefs[key] ? <CheckBoxIcon aria-hidden sx={{ m: 1, color: "primary.main" }} /> : <CheckBoxOutlineBlankIcon aria-hidden sx={{ m: 1, color: "text.secondary" }} />}
{t(label)}
</MenuItem>
))}
</Menu>
<ViewStateNotice loading={summaryResource.loading || summaryResource.refreshing} error={summaryResource.error}
title={t(reporting ? "analyticsSnapshotUnavailableTitle" : "dashboardSummaryUnavailableTitle")} description={t(reporting ? "analyticsSnapshotUnavailableBody" : "dashboardSummaryUnavailableBody")} onRetry={summaryResource.reload} />
{summaryReady && !hasJobs ? <PageSection title={t(reporting ? "analyticsEmptyTitle" : "dashboardStartTitle")} description={t(reporting ? "analyticsEmptyBody" : "dashboardStartBody")}
actions={<Button component={RouterLink} to="/jobs" variant="contained">{t("onboardingStepJobAction")}</Button>}>
<Link component={RouterLink} to="/career">{t("careerWorkspaceProfileTab")}</Link>
</PageSection> : null}
{!reporting && allHidden ? <Typography role="status" color="text.secondary">{t("dashboardAllHidden")}</Typography> : null}
{!reporting && summaryReady && hasJobs && prefs.cards ? (
<Box component="dl" aria-label={t("dashboardSummaryCards")} sx={{ m: 0, display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))", lg: "repeat(4, minmax(0, 1fr))" }, borderBlock: "1px solid", borderColor: "divider", py: 1, gap: 1 }}>
{[
{ label: t("dashboardTrackedJobs"), value: stats!.active, caption: t("dashboardTrackedBody"), to: "/jobs" },
{ label: t("dashboardApplied30Days"), value: stats!.appliedLast30Days, caption: t("dashboardRecordedDates") },
{ label: t("dashboardCurrentInterviews"), value: stageCount("Interview"), caption: t("dashboardCurrentStage") },
{ label: t("dashboardCurrentOffers"), value: stageCount("Offer"), caption: t("dashboardCurrentStage"), to: "/offers" },
].map(item => <Box key={item.label} sx={{ p: 1.5, minWidth: 0 }}>
<Typography component="dt" variant="body2" color="text.secondary">{item.label}</Typography>
<Typography component="dd" sx={{ m: 0, my: 0.5, fontSize: "1.75rem", fontWeight: 700, fontVariantNumeric: "tabular-nums" }}>
{item.to ? <Link component={RouterLink} to={item.to} color="text.primary" underline="hover" aria-label={`${item.label}: ${number(item.value)}`}>{number(item.value)}</Link> : number(item.value)}
</Typography>
<Typography component="dd" variant="caption" color="text.secondary" sx={{ m: 0 }}>{item.caption}</Typography>
</Box>)}
</Box>
) : null}
{!reporting && summaryReady && hasJobs ? <Box sx={grid}>
{!reporting ? <PageSection title={t("dashboardTodayTitle")} description={t("dashboardTodayBody")} actions={<Button component={RouterLink} to="/reminders">{t("reminders")}</Button>}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>{t("dashboardAttentionSample", { count: number(reminderJobs.length), shown: number(Math.min(5, reminderJobs.length)) })}</Typography>
<Typography variant="caption" component="p" color="text.secondary" sx={{ mb: 1.5 }}>{t("dashboardAttentionBreakdown", {
followUp: number(reminderJobs.filter(job => job.workflowSignal?.actionKey === "follow-up").length),
preparation: number(reminderJobs.filter(job => job.workflowSignal?.actionKey === "interview-prep").length),
package: number(reminderJobs.filter(job => job.workflowSignal?.hasPackageGap).length),
})}</Typography>
<Stack spacing={1}>
{reminderJobs.slice(0, 5).map(job => {
const action = getWorkflowAction(job, { packageWork: t("jobTablePackageWork"), followUp: t("jobTableFollowUp"), interviewPrep: t("jobTableInterviewStage"), readiness: t("jobTableReadiness") });
return <Box key={job.id} sx={{ py: 1.5, pl: 1.5, borderLeft: "3px solid", borderColor: `${reminderTone(job)}.main`, display: "flex", alignItems: "center", gap: 1.5, flexWrap: "wrap", bgcolor: "background.paper" }}>
<Box sx={{ flex: "1 1 14rem", minWidth: 0 }}>
<Typography sx={{ fontWeight: 700 }}>{job.jobTitle}</Typography>
<Typography variant="body2" color="text.secondary">{job.company?.name ?? t("jobTableCompany")}</Typography>
<Typography variant="body2" sx={{ mt: 0.5 }}>{action?.detail ?? job.workflowSignal?.reason ?? job.followUpReason ?? t("remindersFollowUpLabel")}</Typography>
</Box>
<Button variant="outlined" onClick={() => navigate(buildWorkflowPath(job))} sx={{ mr: 1.5 }}>{action?.label ?? t("remindersOpen")}</Button>
</Box>;
})}
{!reminderJobs.length ? <Typography color="text.secondary">{t("remindersNothing")}</Typography> : null}
</Stack>
</PageSection> : null}
{prefs.funnel ? pipelineReport : null}
</Box> : null}
<ViewStateNotice loading={trendsResource.loading || trendsResource.refreshing} error={trendsResource.error}
title={t(reporting ? "analyticsTrendsUnavailableTitle" : "dashboardTrendsUnavailableTitle")} description={t("dashboardTrendsUnavailableBody")} onRetry={trendsResource.reload} compact />
{/* Period affects only activity and tag trends, not the current summary or all-time tags. */}
{(reporting || hasJobs || hasActivity || tags.length > 0) && (prefs.activity || prefs.skills) ? <Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }} role="group" aria-label={t("dashboardTrendPeriod")}>
<Typography variant="body2" sx={{ mr: 1 }}>{t("dashboardTrendPeriod")}</Typography>
{([6, 12, 24] as const).map(value => <Button key={value} size="small" aria-pressed={months === value} variant={months === value ? "contained" : "outlined"} onClick={() => setMonths(value)}>{t("dashboardMonthsShort", { count: value })}</Button>)}
</Box> : null}
{trendsReady && (reporting || hasJobs || hasActivity) && prefs.activity ? <PageSection surface title={t("dashboardApplicationActivity")} description={t("dashboardActivityDefinition")}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1.5, flexWrap: "wrap", mb: 1.5 }}>
<Typography variant="body2" color="text.secondary">{monthSpan}</Typography>
{totalApplied > 0 ? <Typography variant="body2">{t("dashboardResponseSample", { responses: number(totalResponses), total: number(totalApplied) })} ({number(totalResponses / totalApplied * 100)}%)</Typography> : null}
</Box>
{!hasActivity ? <Typography color="text.secondary">{t("dashboardNoWindowData")}</Typography> : <>
<Box component="svg" aria-hidden="true" viewBox="0 0 760 190" preserveAspectRatio="none" sx={{ display: "block", width: "100%", height: { xs: 150, sm: 220 } }}>
{[0, 0.5, 1].map(fraction => <line key={fraction} x1="20" x2="740" y1={180 - fraction * 160} y2={180 - fraction * 160} stroke={palette.divider} />)}
<path d={chart.applied} fill="none" stroke={palette.primary.main} strokeWidth="3" />
<path d={chart.responses} fill="none" stroke={palette.success.main} strokeWidth="3" strokeDasharray="7 5" />
</Box>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, mb: 1 }}>
<Typography variant="caption" color="text.secondary">{monthLabel(analytics[0].month)}</Typography>
<Typography variant="caption" color="text.secondary" sx={{ textAlign: "right" }}>{monthLabel(analytics[analytics.length - 1].month)}</Typography>
</Box>
<Typography variant="body2" color="text.secondary">{t("dashboardChartLegend", { max: number(chart.max) })}</Typography>
</>}
<Box component="details" open={reporting || undefined} sx={detailsSx}>
<Box component="summary" tabIndex={0}>{t("dashboardMonthlyValues")}</Box>
<Table size="small" aria-label={t("dashboardMonthlyValues")} sx={{ tableLayout: "fixed" }}>
<TableHead><TableRow>{(["dashboardMonth", "dashboardApplications", "dashboardResponsesLogged"] as const).map(key => <TableCell key={key}>{t(key)}</TableCell>)}</TableRow></TableHead>
<TableBody>{analytics.map(point => <TableRow key={point.month}><TableCell component="th" scope="row">{monthLabel(point.month)}</TableCell><TableCell>{number(point.applied)}</TableCell><TableCell>{number(point.responses)}</TableCell></TableRow>)}</TableBody>
</Table>
</Box>
</PageSection> : null}
{reporting && summaryReady && hasJobs ? pipelineReport : null}
{summaryReady && hasJobs ? <Box sx={grid}>
{(overview?.timeInStage?.length ?? 0) > 0 ? <PageSection title={t("dashboardStageAge")} description={t("dashboardStageAgeBody")}>
<Stack spacing={1.5}>{overview!.timeInStage!.map(item => <Box key={item.stage}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}><Typography variant="body2">{statusLabel(t, item.stage)}</Typography><Typography variant="body2">{t("dashboardTimeInStageValue", { days: number(item.medianDays), count: number(item.count) })}</Typography></Box>
{bar(item.medianDays, Math.max(1, ...overview!.timeInStage!.map(stage => stage.medianDays)), "info.main")}
</Box>)}</Stack>
</PageSection> : null}
{(overview?.responseRateBySource.length ?? 0) > 0 ? <PageSection title={t("dashboardResponseSources")} description={t("dashboardSourceBody")}>
<Stack spacing={1.5}>{overview!.responseRateBySource.map(item => <Box key={item.label}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}><Typography variant="body2" sx={{ overflowWrap: "anywhere" }}>{item.label}</Typography><Typography variant="body2" sx={{ fontWeight: 700 }}>{number(item.rate)}%</Typography></Box>
<Typography variant="caption" color="text.secondary">{t("dashboardRecordedResponseSample", { responses: number(item.responses), total: number(item.total) })}</Typography>
</Box>)}</Stack>
</PageSection> : null}
</Box> : null}
{summaryReady && (overview?.salaryInsights?.length ?? 0) > 0 ? <PageSection title={t("dashboardSalaryInsights")} description={t("dashboardSalaryDefinition")}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, minmax(0, 1fr))" }, gap: 2 }}>
{overview!.salaryInsights!.map(item => {
const money = new Intl.NumberFormat(locale, { style: "currency", currency: item.currency, maximumFractionDigits: 0 });
return <Box key={item.currency + item.period} sx={{ p: 2, border: "1px solid", borderColor: "divider", borderRadius: 1 }}>
<Typography variant="body2" color="text.secondary">{t("dashboardSalaryGroup", { currency: item.currency, period: item.period === "year" ? t("salaryPeriodYear") : item.period === "month" ? t("salaryPeriodMonth") : item.period === "hour" ? t("salaryPeriodHour") : item.period, count: number(item.count) })}</Typography>
<Typography sx={{ fontSize: "1.25rem", fontWeight: 700, my: 0.75 }}>{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>
</PageSection> : null}
<Box sx={grid}>
{summaryReady && hasJobs && prefs.companies ? <PageSection title={t("dashboardTopCompaniesByActivity")} description={t("dashboardCompaniesBody")}>
<Stack spacing={1.5}>{(overview?.topCompanies ?? []).map(item => <Box key={item.companyId} sx={{ borderBottom: "1px solid", borderColor: "divider", pb: 1.5 }}>
<Link component={RouterLink} to={`/jobs?companyId=${item.companyId}`} sx={{ fontWeight: 600, overflowWrap: "anywhere" }}>{item.company}</Link>
<Typography variant="body2" color="text.secondary">{t("dashboardCompanyJobsResponses", { jobs: number(item.count), responses: number(item.responses) })} ({number(item.responseRate)}%)</Typography>
</Box>)}</Stack>
{!overview?.topCompanies.length ? <Typography color="text.secondary">{t("companiesEmpty")}</Typography> : null}
</PageSection> : null}
{trendsReady && (reporting || hasJobs || tags.length > 0) && prefs.skills ? <PageSection title={t("dashboardJobTags")} description={t(reporting ? "analyticsTagsBody" : "dashboardTagsBody")}>
<Stack spacing={1.5}>{tags.slice(0, reporting ? 10 : 8).map(tag => <Box key={tag.tag}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}><Typography variant="body2" sx={{ overflowWrap: "anywhere" }}>{tag.tag}</Typography><Typography variant="body2">{number(tag.count)}</Typography></Box>
{bar(tag.count, Math.max(1, ...tags.map(item => item.count)), "primary.main")}
</Box>)}</Stack>
{!tags.length ? <Typography color="text.secondary">{t("dashboardNoTagsYet")}</Typography> : null}
{tagTrends?.series.length ? <Box sx={{ mt: 3 }}>
<Typography component="h3" variant="body2" sx={{ fontWeight: 700, mb: 1 }}>{t("dashboardSkillTrends")}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>{t("dashboardTagTrendBody")}</Typography>
<Stack spacing={1.5}>{tagTrends.series.map(series => <Box key={series.tag}>
<Typography variant="body2">{series.tag} · {t("dashboardTotalCount", { count: number(series.counts.reduce((sum, count) => sum + count, 0)) })}</Typography>
<Box aria-hidden="true" sx={{ display: "flex", alignItems: "end", gap: 0.5, height: 32, mt: 0.5 }}>
{series.counts.map((count, index) => <Box key={index} sx={{ flex: 1, height: `${count / Math.max(1, ...series.counts) * 100}%`, bgcolor: "primary.main" }} />)}
</Box>
</Box>)}</Stack>
</Box> : null}
<Box component="details" open={reporting || undefined} sx={detailsSx}>
<Box component="summary" tabIndex={0}>{t("dashboardTagTrendValues")}</Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>{t("dashboardTagTrendBody")}</Typography>
{!tagTrends?.series.length ? <Typography color="text.secondary">{t("dashboardNoTagTrendData")}</Typography> : tagTrends.series.map(series => <Box key={series.tag} sx={{ mb: 2 }}>
<Typography component="h3" variant="body2" sx={{ fontWeight: 700 }}>{series.tag}</Typography>
<Table size="small" aria-label={series.tag} sx={{ tableLayout: "fixed" }}>
<TableHead><TableRow><TableCell>{t("dashboardMonth")}</TableCell><TableCell>{t("dashboardOccurrences")}</TableCell></TableRow></TableHead>
<TableBody>{tagTrends.months.map((month, index) => <TableRow key={month}><TableCell component="th" scope="row">{monthLabel(month)}</TableCell><TableCell>{number(series.counts[index])}</TableCell></TableRow>)}</TableBody>
</Table>
</Box>)}
</Box>
</PageSection> : null}
</Box>
</Box>
);
}