feat(dashboard): customise widget visibility order and size
This commit is contained in:
@@ -1,11 +1,10 @@
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { Link as RouterLink, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import React, { useCallback, useContext, useMemo, useRef, useState } from "react";
|
||||
import { Link as RouterLink, useNavigate, useSearchParams, UNSAFE_DataRouterContext } from "react-router-dom";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Alert,
|
||||
Stack,
|
||||
Link,
|
||||
Table, TableBody, TableCell, TableHead, TableRow,
|
||||
@@ -13,13 +12,14 @@ import {
|
||||
} 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 { WIDGETS, defaultDashboardLayout, WidgetId } from "../dashboardLayout";
|
||||
import { useDashboardLayout } from "../hooks/useDashboardLayout";
|
||||
import DashboardLayoutEditor, { DashboardNavigationGuard } from "./DashboardLayoutEditor";
|
||||
import { useOptionalToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { statusLabel, statusTone } from "../pipeline";
|
||||
import { PageSection } from "../layout/PageLayout";
|
||||
@@ -52,32 +52,6 @@ type OverviewAnalytics = {
|
||||
};
|
||||
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.
|
||||
@@ -103,9 +77,21 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
|
||||
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 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"),
|
||||
@@ -177,14 +163,6 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
|
||||
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]));
|
||||
@@ -197,7 +175,7 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
|
||||
<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 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}>
|
||||
@@ -219,35 +197,8 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
|
||||
</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 }}>
|
||||
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") },
|
||||
@@ -260,11 +211,8 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
|
||||
</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>}>
|
||||
</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),
|
||||
@@ -285,20 +233,9 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
|
||||
})}
|
||||
{!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")}>
|
||||
</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}
|
||||
@@ -322,25 +259,20 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
|
||||
<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")}>
|
||||
</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}
|
||||
{(overview?.responseRateBySource.length ?? 0) > 0 ? <PageSection title={t("dashboardResponseSources")} description={t("dashboardSourceBody")}>
|
||||
</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}
|
||||
</Box> : null}
|
||||
|
||||
{summaryReady && (overview?.salaryInsights?.length ?? 0) > 0 ? <PageSection title={t("dashboardSalaryInsights")} description={t("dashboardSalaryDefinition")}>
|
||||
</PageSection> : null,
|
||||
salary: 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 });
|
||||
@@ -351,17 +283,15 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
|
||||
</Box>;
|
||||
})}
|
||||
</Box>
|
||||
</PageSection> : null}
|
||||
|
||||
<Box sx={grid}>
|
||||
{summaryReady && hasJobs && prefs.companies ? <PageSection title={t("dashboardTopCompaniesByActivity")} description={t("dashboardCompaniesBody")}>
|
||||
</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}
|
||||
{trendsReady && (reporting || hasJobs || tags.length > 0) && prefs.skills ? <PageSection title={t("dashboardJobTags")} description={t(reporting ? "analyticsTagsBody" : "dashboardTagsBody")}>
|
||||
</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")}
|
||||
@@ -388,8 +318,61 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
|
||||
</Table>
|
||||
</Box>)}
|
||||
</Box>
|
||||
</PageSection> : null}
|
||||
</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>
|
||||
</> : <Box aria-label={layoutState.editing ? t("dashboardPreview") : undefined} sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "repeat(2, minmax(0, 1fr))" }, gap: 3, alignItems: "start" }}>
|
||||
{activeLayout.widgets.filter(widget => widget.visible).map(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 <Box key={widget.id} data-dashboard-widget={widget.id} data-widget-size={widget.size} sx={{ minWidth: 0, gridColumn: { xs: "auto", md: widget.size === "full" ? "1 / -1" : "auto" } }}>
|
||||
{content ?? <PageSection title={t(WIDGETS.find(item => item.id === widget.id)!.titleKey)}><Typography color="text.secondary">{t("dashboardNoSample")}</Typography></PageSection>}
|
||||
</Box>;
|
||||
})}
|
||||
</Box>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user