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; 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(() => 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(null); const loadSummary = useCallback(async () => { const [statsResponse, overviewResponse, remindersResponse] = await Promise.all([ api.get("/jobapplications/stats"), api.get("/jobapplications/analytics-overview"), reporting ? Promise.resolve({ data: [] as ReminderJob[] }) : api.get("/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("/jobapplications/analytics", { params }), api.get("/jobapplications/tags", { params: { limit: 10 } }), api.get("/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) =>