import React, { useCallback, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { Box, Button, Card, Checkbox, Chip, LinearProgress, Menu, MenuItem, Stack, Typography, } from "@mui/material"; import useMediaQuery from "@mui/material/useMediaQuery"; import { alpha, useTheme } from "@mui/material/styles"; import TuneIcon from "@mui/icons-material/Tune"; import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward"; import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward"; import { api } from "../api"; import ViewStateNotice from "./ViewStateNotice"; import OnboardingChecklist from "./OnboardingChecklist"; import { getUserKeyFromToken } from "../themePrefs"; import { useI18n } from "../i18n/I18nProvider"; import { statusLabel } from "../pipeline"; 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)); } function clamp(n: number, a: number, b: number) { return Math.max(a, Math.min(b, n)); } function buildLinePath(values: number[], width: number, height: number) { if (!values.length) return ""; const min = Math.min(...values); const max = Math.max(...values); const step = width / Math.max(1, values.length - 1); const yFor = (value: number) => { const t = max === min ? 0.5 : (value - min) / (max - min); return height - t * height; }; return values .map((value, index) => `${index === 0 ? "M" : "L"} ${Math.round(index * step)} ${Math.round(yFor(value))}`) .join(" "); } // Mockup cards float on the grey page background purely via the theme's Card shadow/radius -- // this wraps MuiCard instead of Paper-with-hardcoded-shadow so every section stays in sync with // theme.ts (see 02-dashboard.png). function SectionCard({ children, sx = {} }: { children: React.ReactNode; sx?: any }) { return ( {children} ); } // "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() { const theme = useTheme(); const isMobile = useMediaQuery("(max-width:767.95px)"); const navigate = useNavigate(); const { t } = useI18n(); const [months, setMonths] = useState<6 | 12 | 24>(12); const [prefs, setPrefs] = useState(() => loadPrefs()); 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"), api.get("/jobapplications/reminders", { params: { upcomingDays: 14 } }), ]); return { stats: statsResponse.data, overview: overviewResponse.data, reminderJobs: Array.isArray(remindersResponse.data) ? remindersResponse.data : [], }; }, []); const summaryResource = useViewResource( loadSummary, { initialData: { stats: null as JobStats | null, overview: null as OverviewAnalytics | null, reminderJobs: [] as ReminderJob[] }, errorMessage: t("dashboardSummaryUnavailableTitle"), deps: [t], }, ); 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, ...params } }), 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("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 tagColors = useMemo( () => [theme.palette.primary.main, theme.palette.success.main, theme.palette.warning.main, theme.palette.info.main, theme.palette.error.main], [theme.palette.error.main, theme.palette.info.main, theme.palette.primary.main, theme.palette.success.main, theme.palette.warning.main], ); const summaryView = useMemo(() => { const topSource = overview?.responseRateBySource?.[0]; const missingCvCount = reminderJobs.filter((job) => job.workflowSignal?.hasPackageGap).length; const interviewCount = overview?.funnel?.find((item) => item.label === "Interview")?.count ?? 0; const interviewPrepCount = reminderJobs.filter((job) => job.workflowSignal?.actionKey === "interview-prep").length; const overdueFollowUpCount = reminderJobs.filter((job) => job.workflowSignal?.actionKey === "follow-up").length; return { funnelMax: overview?.funnel?.length ? Math.max(...overview.funnel.map((item) => item.count)) : 0, topSource, missingCvCount, interviewCount, interviewPrepCount, overdueFollowUpCount, priorityJobs: reminderJobs.slice(0, 5), }; }, [overview, reminderJobs]); const trendsView = useMemo(() => { const appliedValues = analytics.map((x) => x.applied); const responseValues = analytics.map((x) => x.responses); const chartWidth = 860; const chartHeight = isMobile ? 210 : 250; const totalApplied = appliedValues.reduce((sum, value) => sum + value, 0); const totalResponses = responseValues.reduce((sum, value) => sum + value, 0); const maxTagCount = Math.max(...tags.map((item) => item.count), 1); return { appliedValues, responseValues, chartWidth, chartHeight, appliedPath: buildLinePath(appliedValues, chartWidth, chartHeight), responsePath: buildLinePath(responseValues, chartWidth, chartHeight), totalApplied, totalResponses, responseRate: totalApplied > 0 ? Math.round((totalResponses / totalApplied) * 100) : 0, maxTagCount, }; }, [analytics, isMobile, tags]); // Mockup's 4 stat tiles (Active applications / Response rate / Interviews / Needs follow-up) // -- every value below is derived from data this view already fetches, no new endpoints. const metricCards = useMemo(() => { const cards: { label: string; value: string | number; trend?: { text: string; tone: "success" | "error" }; caption?: string }[] = [ { label: t("dashboardActiveApplications"), value: stats?.active ?? 0, trend: (stats?.appliedLast30Days ?? 0) > 0 ? { text: `+${stats?.appliedLast30Days} ${t("dashboardNewApplications").toLowerCase()}`, tone: "success" } : undefined, }, { label: t("dashboardResponseRateTile"), value: `${trendsView.responseRate}%`, caption: summaryView.topSource ? `${summaryView.topSource.label} · ${summaryView.topSource.rate}%` : t("dashboardResponseSources"), }, { label: t("dashboardInterviews"), value: summaryView.interviewCount, caption: summaryView.interviewPrepCount > 0 ? t("dashboardUpcomingCount", { count: summaryView.interviewPrepCount }) : undefined, }, { label: t("dashboardFollowUpsDue"), value: reminderJobs.length, trend: summaryView.overdueFollowUpCount > 0 ? { text: t("dashboardOverdueCount", { count: summaryView.overdueFollowUpCount }), tone: "error" } : undefined, }, ]; return cards; }, [reminderJobs.length, stats?.active, stats?.appliedLast30Days, summaryView.interviewCount, summaryView.interviewPrepCount, summaryView.overdueFollowUpCount, summaryView.topSource, t, trendsView.responseRate]); const togglePref = useCallback((key: keyof Prefs) => { setPrefs((current) => { const next = { ...current, [key]: !current[key] }; savePrefs(next); return next; }); }, []); const getReminderAction = useCallback((job: ReminderJob) => getWorkflowAction(job, { packageWork: t("jobTablePackageWork"), followUp: t("jobTableFollowUp"), interviewPrep: t("jobTableInterviewStage"), readiness: t("jobTableReadiness"), }), [t]); const openReminderJob = useCallback((job: ReminderJob) => { navigate(buildWorkflowPath(job)); }, [navigate]); const timeInStageMax = overview?.timeInStage?.length ? Math.max(...overview.timeInStage.map((item) => item.medianDays), 1) : 1; const hasJobs = (stats?.total ?? 0) > 0; const hasActivityData = analytics.some((item) => item.applied > 0 || item.responses > 0); const funnelItems = (overview?.funnel ?? []).filter((item) => item.count > 0); const hasStageData = Boolean(overview?.timeInStage?.length); const hasCompanies = Boolean(overview?.topCompanies?.length); const hasSkills = tags.length > 0; const priorityPanel = !summaryResource.loading && !summaryResource.error && hasJobs ? ( {t("dashboardTodayTitle")} {t("dashboardTodayBody")} {summaryView.priorityJobs.length === 0 ? ( {t("remindersNothing")} ) : ( {summaryView.priorityJobs.map((job) => { const action = getReminderAction(job); const tone = reminderTone(job); const toneColor = theme.palette[tone].main; const urgent = tone === "error"; return ( {job.company?.name ?? t("jobTableCompany")} • {job.jobTitle} {action?.detail ?? job.workflowSignal?.reason ?? job.followUpReason ?? t("remindersFollowUpLabel")} ); })} )} ) : null; return ( 0} /> {hasJobs ? {([6, 12, 24] as const).map((m) => ( ))} setPrefsAnchor(null)}> {[ ["cards", t("dashboardSummaryCards")], ["activity", t("dashboardActivityChart")], ["funnel", t("dashboardConversionFunnel")], ["companies", t("dashboardTopCompanies")], ["skills", t("dashboardSkillsInsights")], ].map(([key, label]) => ( togglePref(key as keyof Prefs)}> {label} ))} : null} {priorityPanel ? {priorityPanel} : null} {!summaryResource.loading && !summaryResource.error && hasJobs && prefs.cards ? ( {metricCards.map((card) => ( {card.label} {card.value} {card.trend || card.caption ? ( {card.trend ? ( <> {card.trend.tone === "success" ? ( ) : ( )} {card.trend.text} ) : ( {card.caption} )} ) : null} ))} ) : null} {!summaryResource.loading && !summaryResource.error && hasActivityData && prefs.activity ? ( {t("dashboardApplicationActivity")} {t("dashboardMonthlyApplicationsResponses")} {t("dashboardAppliedCount", { count: trendsView.totalApplied })} {t("dashboardResponsesCount", { count: trendsView.totalResponses })} {[0.2, 0.4, 0.6, 0.8].map((tick) => ( ))} {trendsView.responsePath ? : null} {trendsView.appliedPath ? : null} {analytics.map((point) => ( {point.month.slice(5)} ))} ) : null} {!summaryResource.loading && !summaryResource.error && (hasStageData || funnelItems.length > 0 || summaryView.topSource) ? ( {overview?.timeInStage?.length ? (<> {t("dashboardTimeInStageTitle")} {overview.timeInStage.map((item, index) => ( {statusLabel(t, item.stage)} {t("dashboardTimeInStageValue", { days: item.medianDays, count: item.count })} ))} ) : null} {tags.length ? ( {t("dashboardTopSkills")} {tags.slice(0, 4).map((tag) => ( ))} ) : null} {funnelItems.length > 0 ? <> {t("dashboardConversionFunnelTitle")} {funnelItems.map((item) => { const width = summaryView.funnelMax ? clamp((item.count / summaryView.funnelMax) * 100, 0, 100) : 0; return ( {statusLabel(t, item.label)} {item.count} ); })} : null} {summaryView.topSource ? {summaryView.topSource?.label ?? t("dashboardResponseSources")} {summaryView.topSource ? `${summaryView.topSource.rate}%` : "—"} {summaryView.topSource ? t("dashboardResponseConversion", { responses: summaryView.topSource.responses, total: summaryView.topSource.total }) : t("dashboardNoSourceData")} : null} ) : null} {(overview?.salaryInsights?.length ?? 0) > 0 ? ( {t("dashboardSalaryInsights")} {t("dashboardSalaryInsightsBody")} {overview!.salaryInsights!.map((item) => { const money = new Intl.NumberFormat(undefined, { style: "currency", currency: item.currency, maximumFractionDigits: 0 }); return ( {t("dashboardSalaryGroup", { currency: item.currency, period: item.period, count: item.count })} {money.format(item.averageMidpoint)} {t("dashboardSalaryRange", { minimum: money.format(item.minimum), maximum: money.format(item.maximum) })} ); })} ) : null} {!summaryResource.loading && !summaryResource.error && hasCompanies && prefs.companies ? ( {t("dashboardTopCompaniesByActivity")} {(overview?.topCompanies ?? []).map((item, index) => ( {item.company} {t("dashboardCompanyJobsResponses", { jobs: item.count, responses: item.responses })} = 50 ? "success" : item.responseRate >= 25 ? "warning" : "default"} variant="outlined" /> ))} ) : null} {!trendsResource.loading && !trendsResource.error && hasSkills && prefs.skills ? ( {t("dashboardTopSkills")} {tags.slice(0, 8).map((tag, index) => { const max = Math.max(...tags.map((item) => item.count), 1); const width = (tag.count / max) * 100; return ( {tag.tag} {tag.count} ); })} {t("dashboardSkillTrends")} {!tagTrends || tagTrends.series.length === 0 ? ( {t("dashboardNoTagTrendData")} ) : ( {tagTrends.series.map((series, index) => ( {series.tag} {t("dashboardTotalCount", { count: series.counts.reduce((sum, value) => sum + value, 0) })} {series.counts.map((count, i) => ( 0 ? alpha(tagColors[index % tagColors.length], 0.22 + Math.min(0.6, count / 10)) : alpha(theme.palette.text.primary, 0.05), }} title={`${tagTrends.months[i]}: ${count}`} /> ))} ))} )} ) : null} ); }