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

377 lines
28 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, useContext, useMemo, useRef, useState } from "react";
import { Link as RouterLink, useNavigate, useSearchParams, UNSAFE_DataRouterContext } from "react-router-dom";
import {
Box,
Button,
Alert,
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 { api } from "../api";
import ViewStateNotice from "./ViewStateNotice";
import OnboardingChecklist from "./OnboardingChecklist";
import { WIDGETS, defaultDashboardLayout, WidgetId } from "../dashboardLayout";
import { useDashboardLayout } from "../hooks/useDashboardLayout";
import DashboardLayoutEditor, { DashboardNavigationGuard } from "./DashboardLayoutEditor";
import DashboardGrid from "./DashboardGrid";
import { useOptionalToast } from "../toast";
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[] }[] };
// "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 layoutState = useDashboardLayout(!reporting);
const activeLayout = reporting ? defaultDashboardLayout() : layoutState.draft ?? layoutState.layout;
const prefs = Object.fromEntries(activeLayout.widgets.map(widget => [widget.id, widget.visible]));
const dataRouter = useContext(UNSAFE_DataRouterContext);
const customizeButton = useRef<HTMLButtonElement>(null);
const optionalToast = useOptionalToast();
const [savedMessage, setSavedMessage] = useState(false);
const cancelLayout = () => { layoutState.cancelEditing(); requestAnimationFrame(() => customizeButton.current?.focus()); };
const saveLayout = async () => {
if (await layoutState.save()) {
setSavedMessage(true);
optionalToast?.toast(t("dashboardSaved"), "success");
requestAnimationFrame(() => customizeButton.current?.focus());
}
};
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;
// 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 = !layoutState.editing ? <Button ref={customizeButton} variant="outlined" startIcon={<TuneIcon />} disabled={layoutState.loading || Boolean(layoutState.loadError)} onClick={() => { setSavedMessage(false); layoutState.startEditing(); }}>{t("dashboardCustomize")}</Button> : null;
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>;
const reports: Record<WidgetId, React.ReactNode> = {
cards: summaryReady && hasJobs ? <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,
attention: summaryReady && hasJobs ? <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,
funnel: summaryReady && hasJobs ? pipelineReport : null,
activity: trendsReady && (reporting || hasJobs || hasActivity) ? <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,
stageAge: summaryReady && hasJobs && (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,
sources: summaryReady && hasJobs && (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,
salary: summaryReady && (overview?.salaryInsights?.length ?? 0) > 0 ? <PageSection title={t("dashboardSalaryInsights")} description={t("dashboardSalaryDefinition")}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: reporting || activeLayout.widgets.find(widget => widget.id === "salary")?.size === "full" ? "repeat(3, minmax(0, 1fr))" : "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,
companies: summaryReady && hasJobs ? <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,
skills: trendsReady && (reporting || hasJobs || tags.length > 0) ? <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,
};
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>
{!reporting && layoutState.editing && layoutState.draft ? <>
<DashboardLayoutEditor draft={layoutState.draft} dirty={layoutState.dirty} saving={layoutState.saving || layoutState.loading}
saveError={layoutState.saveError} conflict={layoutState.conflict} onChange={layoutState.setDraft}
onSave={() => void saveLayout()} onCancel={cancelLayout} onReload={() => void layoutState.reload()} />
{dataRouter ? <DashboardNavigationGuard dirty={layoutState.dirty} saving={layoutState.saving} /> : null}
</> : null}
{!reporting && layoutState.loadError ? <Alert severity="warning" action={<Button onClick={() => void layoutState.reload()}>{t("retry")}</Button>}>
{t(layoutState.loadError === "unsupportedVersion" ? "dashboardUnsupportedVersion" : "dashboardLoadFailed")}
</Alert> : null}
{!reporting && savedMessage ? <Typography role="status">{t("dashboardSaved")}</Typography> : null}
<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}
<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}
{reporting ? <>
{reports.activity}
{reports.funnel}
{summaryReady && hasJobs ? <Box sx={grid}>{reports.stageAge}{reports.sources}</Box> : null}
{reports.salary}
<Box sx={grid}>{reports.companies}{reports.skills}</Box>
</> : <DashboardGrid layout={activeLayout} editing={layoutState.editing} disabled={layoutState.saving || layoutState.conflict || layoutState.loading}
onChange={layoutState.setDraft} childrenFor={widget => {
const content = reports[widget.id];
const ready = widget.id === "activity" || widget.id === "skills" ? trendsReady : summaryReady;
if (!content && !(ready && (hasJobs || layoutState.editing))) return null;
return content ?? <PageSection title={t(WIDGETS.find(item => item.id === widget.id)!.titleKey)}><Typography color="text.secondary">{t("dashboardNoSample")}</Typography></PageSection>;
}} />}
</Box>
);
}