396 lines
27 KiB
TypeScript
396 lines
27 KiB
TypeScript
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<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[] }[] };
|
||
|
||
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<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"),
|
||
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;
|
||
|
||
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) => <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 = <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", display: "inline-flex", alignItems: "center", minWidth: 24, minHeight: 24 }}>{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" } }}>
|
||
{!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 }}>
|
||
{[
|
||
{ 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}
|
||
|
||
{!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),
|
||
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}
|
||
{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")}>
|
||
<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}
|
||
|
||
{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}>
|
||
<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")}>
|
||
<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")}>
|
||
<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 });
|
||
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}
|
||
|
||
<Box sx={grid}>
|
||
{summaryReady && hasJobs && prefs.companies ? <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")}>
|
||
<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}
|
||
</Box>
|
||
</Box>
|
||
);
|
||
}
|