feat(ui): add existing-report analytics destination
This commit is contained in:
@@ -17,6 +17,7 @@ import MemoryIcon from "@mui/icons-material/Memory";
|
||||
import DescriptionOutlinedIcon from "@mui/icons-material/DescriptionOutlined";
|
||||
import MailOutlineIcon from "@mui/icons-material/MailOutline";
|
||||
import CompareArrowsIcon from "@mui/icons-material/CompareArrows";
|
||||
import InsightsOutlinedIcon from "@mui/icons-material/InsightsOutlined";
|
||||
|
||||
import { Navigate, Route, Routes, useLocation, useNavigate, useParams, createBrowserRouter, RouterProvider } from "react-router-dom";
|
||||
|
||||
@@ -46,6 +47,7 @@ import { isSessionInvalidated, SESSION_INVALIDATED_EVENT } from "./sessionBounda
|
||||
|
||||
const AddJobModal = lazy(() => import("./components/AddJobModal"));
|
||||
const DashboardView = lazy(() => import("./components/DashboardView"));
|
||||
const AnalyticsPage = lazy(() => import("./views/AnalyticsPage"));
|
||||
const JobDiscoveryPage = lazy(() => import("./views/JobDiscoveryPage"));
|
||||
const CompaniesTable = lazy(() => import("./components/CompaniesTable"));
|
||||
const SettingsView = lazy(() => import("./components/SettingsView"));
|
||||
@@ -94,7 +96,8 @@ type MeResponse = {
|
||||
};
|
||||
|
||||
function breadcrumbsFor(path: string, t: (k: any) => string): string[] {
|
||||
if (path.startsWith("/dashboard")) return [t("home"), t("analytics"), t("overview")];
|
||||
if (path.startsWith("/dashboard")) return [t("home"), t("dashboard")];
|
||||
if (path.startsWith("/analytics")) return [t("home"), t("insights"), t("analytics")];
|
||||
if (path.startsWith("/discover")) return [t("home"), t("discoverJobs")];
|
||||
if (/^\/jobs\/\d+/.test(path)) return [t("home"), t("jobApplications"), t("jobDetails")];
|
||||
if (path.startsWith("/jobs")) return [t("home"), t("jobApplications")];
|
||||
@@ -119,6 +122,7 @@ function breadcrumbsFor(path: string, t: (k: any) => string): string[] {
|
||||
|
||||
function titleFor(path: string, t: (k: any) => string): string {
|
||||
if (path === "/dashboard") return t("dashboard");
|
||||
if (path.startsWith("/analytics")) return t("analytics");
|
||||
if (path.startsWith("/reminders")) return t("reminders");
|
||||
if (path.startsWith("/operations")) return t("operations");
|
||||
if (path.startsWith("/discover")) return t("discoverJobs");
|
||||
@@ -144,6 +148,7 @@ function titleFor(path: string, t: (k: any) => string): string {
|
||||
function subtitleFor(path: string, t: (k: any) => string): string | undefined {
|
||||
if (/^\/settings\/?$/.test(path)) return t("settingsPageDescription");
|
||||
if (path === "/dashboard") return t("dashboardPageSubtitle");
|
||||
if (path.startsWith("/analytics")) return t("analyticsPageSubtitle");
|
||||
if (path.startsWith("/discover")) return t("discoverJobsSubtitle");
|
||||
if (/^\/jobs\/\d+/.test(path)) return t("jobDetailsWorkspaceSubtitle");
|
||||
if (path.startsWith("/jobs")) return t("jobsPageSubtitle");
|
||||
@@ -306,6 +311,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
{ to: "/correspondence", label: t("correspondenceInbox"), icon: <MailOutlineIcon fontSize="small" />, section: t("jobSearch") },
|
||||
{ to: "/career", label: t("careerWorkspaceProfileTab"), icon: <AccountCircleIcon fontSize="small" />, section: t("career") },
|
||||
{ to: "/career/builder", label: t("cvBuilder"), icon: <DescriptionOutlinedIcon fontSize="small" />, section: t("career") },
|
||||
{ to: "/analytics", label: t("analytics"), icon: <InsightsOutlinedIcon fontSize="small" />, section: t("insights") },
|
||||
];
|
||||
|
||||
const navBottom: NavItem[] = [
|
||||
@@ -387,6 +393,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/dashboard" element={<DashboardView />} />
|
||||
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||
<Route path="/discover" element={<JobDiscoveryPage />} />
|
||||
<Route path="/jobs" element={<JobTable refreshToken={refreshToken} pageSize={jobPageSize} onPageSizeChange={setAndPersistPageSize} columns={jobColumns} onColumnsChange={setAndPersistColumns} mode="jobs" />} />
|
||||
<Route path="/jobs/:id" element={<ApplicationWorkspacePage />} />
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { MemoryRouter, useLocation, useNavigate } from 'react-router-dom';
|
||||
import AnalyticsPage from './views/AnalyticsPage';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
import { api } from './api';
|
||||
import { getUserKeyFromToken } from './themePrefs';
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
const snapshot = { total: 5, active: 4, byStatus: { Applied: 2, 'Faglig vurdering': 2 } };
|
||||
const overview = { funnel: [{ label: 'Applied', count: 2 }], responseRateBySource: [{ label: 'NAV', total: 4, responses: 1, rate: 25 }], topCompanies: [{ companyId: 7, company: 'Nordlys æøå', count: 4, responses: 1, responseRate: 25 }], timeInStage: [{ stage: 'Applied', medianDays: 3, count: 2 }], salaryInsights: [{ currency: 'NOK', period: 'year', count: 2, minimum: 600000, maximum: 800000, averageMidpoint: 700000 }] };
|
||||
function response(url: string) {
|
||||
if (url.endsWith('/stats')) return snapshot;
|
||||
if (url.endsWith('/analytics-overview')) return overview;
|
||||
if (url.endsWith('/analytics')) return [{ month: '2026-08', applied: 2, responses: 1 }, { month: '2026-09', applied: 1, responses: 0 }];
|
||||
if (url.endsWith('/tags')) return Array.from({ length: 10 }, (_, i) => ({ tag: `Tag ${i}`, count: i + 1 }));
|
||||
if (url.endsWith('/tag-trends')) return { months: ['2026-08', '2026-09'], series: [{ tag: 'Azure', counts: [2, 1] }] };
|
||||
return [];
|
||||
}
|
||||
function History() { const location = useLocation(); const navigate = useNavigate(); return <><output aria-label="Destination">{location.pathname}{location.search}</output><button onClick={() => navigate(-1)}>Back</button></>; }
|
||||
function open(entry = '/analytics?months=6') { return render(<I18nProvider><MemoryRouter initialEntries={[entry]}><History /><AnalyticsPage /></MemoryRouter></I18nProvider>); }
|
||||
beforeEach(() => { localStorage.clear(); mockedApi.get.mockReset(); mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: response(url) }) as any); });
|
||||
|
||||
test('[PRES-S01 S02] all existing reports have exact values independent of hidden Dashboard flags', async () => {
|
||||
const key = `dashboardPrefs:${getUserKeyFromToken()}`;
|
||||
localStorage.setItem(key, JSON.stringify({ cards: false, activity: false, funnel: false, companies: false, skills: false }));
|
||||
const stored = localStorage.getItem(key);
|
||||
open();
|
||||
const activity = await screen.findByRole('region', { name: 'Application activity' });
|
||||
expect(within(activity).getByText(/1 \/ 3 recorded responses/)).toBeVisible();
|
||||
expect(within(activity).getByRole('row', { name: 'August 2026 2 1' })).toBeVisible();
|
||||
for (const name of ['Current pipeline', 'Median current-stage age', 'Response sources', 'Salary insights', 'Top companies by activity', 'Job tags']) expect(screen.getByRole('region', { name })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Faglig vurdering' })).toBeVisible();
|
||||
expect(screen.getByText('Tag 9')).toBeVisible();
|
||||
expect(screen.getByRole('row', { name: 'September 2026 1' })).toBeVisible();
|
||||
expect(screen.queryByRole('button', { name: 'Customize dashboard' })).not.toBeInTheDocument();
|
||||
expect(mockedApi.get.mock.calls.some(([url]) => String(url).includes('reminders'))).toBe(false);
|
||||
expect(localStorage.getItem(key)).toBe(stored);
|
||||
});
|
||||
|
||||
test('[PRES-S01 E07] period is direct-loadable and browser Back restores the period without refetching snapshot', async () => {
|
||||
open('/analytics?months=6&origin=retained');
|
||||
await screen.findByRole('region', { name: 'Application activity' });
|
||||
expect(mockedApi.get).toHaveBeenCalledWith('/jobapplications/analytics', { params: { months: 6 } });
|
||||
mockedApi.get.mockClear(); fireEvent.click(screen.getByRole('button', { name: '24 mo' }));
|
||||
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/jobapplications/tag-trends', { params: { months: 24, limit: 5 } }));
|
||||
expect(screen.getByLabelText('Destination')).toHaveTextContent('/analytics?months=24&origin=retained');
|
||||
expect(mockedApi.get).not.toHaveBeenCalledWith('/jobapplications/stats');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Back' }));
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '6 mo' })).toHaveAttribute('aria-pressed', 'true'));
|
||||
});
|
||||
|
||||
test('[PRES-S01] failed snapshot remains an error while trends and retry stay available', async () => {
|
||||
let fail = true;
|
||||
mockedApi.get.mockImplementation((url: string) => url.endsWith('/stats') && fail ? Promise.reject(new Error('offline')) : Promise.resolve({ data: response(url) }) as any);
|
||||
open(); await screen.findByText('Unable to load current reports');
|
||||
expect(screen.getByText('Current job reports are unavailable right now. Application activity and tag trends load separately.')).toBeVisible();
|
||||
expect(screen.queryByText('The dashboard summary is unavailable right now.')).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole('region', { name: 'Application activity' })).toBeVisible();
|
||||
expect(screen.queryByText('No tracked jobs to report yet.')).not.toBeInTheDocument();
|
||||
fail = false; fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
|
||||
expect(await screen.findByRole('link', { name: 'Faglig vurdering' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[PRES-S01] empty cohorts do not invent a denominator, and unsupported URL periods default safely', async () => {
|
||||
mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: url.endsWith('/stats') ? { total: 0, active: 0, byStatus: {} } : url.endsWith('/analytics-overview') ? { funnel: [], responseRateBySource: [], topCompanies: [] } : url.endsWith('/analytics') ? [{ month: '2026-09', applied: 0, responses: 0 }] : url.endsWith('/tag-trends') ? { months: [], series: [] } : [] }) as any);
|
||||
open('/analytics?months=999'); await screen.findByText('No tracked jobs to report yet.');
|
||||
expect(mockedApi.get).toHaveBeenCalledWith('/jobapplications/analytics', { params: { months: 12 } });
|
||||
expect(screen.queryByText(/0%/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText('No applications or dated responses in this period.')).toBeVisible();
|
||||
});
|
||||
|
||||
test('[PRES-J05 J09] drill-down retains supported raw status and company identities', async () => {
|
||||
open(); fireEvent.click(await screen.findByRole('link', { name: 'Faglig vurdering' }));
|
||||
expect(screen.getByLabelText('Destination')).toHaveTextContent('/jobs?status=Faglig+vurdering');
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Nordlys æøå' }));
|
||||
expect(screen.getByLabelText('Destination')).toHaveTextContent('/jobs?companyId=7');
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { Link as RouterLink, useNavigate } from "react-router-dom";
|
||||
import { Link as RouterLink, useNavigate, useSearchParams } from "react-router-dom";
|
||||
|
||||
import {
|
||||
Box,
|
||||
@@ -88,18 +88,29 @@ function reminderTone(job: ReminderJob): "error" | "warning" | "info" {
|
||||
return "info";
|
||||
}
|
||||
|
||||
export default function DashboardView() {
|
||||
export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboard" | "analytics" }) {
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const { t, language } = useI18n();
|
||||
const [months, setMonths] = useState<6 | 12 | 24>(12);
|
||||
const [prefs, setPrefs] = useState<Prefs>(() => loadPrefs());
|
||||
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"),
|
||||
api.get<ReminderJob[]>("/jobapplications/reminders", { params: { upcomingDays: 14 } }),
|
||||
reporting ? Promise.resolve({ data: [] as ReminderJob[] }) : api.get<ReminderJob[]>("/jobapplications/reminders", { params: { upcomingDays: 14 } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -107,14 +118,14 @@ export default function DashboardView() {
|
||||
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("dashboardSummaryUnavailableTitle"),
|
||||
deps: [t],
|
||||
errorMessage: t(reporting ? "analyticsSnapshotUnavailableTitle" : "dashboardSummaryUnavailableTitle"),
|
||||
deps: [t, reporting],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -137,7 +148,7 @@ export default function DashboardView() {
|
||||
loadTrends,
|
||||
{
|
||||
initialData: { analytics: [] as AnalyticsPoint[], tags: [] as TagPoint[], tagTrends: null as TagTrendResponse | null },
|
||||
errorMessage: t("dashboardTrendsUnavailableTitle"),
|
||||
errorMessage: t(reporting ? "analyticsTrendsUnavailableTitle" : "dashboardTrendsUnavailableTitle"),
|
||||
deps: [months, t],
|
||||
},
|
||||
);
|
||||
@@ -156,7 +167,7 @@ export default function DashboardView() {
|
||||
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 = (stats?.total ?? 0) > 0;
|
||||
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);
|
||||
@@ -187,13 +198,33 @@ export default function DashboardView() {
|
||||
</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" }}>{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" } }}>
|
||||
<OnboardingChecklist hasJobs={hasJobs} />
|
||||
{!reporting ? <OnboardingChecklist hasJobs={hasJobs} /> : null}
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<Typography variant="body2" color="text.secondary">{t("dashboardSnapshotScope")}</Typography>
|
||||
{prefsButton}
|
||||
<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)}>
|
||||
{([
|
||||
@@ -208,14 +239,14 @@ export default function DashboardView() {
|
||||
</Menu>
|
||||
|
||||
<ViewStateNotice loading={summaryResource.loading || summaryResource.refreshing} error={summaryResource.error}
|
||||
title={t("dashboardSummaryUnavailableTitle")} description={t("dashboardSummaryUnavailableBody")} onRetry={summaryResource.reload} />
|
||||
{summaryReady && !hasJobs ? <PageSection title={t("dashboardStartTitle")} description={t("dashboardStartBody")}
|
||||
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}
|
||||
{allHidden ? <Typography role="status" color="text.secondary">{t("dashboardAllHidden")}</Typography> : null}
|
||||
{!reporting && allHidden ? <Typography role="status" color="text.secondary">{t("dashboardAllHidden")}</Typography> : null}
|
||||
|
||||
{summaryReady && hasJobs && prefs.cards ? (
|
||||
{!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" },
|
||||
@@ -232,8 +263,8 @@ export default function DashboardView() {
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{summaryReady && hasJobs ? <Box sx={grid}>
|
||||
<PageSection title={t("dashboardTodayTitle")} description={t("dashboardTodayBody")} actions={<Button component={RouterLink} to="/reminders">{t("reminders")}</Button>}>
|
||||
{!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),
|
||||
@@ -254,37 +285,20 @@ export default function DashboardView() {
|
||||
})}
|
||||
{!reminderJobs.length ? <Typography color="text.secondary">{t("remindersNothing")}</Typography> : null}
|
||||
</Stack>
|
||||
</PageSection>
|
||||
{prefs.funnel ? <PageSection surface title={t("dashboardCurrentPipeline")} description={t("dashboardPipelineBody")}>
|
||||
<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" sx={detailsSx}>
|
||||
<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" }}>{statusLabel(t, status)}</Link>
|
||||
<Typography variant="body2">{number(count)}</Typography>
|
||||
</Box>)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</PageSection> : null}
|
||||
{prefs.funnel ? pipelineReport : null}
|
||||
</Box> : null}
|
||||
|
||||
<ViewStateNotice loading={trendsResource.loading || trendsResource.refreshing} error={trendsResource.error}
|
||||
title={t("dashboardTrendsUnavailableTitle")} description={t("dashboardTrendsUnavailableBody")} onRetry={trendsResource.reload} compact />
|
||||
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. */}
|
||||
{(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")}>
|
||||
{(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 && (hasJobs || hasActivity) && prefs.activity ? <PageSection surface title={t("dashboardApplicationActivity")} description={t("dashboardActivityDefinition")}>
|
||||
{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}
|
||||
@@ -301,7 +315,7 @@ export default function DashboardView() {
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary">{t("dashboardChartLegend", { max: number(chart.max) })}</Typography>
|
||||
</>}
|
||||
<Box component="details" sx={detailsSx}>
|
||||
<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>
|
||||
@@ -310,6 +324,7 @@ export default function DashboardView() {
|
||||
</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}>
|
||||
@@ -346,8 +361,8 @@ export default function DashboardView() {
|
||||
</Box>)}</Stack>
|
||||
{!overview?.topCompanies.length ? <Typography color="text.secondary">{t("companiesEmpty")}</Typography> : null}
|
||||
</PageSection> : null}
|
||||
{trendsReady && (hasJobs || tags.length > 0) && prefs.skills ? <PageSection title={t("dashboardJobTags")} description={t("dashboardTagsBody")}>
|
||||
<Stack spacing={1.5}>{tags.slice(0, 8).map(tag => <Box key={tag.tag}>
|
||||
{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>
|
||||
@@ -362,7 +377,7 @@ export default function DashboardView() {
|
||||
</Box>
|
||||
</Box>)}</Stack>
|
||||
</Box> : null}
|
||||
<Box component="details" sx={detailsSx}>
|
||||
<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 }}>
|
||||
|
||||
@@ -97,7 +97,7 @@ export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAd
|
||||
|
||||
const commands = useMemo<CommandItem[]>(() => {
|
||||
const base: CommandItem[] = [
|
||||
{ id: "go-dashboard", label: t("goToDashboard"), hint: t("analyticsOverview"), action: () => onNavigate("/dashboard") },
|
||||
{ id: "go-dashboard", label: t("goToDashboard"), hint: t("dashboardPageSubtitle"), action: () => onNavigate("/dashboard") },
|
||||
{ id: "go-jobs", label: t("goToJobs"), hint: t("mainApplicationsTable"), action: () => onNavigate("/jobs") },
|
||||
{ id: "go-reminders", label: t("goToReminders"), hint: t("followUpQueue"), action: () => onNavigate("/reminders") },
|
||||
{ id: "go-companies", label: t("goToCompanies"), hint: t("crmAndSourceTracking"), action: () => onNavigate("/companies") },
|
||||
|
||||
@@ -30,6 +30,15 @@ export const translations = {
|
||||
location: "Location",
|
||||
home: "Home",
|
||||
analytics: "Analytics",
|
||||
insights: "Insights",
|
||||
analyticsPageSubtitle: "Explore recorded application trends and current job breakdowns.",
|
||||
analyticsScope: "The period below applies only to application activity and monthly tag trends. Pipeline, stage ages, sources, companies and salary describe your current non-deleted jobs; tag totals are all-time. Reports do not measure historical conversion.",
|
||||
analyticsTagsBody: "Top ten job tags across all non-deleted jobs. All time; not verified profile skills.",
|
||||
analyticsEmptyTitle: "No tracked jobs to report yet.",
|
||||
analyticsEmptyBody: "Add a job to start recording your search. Reports use your own saved data, with no sample statistics.",
|
||||
analyticsSnapshotUnavailableTitle: "Unable to load current reports",
|
||||
analyticsSnapshotUnavailableBody: "Current job reports are unavailable right now. Application activity and tag trends load separately.",
|
||||
analyticsTrendsUnavailableTitle: "Unable to load report trends",
|
||||
overview: "Overview",
|
||||
dashboardPageSubtitle: "Your next actions, current pipeline and application activity.",
|
||||
jobsPageSubtitle: "Filter, search, and manage every application in one workspace.",
|
||||
@@ -2535,6 +2544,15 @@ export const translations = {
|
||||
location: "Sted",
|
||||
home: "Hjem",
|
||||
analytics: "Analyse",
|
||||
insights: "Innsikt",
|
||||
analyticsPageSubtitle: "Utforsk registrerte søknadstrender og fordelinger for nåværende jobber.",
|
||||
analyticsScope: "Perioden nedenfor gjelder bare søknadsaktivitet og månedlige taggtrender. Stillingsløp, tid i fase, kilder, selskaper og lønn beskriver nåværende jobber som ikke er slettet; samlet taggantall gjelder alle perioder. Rapportene måler ikke historisk konvertering.",
|
||||
analyticsTagsBody: "De ti vanligste jobbtaggene på alle jobber som ikke er slettet. Alle perioder; ikke bekreftede ferdigheter fra profilen.",
|
||||
analyticsEmptyTitle: "Ingen registrerte jobber å rapportere ennå.",
|
||||
analyticsEmptyBody: "Legg til en jobb for å begynne å registrere søket ditt. Rapportene bruker dine egne lagrede data, uten eksempelstatistikk.",
|
||||
analyticsSnapshotUnavailableTitle: "Kunne ikke laste nåværende rapporter",
|
||||
analyticsSnapshotUnavailableBody: "Rapportene for nåværende jobber er ikke tilgjengelige akkurat nå. Søknadsaktivitet og taggtrender lastes separat.",
|
||||
analyticsTrendsUnavailableTitle: "Kunne ikke laste rapporttrender",
|
||||
overview: "Oversikt",
|
||||
dashboardPageSubtitle: "Neste steg, nåværende statusfordeling og søknadsaktivitet.",
|
||||
jobsPageSubtitle: "Filtrer, søk og administrer alle søknader på ett sted.",
|
||||
|
||||
@@ -216,6 +216,14 @@ export default function AppShell({
|
||||
() => activeNavItemTo(pathname, [...nav, ...navBottom]),
|
||||
[pathname, nav, navBottom],
|
||||
);
|
||||
const desktopNavigationRef = useRef<HTMLDivElement>(null);
|
||||
const mobileNavigationRef = useRef<HTMLDivElement>(null);
|
||||
// New destinations can extend the existing scrollable list below its initial viewport.
|
||||
// Reveal the actual current item on entry, without resetting a user's later manual scroll.
|
||||
useEffect(() => {
|
||||
const navigation = isMobile ? mobileNavigationRef.current : desktopNavigationRef.current;
|
||||
navigation?.querySelector<HTMLElement>('[aria-current="page"]')?.scrollIntoView?.({ block: "nearest", inline: "nearest" });
|
||||
}, [activeTo, navCollapsed, isMobile, drawerOpen, headerHeight]);
|
||||
|
||||
const renderNavList = (groups: Array<[string, NavItem[]]>) => (
|
||||
<Box sx={{ px: navCollapsed ? 0.75 : 1.25, pt: 1 }}>
|
||||
@@ -577,6 +585,7 @@ export default function AppShell({
|
||||
</AppBar>
|
||||
|
||||
<Drawer
|
||||
ref={desktopNavigationRef}
|
||||
variant="permanent"
|
||||
sx={{
|
||||
display: isMobile ? "none" : "block",
|
||||
@@ -598,6 +607,7 @@ export default function AppShell({
|
||||
</Drawer>
|
||||
|
||||
<Drawer
|
||||
ref={mobileNavigationRef}
|
||||
variant="temporary"
|
||||
open={isMobile && drawerOpen}
|
||||
onClose={() => onToggleDrawer(false)}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import React from "react";
|
||||
import DashboardView from "../components/DashboardView";
|
||||
|
||||
// One report/data owner; the detail destination never consumes Dashboard visibility flags.
|
||||
export default function AnalyticsPage() {
|
||||
return <DashboardView mode="analytics" />;
|
||||
}
|
||||
Reference in New Issue
Block a user