Polish UI, harden company creation, and add error pages
This commit is contained in:
@@ -1,16 +1,12 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Checkbox,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Tab,
|
||||
Tabs,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { alpha, useTheme } from "@mui/material/styles";
|
||||
@@ -18,6 +14,7 @@ import TuneIcon from "@mui/icons-material/Tune";
|
||||
|
||||
import { api } from "../api";
|
||||
import { getUserKeyFromToken } from "../themePrefs";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
interface JobStats {
|
||||
total: number;
|
||||
@@ -36,24 +33,6 @@ type ReminderJob = {
|
||||
|
||||
type AnalyticsPoint = { month: string; applied: number; responses: number };
|
||||
type TagPoint = { tag: string; count: number };
|
||||
type SummarizerMetrics = {
|
||||
healthy: boolean;
|
||||
model?: string | null;
|
||||
healthLatencyMs?: number | null;
|
||||
probeLatencyMs?: number | null;
|
||||
lastProbeAt?: string | null;
|
||||
lastProbeSuccessAt?: string | null;
|
||||
lastProbeFailureAt?: string | null;
|
||||
probeFailures: number;
|
||||
requests: number;
|
||||
cacheHits: number;
|
||||
cacheMisses: number;
|
||||
failures: number;
|
||||
averageLatencyMs?: number | null;
|
||||
lastSuccessAt?: string | null;
|
||||
lastFailureAt?: string | null;
|
||||
lastError?: string | null;
|
||||
};
|
||||
type OverviewAnalytics = {
|
||||
funnel: { label: string; count: number }[];
|
||||
responseRateBySource: { label: string; total: number; responses: number; rate: number }[];
|
||||
@@ -106,32 +85,15 @@ function toPath(values: number[], w: number, h: number) {
|
||||
return values.map((v, i) => `${i === 0 ? "M" : "L"} ${Math.round(i * dx)} ${Math.round(norm(v))}`).join(" ");
|
||||
}
|
||||
|
||||
function formatRelative(ts?: string | null) {
|
||||
if (!ts) return "Never";
|
||||
const d = new Date(ts);
|
||||
if (Number.isNaN(d.getTime())) return "Unknown";
|
||||
const mins = Math.round((Date.now() - d.getTime()) / 60000);
|
||||
if (mins < 1) return "Just now";
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hours = Math.round(mins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return `${Math.round(hours / 24)}d ago`;
|
||||
}
|
||||
|
||||
export default function DashboardView() {
|
||||
const theme = useTheme();
|
||||
const { t } = useI18n();
|
||||
const [stats, setStats] = useState<JobStats | null>(null);
|
||||
const [overview, setOverview] = useState<OverviewAnalytics | null>(null);
|
||||
const [tagTrends, setTagTrends] = useState<TagTrendResponse | null>(null);
|
||||
const [tab, setTab] = useState(0);
|
||||
const [rangeMode, setRangeMode] = useState<"preset" | "custom">("preset");
|
||||
const [months, setMonths] = useState<6 | 12 | 24>(12);
|
||||
const [fromMonth, setFromMonth] = useState(() => new Date(new Date().getFullYear(), new Date().getMonth() - 11, 1).toISOString().slice(0, 7));
|
||||
const [toMonth, setToMonth] = useState(() => new Date().toISOString().slice(0, 7));
|
||||
const [appliedCustom, setAppliedCustom] = useState<{ from: string; to: string } | null>(null);
|
||||
const [analytics, setAnalytics] = useState<AnalyticsPoint[]>([]);
|
||||
const [tags, setTags] = useState<TagPoint[]>([]);
|
||||
const [summarizerMetrics, setSummarizerMetrics] = useState<SummarizerMetrics | null>(null);
|
||||
const [months, setMonths] = useState<6 | 12 | 24>(12);
|
||||
const [reminderJobs, setReminderJobs] = useState<ReminderJob[]>([]);
|
||||
const [prefs, setPrefs] = useState<Prefs>(() => loadPrefs());
|
||||
const [prefsAnchor, setPrefsAnchor] = useState<HTMLElement | null>(null);
|
||||
@@ -143,34 +105,12 @@ export default function DashboardView() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const params = rangeMode === "custom" && appliedCustom ? { from: `${appliedCustom.from}-01`, to: `${appliedCustom.to}-01` } : { months };
|
||||
|
||||
const params = { months };
|
||||
api.get<AnalyticsPoint[]>("/jobapplications/analytics", { params }).then((r) => setAnalytics(r.data ?? [])).catch(() => setAnalytics([]));
|
||||
api.get<TagPoint[]>("/jobapplications/tags", { params: { limit: 10, ...params } }).then((r) => setTags(r.data ?? [])).catch(() => setTags([]));
|
||||
api.get<TagTrendResponse>("/jobapplications/tag-trends", { params: { months: rangeMode === "custom" ? 6 : months, limit: 5 } }).then((r) => setTagTrends(r.data)).catch(() => setTagTrends(null));
|
||||
}, [months, rangeMode, appliedCustom]);
|
||||
api.get<TagTrendResponse>("/jobapplications/tag-trends", { params: { months, limit: 5 } }).then((r) => setTagTrends(r.data)).catch(() => setTagTrends(null));
|
||||
}, [months]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab !== 2) return;
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
try {
|
||||
const res = await api.get<SummarizerMetrics>("/jobapplications/summarizer-metrics");
|
||||
if (!cancelled) setSummarizerMetrics(res.data);
|
||||
} catch {
|
||||
if (!cancelled) setSummarizerMetrics(null);
|
||||
}
|
||||
};
|
||||
void load();
|
||||
const id = window.setInterval(() => void load(), 30000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(id);
|
||||
};
|
||||
}, [tab]);
|
||||
|
||||
const statusRows = useMemo(() => Object.entries(stats?.byStatus ?? {}).sort((a, b) => b[1] - a[1]), [stats]);
|
||||
const maxStatus = statusRows.length ? Math.max(...statusRows.map(([, v]) => v)) : 0;
|
||||
const chartW = 860;
|
||||
const chartH = 260;
|
||||
const appliedSeries = analytics.map((x) => x.applied);
|
||||
@@ -182,11 +122,11 @@ export default function DashboardView() {
|
||||
const funnelMax = overview?.funnel?.length ? Math.max(...overview.funnel.map((x) => x.count)) : 0;
|
||||
|
||||
const metricCards = [
|
||||
{ label: "Active applications", value: stats?.active ?? "-", sub: "Currently in progress" },
|
||||
{ label: "Applied (30 days)", value: stats?.appliedLast30Days ?? "-", sub: "New applications" },
|
||||
{ label: "Median first response", value: overview?.medianDaysToFirstResponse ?? "-", sub: "Days until first reply" },
|
||||
{ label: "Responses logged", value: overview?.totalResponses ?? 0, sub: "Across active jobs" },
|
||||
{ label: "Low readiness", value: reminderJobs.filter((job) => !job.tailoredCvText).length, sub: "Reminder jobs missing tailored CV" },
|
||||
{ label: t("dashboardActiveApplications"), value: stats?.active ?? "-", sub: t("dashboardCurrentlyInProgress") },
|
||||
{ label: t("dashboardApplied30Days"), value: stats?.appliedLast30Days ?? "-", sub: t("dashboardNewApplications") },
|
||||
{ label: t("dashboardMedianFirstResponse"), value: overview?.medianDaysToFirstResponse ?? "-", sub: t("dashboardDaysUntilFirstReply") },
|
||||
{ label: t("dashboardResponsesLogged"), value: overview?.totalResponses ?? 0, sub: t("dashboardAcrossActiveJobs") },
|
||||
{ label: t("dashboardLowReadiness"), value: reminderJobs.filter((job) => !job.tailoredCvText).length, sub: t("dashboardMissingTailoredCv") },
|
||||
];
|
||||
|
||||
const togglePref = (key: keyof Prefs) => {
|
||||
@@ -205,24 +145,29 @@ export default function DashboardView() {
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
|
||||
<Tab label="Overview" />
|
||||
<Tab label="Pipeline" />
|
||||
<Tab label="Summarizer" />
|
||||
</Tabs>
|
||||
|
||||
{tab !== 2 ? (
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 1 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 2, mb: 2, flexWrap: "wrap" }}>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 950 }}>{t("dashboardOverviewTitle")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
{t("dashboardOverviewBody")}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
{([6, 12, 24] as const).map((m) => (
|
||||
<Button key={m} size="small" variant={months === m ? "contained" : "outlined"} onClick={() => setMonths(m)}>
|
||||
{m} mo
|
||||
</Button>
|
||||
))}
|
||||
<Button variant="outlined" startIcon={<TuneIcon />} onClick={(e) => setPrefsAnchor(e.currentTarget)}>
|
||||
Customize dashboard
|
||||
{t("dashboardCustomize")}
|
||||
</Button>
|
||||
<Menu anchorEl={prefsAnchor} open={Boolean(prefsAnchor)} onClose={() => setPrefsAnchor(null)}>
|
||||
{[
|
||||
["cards", "Summary cards"],
|
||||
["activity", "Activity chart"],
|
||||
["funnel", "Conversion funnel"],
|
||||
["companies", "Top companies"],
|
||||
["skills", "Skills insights"],
|
||||
["cards", t("dashboardSummaryCards")],
|
||||
["activity", t("dashboardActivityChart")],
|
||||
["funnel", t("dashboardConversionFunnel")],
|
||||
["companies", t("dashboardTopCompanies")],
|
||||
["skills", t("dashboardSkillsInsights")],
|
||||
].map(([key, label]) => (
|
||||
<MenuItem key={key} onClick={() => togglePref(key as keyof Prefs)}>
|
||||
<Checkbox checked={prefs[key as keyof Prefs]} />
|
||||
@@ -231,208 +176,136 @@ export default function DashboardView() {
|
||||
))}
|
||||
</Menu>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
{tab === 0 ? (
|
||||
<>
|
||||
{prefs.cards ? (
|
||||
<Paper sx={{ p: 0.5 }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, 1fr)", xl: "repeat(5, 1fr)" } }}>
|
||||
{metricCards.map((m, idx) => (
|
||||
<Box key={m.label} sx={{ borderLeft: { xs: "none", xl: idx === 0 ? "none" : `1px solid ${theme.palette.divider}` }, borderTop: { xs: idx === 0 ? "none" : `1px solid ${theme.palette.divider}`, sm: idx < 2 ? "none" : `1px solid ${theme.palette.divider}`, xl: "none" } }}>
|
||||
<StatCard label={m.label} value={m.value} sub={m.sub} />
|
||||
</Box>
|
||||
))}
|
||||
{prefs.cards ? (
|
||||
<Paper sx={{ p: 0.5 }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, 1fr)", xl: "repeat(5, 1fr)" } }}>
|
||||
{metricCards.map((m, idx) => (
|
||||
<Box key={m.label} sx={{ borderLeft: { xs: "none", xl: idx === 0 ? "none" : `1px solid ${theme.palette.divider}` }, borderTop: { xs: idx === 0 ? "none" : `1px solid ${theme.palette.divider}`, sm: idx < 2 ? "none" : `1px solid ${theme.palette.divider}`, xl: "none" } }}>
|
||||
<StatCard label={m.label} value={m.value} sub={m.sub} />
|
||||
</Box>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
{prefs.activity ? (
|
||||
<Paper sx={{ mt: 2, p: 2.25 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950 }}>Application activity</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>Monthly applications versus responses.</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 1 }}>
|
||||
<ButtonGroup size="small">
|
||||
{([6, 12, 24] as const).map((m) => (
|
||||
<Button key={m} variant={rangeMode === "preset" && months === m ? "contained" : "outlined"} onClick={() => { setRangeMode("preset"); setMonths(m); }}>{m} mo</Button>
|
||||
))}
|
||||
<Button variant={rangeMode === "custom" ? "contained" : "outlined"} onClick={() => { setRangeMode("custom"); setAppliedCustom({ from: fromMonth, to: toMonth }); }}>Custom</Button>
|
||||
</ButtonGroup>
|
||||
{rangeMode === "custom" ? (
|
||||
<>
|
||||
<TextField size="small" label="From" type="month" value={fromMonth} onChange={(e) => setFromMonth(e.target.value)} />
|
||||
<TextField size="small" label="To" type="month" value={toMonth} onChange={(e) => setToMonth(e.target.value)} />
|
||||
<Button size="small" variant="contained" onClick={() => setAppliedCustom({ from: fromMonth, to: toMonth })}>Apply</Button>
|
||||
</>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ mt: 2, overflowX: "auto" }}>
|
||||
<Box sx={{ minWidth: chartW }}>
|
||||
<svg width={chartW} height={chartH} viewBox={`0 0 ${chartW} ${chartH}`}>
|
||||
{[0.25, 0.5, 0.75].map((tick) => <line key={tick} x1="0" x2={chartW} y1={Math.round(chartH * tick)} y2={Math.round(chartH * tick)} stroke={alpha(theme.palette.text.primary, 0.08)} strokeDasharray="5 5" />)}
|
||||
{responsePath ? <path d={responsePath} fill="none" stroke={alpha(theme.palette.info.main, 0.95)} strokeWidth="2.5" /> : null}
|
||||
{appliedPath ? <path d={appliedPath} fill="none" stroke={alpha(theme.palette.success.main, 0.95)} strokeWidth="2.5" /> : null}
|
||||
</svg>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", mt: 1 }}>{analytics.map((p) => <Typography key={p.month} variant="caption" sx={{ width: `${100 / Math.max(1, analytics.length)}%`, textAlign: "center" }}>{p.month.slice(5)}</Typography>)}</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
<Box sx={{ mt: 2, display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr" }, gap: 2 }}>
|
||||
{prefs.funnel ? (
|
||||
<Paper sx={{ p: 2.25 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>Conversion funnel</Typography>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||
{(overview?.funnel ?? []).map((item) => (
|
||||
<Box key={item.label} sx={{ display: "grid", gridTemplateColumns: "140px 1fr 50px", gap: 1, alignItems: "center" }}>
|
||||
<Typography sx={{ fontWeight: 800 }}>{item.label}</Typography>
|
||||
<Box sx={{ height: 10, borderRadius: 999, bgcolor: alpha(theme.palette.text.primary, 0.08), overflow: "hidden" }}>
|
||||
<Box sx={{ width: `${funnelMax ? clamp((item.count / funnelMax) * 100, 0, 100) : 0}%`, height: "100%", background: `linear-gradient(90deg, ${alpha(theme.palette.primary.main, 0.9)}, ${alpha(theme.palette.primary.main, 0.3)})` }} />
|
||||
</Box>
|
||||
<Typography sx={{ textAlign: "right", fontWeight: 900 }}>{item.count}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 2 }}>Response sources</Typography>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1, mt: 1 }}>
|
||||
{(overview?.responseRateBySource ?? []).map((item) => (
|
||||
<Box key={item.label} sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}>
|
||||
<Typography variant="body2">{item.label}</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 800 }}>{item.rate}%</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
{prefs.companies ? (
|
||||
<Paper sx={{ p: 2.25 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>Top companies by activity</Typography>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||
{(overview?.topCompanies ?? []).map((item) => (
|
||||
<Box key={item.companyId} sx={{ display: "grid", gridTemplateColumns: "1fr auto auto", gap: 2, alignItems: "center" }}>
|
||||
<Typography sx={{ fontWeight: 800 }}>{item.company}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{item.count} jobs</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 900 }}>{item.responseRate}%</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
) : null}
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{prefs.skills ? (
|
||||
<Paper sx={{ mt: 2, p: 2.25 }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr" }, gap: 2.5 }}>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>Top skills</Typography>
|
||||
{tags.length === 0 ? <Typography sx={{ color: "text.secondary" }}>No tags yet.</Typography> : (
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "132px 1fr", gap: 2, alignItems: "center" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "center" }}>
|
||||
<svg width="132" height="132" viewBox="0 0 132 132">
|
||||
<circle cx="66" cy="66" r="52" stroke={alpha(theme.palette.text.primary, 0.1)} strokeWidth="14" fill="none" />
|
||||
{(() => {
|
||||
const r = 52;
|
||||
const circ = 2 * Math.PI * r;
|
||||
let offset = 0;
|
||||
return tags.map((t, i) => {
|
||||
const len = circ * (tagTotal ? t.count / tagTotal : 0);
|
||||
const el = <circle key={t.tag} cx="66" cy="66" r={r} fill="none" stroke={tagColors[i % tagColors.length]} strokeWidth="14" strokeDasharray={`${len} ${circ}`} strokeDashoffset={-offset} transform="rotate(-90 66 66)" />;
|
||||
offset += len;
|
||||
return el;
|
||||
});
|
||||
})()}
|
||||
<circle cx="66" cy="66" r="39" fill={theme.palette.background.paper} />
|
||||
<text x="66" y="62" textAnchor="middle" fontSize="16" fontWeight="900" fill={theme.palette.text.primary}>{tagTotal}</text>
|
||||
<text x="66" y="80" textAnchor="middle" fontSize="11" fill={alpha(theme.palette.text.primary, 0.65)}>skill tags</text>
|
||||
</svg>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75 }}>
|
||||
{tags.slice(0, 8).map((t, i) => <Box key={t.tag} sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}><Box sx={{ display: "flex", alignItems: "center", gap: 1 }}><Box sx={{ width: 10, height: 10, borderRadius: 999, bgcolor: tagColors[i % tagColors.length] }} /><Typography variant="body2" sx={{ fontWeight: 700 }}>{t.tag}</Typography></Box><Typography variant="body2" sx={{ fontWeight: 900 }}>{t.count}</Typography></Box>)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>Skill trends</Typography>
|
||||
{!tagTrends || tagTrends.series.length === 0 ? <Typography sx={{ color: "text.secondary" }}>No tag trend data yet.</Typography> : (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||
{tagTrends.series.map((series, idx) => (
|
||||
<Box key={series.tag}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 0.5 }}>
|
||||
<Typography sx={{ fontWeight: 800 }}>{series.tag}</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>{series.counts.reduce((a, b) => a + b, 0)} total</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: `repeat(${series.counts.length}, 1fr)`, gap: 0.5 }}>
|
||||
{series.counts.map((count, i) => (
|
||||
<Box key={`${series.tag}-${i}`} sx={{ height: 14, borderRadius: 1, bgcolor: count > 0 ? alpha(tagColors[idx % tagColors.length], 0.25 + Math.min(0.6, count / 10)) : alpha(theme.palette.text.primary, 0.06) }} title={`${tagTrends.months[i]}: ${count}`} />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: `repeat(${tagTrends.months.length}, 1fr)`, gap: 0.5 }}>
|
||||
{tagTrends.months.map((month) => <Typography key={month} variant="caption" sx={{ textAlign: "center", color: "text.secondary" }}>{month.slice(5)}</Typography>)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
) : null}
|
||||
</>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
{tab === 1 ? (
|
||||
<Paper sx={{ p: 2.25 }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1.2fr 0.8fr" }, gap: 2.5 }}>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>Status breakdown</Typography>
|
||||
{statusRows.length === 0 ? <Typography sx={{ color: "text.secondary" }}>No data yet.</Typography> : (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||
{statusRows.map(([status, value]) => {
|
||||
const tone = status === "Rejected" ? theme.palette.error.main : status === "Waiting" || status === "Ghosted" ? theme.palette.warning.main : status === "Offer" ? theme.palette.success.main : status === "Interview" ? theme.palette.info.main : theme.palette.primary.main;
|
||||
const w = maxStatus ? clamp(Math.round((value / maxStatus) * 100), 0, 100) : 0;
|
||||
return <Box key={status} sx={{ display: "grid", gridTemplateColumns: "160px 1fr 60px", gap: 1, alignItems: "center" }}><Typography sx={{ fontWeight: 850 }}>{status}</Typography><Box sx={{ height: 10, borderRadius: 999, background: alpha(theme.palette.text.primary, 0.08), overflow: "hidden" }}><Box sx={{ width: `${w}%`, height: "100%", background: `linear-gradient(90deg, ${alpha(tone, 0.85)}, ${alpha(tone, 0.32)})` }} /></Box><Typography sx={{ textAlign: "right", fontWeight: 900 }}>{value}</Typography></Box>;
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>Response rate by source</Typography>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||
{(overview?.responseRateBySource ?? []).map((item) => (
|
||||
<Box key={item.label} sx={{ p: 1.25, border: "1px solid", borderColor: "divider", borderRadius: 2 }}>
|
||||
<Typography sx={{ fontWeight: 800 }}>{item.label}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{item.responses} responses from {item.total} jobs</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 900, mt: 0.5 }}>{item.rate}%</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
{prefs.activity ? (
|
||||
<Paper sx={{ mt: 2, p: 2.25 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950 }}>{t("dashboardApplicationActivity")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("dashboardMonthlyApplicationsResponses")}</Typography>
|
||||
<Box sx={{ mt: 2, overflowX: "auto" }}>
|
||||
<Box sx={{ minWidth: chartW }}>
|
||||
<svg width={chartW} height={chartH} viewBox={`0 0 ${chartW} ${chartH}`}>
|
||||
{[0.25, 0.5, 0.75].map((tick) => <line key={tick} x1="0" x2={chartW} y1={Math.round(chartH * tick)} y2={Math.round(chartH * tick)} stroke={alpha(theme.palette.text.primary, 0.08)} strokeDasharray="5 5" />)}
|
||||
{responsePath ? <path d={responsePath} fill="none" stroke={alpha(theme.palette.info.main, 0.95)} strokeWidth="2.5" /> : null}
|
||||
{appliedPath ? <path d={appliedPath} fill="none" stroke={alpha(theme.palette.success.main, 0.95)} strokeWidth="2.5" /> : null}
|
||||
</svg>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", mt: 1 }}>{analytics.map((p) => <Typography key={p.month} variant="caption" sx={{ width: `${100 / Math.max(1, analytics.length)}%`, textAlign: "center" }}>{p.month.slice(5)}</Typography>)}</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
{tab === 2 ? (
|
||||
<Paper sx={{ p: 2.25 }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(4, 1fr)" }, gap: 2 }}>
|
||||
{[{ label: "Service status", value: summarizerMetrics?.healthy ? "Healthy" : "Offline", sub: summarizerMetrics?.model || "Summarizer health check" }, { label: "Health latency", value: summarizerMetrics?.healthLatencyMs != null ? `${summarizerMetrics.healthLatencyMs} ms` : "-", sub: "Latest /health round-trip" }, { label: "Probe latency", value: summarizerMetrics?.probeLatencyMs != null ? `${summarizerMetrics.probeLatencyMs} ms` : "-", sub: "Periodic small summarize request" }, { label: "Last success", value: formatRelative(summarizerMetrics?.lastProbeSuccessAt || summarizerMetrics?.lastSuccessAt), sub: "Recent successful latency sample" }].map((m) => <Paper key={m.label} variant="outlined" sx={{ p: 2 }}><Typography variant="overline" sx={{ color: "text.secondary" }}>{m.label}</Typography><Typography variant="h5" sx={{ fontWeight: 950 }}>{m.value}</Typography><Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>{m.sub}</Typography></Paper>)}
|
||||
</Box>
|
||||
<Paper variant="outlined" sx={{ p: 2, mt: 2 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>Telemetry</Typography>
|
||||
<Typography variant="body2"><strong>Requests:</strong> {summarizerMetrics?.requests ?? 0}</Typography>
|
||||
<Typography variant="body2"><strong>Cache hits:</strong> {summarizerMetrics?.cacheHits ?? 0}</Typography>
|
||||
<Typography variant="body2"><strong>Cache misses:</strong> {summarizerMetrics?.cacheMisses ?? 0}</Typography>
|
||||
<Typography variant="body2"><strong>Failures:</strong> {summarizerMetrics?.failures ?? 0}</Typography>
|
||||
<Typography variant="body2"><strong>Probe failures:</strong> {summarizerMetrics?.probeFailures ?? 0}</Typography>
|
||||
<Typography variant="body2"><strong>Last failure:</strong> {formatRelative(summarizerMetrics?.lastFailureAt)}</Typography>
|
||||
<Typography variant="body2" sx={{ mt: 1, color: summarizerMetrics?.lastError ? "warning.main" : "text.secondary" }}>{summarizerMetrics?.lastError || "No recent summarizer errors recorded."}</Typography>
|
||||
<Box sx={{ mt: 2, display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr" }, gap: 2 }}>
|
||||
{prefs.funnel ? (
|
||||
<Paper sx={{ p: 2.25 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>{t("dashboardConversionFunnelTitle")}</Typography>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||
{(overview?.funnel ?? []).map((item) => (
|
||||
<Box key={item.label} sx={{ display: "grid", gridTemplateColumns: "140px 1fr 50px", gap: 1, alignItems: "center" }}>
|
||||
<Typography sx={{ fontWeight: 800 }}>{item.label}</Typography>
|
||||
<Box sx={{ height: 10, borderRadius: 999, bgcolor: alpha(theme.palette.text.primary, 0.08), overflow: "hidden" }}>
|
||||
<Box sx={{ width: `${funnelMax ? clamp((item.count / funnelMax) * 100, 0, 100) : 0}%`, height: "100%", background: `linear-gradient(90deg, ${alpha(theme.palette.primary.main, 0.9)}, ${alpha(theme.palette.primary.main, 0.3)})` }} />
|
||||
</Box>
|
||||
<Typography sx={{ textAlign: "right", fontWeight: 900 }}>{item.count}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 2 }}>{t("dashboardResponseSources")}</Typography>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1, mt: 1 }}>
|
||||
{(overview?.responseRateBySource ?? []).map((item) => (
|
||||
<Box key={item.label} sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}>
|
||||
<Typography variant="body2">{item.label}</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 800 }}>{item.rate}%</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
{prefs.companies ? (
|
||||
<Paper sx={{ p: 2.25 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>{t("dashboardTopCompaniesByActivity")}</Typography>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||
{(overview?.topCompanies ?? []).map((item) => (
|
||||
<Box key={item.companyId} sx={{ display: "grid", gridTemplateColumns: "1fr auto auto", gap: 2, alignItems: "center" }}>
|
||||
<Typography sx={{ fontWeight: 800 }}>{item.company}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{item.count} jobs</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 900 }}>{item.responseRate}%</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
{prefs.skills ? (
|
||||
<Paper sx={{ mt: 2, p: 2.25 }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr" }, gap: 2.5 }}>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>{t("dashboardTopSkills")}</Typography>
|
||||
{tags.length === 0 ? <Typography sx={{ color: "text.secondary" }}>{t("dashboardNoTagsYet")}</Typography> : (
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "132px 1fr", gap: 2, alignItems: "center" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "center" }}>
|
||||
<svg width="132" height="132" viewBox="0 0 132 132">
|
||||
<circle cx="66" cy="66" r="52" stroke={alpha(theme.palette.text.primary, 0.1)} strokeWidth="14" fill="none" />
|
||||
{(() => {
|
||||
const r = 52;
|
||||
const circ = 2 * Math.PI * r;
|
||||
let offset = 0;
|
||||
return tags.map((tItem, i) => {
|
||||
const len = circ * (tagTotal ? tItem.count / tagTotal : 0);
|
||||
const el = <circle key={tItem.tag} cx="66" cy="66" r={r} fill="none" stroke={tagColors[i % tagColors.length]} strokeWidth="14" strokeDasharray={`${len} ${circ}`} strokeDashoffset={-offset} transform="rotate(-90 66 66)" />;
|
||||
offset += len;
|
||||
return el;
|
||||
});
|
||||
})()}
|
||||
<circle cx="66" cy="66" r="39" fill={theme.palette.background.paper} />
|
||||
<text x="66" y="62" textAnchor="middle" fontSize="16" fontWeight="900" fill={theme.palette.text.primary}>{tagTotal}</text>
|
||||
<text x="66" y="80" textAnchor="middle" fontSize="11" fill={alpha(theme.palette.text.primary, 0.65)}>{t("dashboardSkillTags")}</text>
|
||||
</svg>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75 }}>
|
||||
{tags.slice(0, 8).map((tItem, i) => <Box key={tItem.tag} sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}><Box sx={{ display: "flex", alignItems: "center", gap: 1 }}><Box sx={{ width: 10, height: 10, borderRadius: 999, bgcolor: tagColors[i % tagColors.length] }} /><Typography variant="body2" sx={{ fontWeight: 700 }}>{tItem.tag}</Typography></Box><Typography variant="body2" sx={{ fontWeight: 900 }}>{tItem.count}</Typography></Box>)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>{t("dashboardSkillTrends")}</Typography>
|
||||
{!tagTrends || tagTrends.series.length === 0 ? <Typography sx={{ color: "text.secondary" }}>{t("dashboardNoTagTrendData")}</Typography> : (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||
{tagTrends.series.map((series, idx) => (
|
||||
<Box key={series.tag}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 0.5 }}>
|
||||
<Typography sx={{ fontWeight: 800 }}>{series.tag}</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>{series.counts.reduce((a, b) => a + b, 0)} total</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: `repeat(${series.counts.length}, 1fr)`, gap: 0.5 }}>
|
||||
{series.counts.map((count, i) => (
|
||||
<Box key={`${series.tag}-${i}`} sx={{ height: 14, borderRadius: 1, bgcolor: count > 0 ? alpha(tagColors[idx % tagColors.length], 0.25 + Math.min(0.6, count / 10)) : alpha(theme.palette.text.primary, 0.06) }} title={`${tagTrends.months[i]}: ${count}`} />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: `repeat(${tagTrends.months.length}, 1fr)`, gap: 0.5 }}>
|
||||
{tagTrends.months.map((month) => <Typography key={month} variant="caption" sx={{ textAlign: "center", color: "text.secondary" }}>{month.slice(5)}</Typography>)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user