First Commit
This commit is contained in:
@@ -0,0 +1,401 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { Box, Button, ButtonGroup, Divider, Paper, Tab, Tabs, TextField, Typography } from "@mui/material";
|
||||
|
||||
import { alpha, useTheme } from "@mui/material/styles";
|
||||
|
||||
import { api } from "../api";
|
||||
|
||||
interface JobStats {
|
||||
total: number;
|
||||
active: number;
|
||||
deleted: number;
|
||||
byStatus: Record<string, number>;
|
||||
appliedLast30Days: number;
|
||||
averageDaysSinceApplied: number;
|
||||
}
|
||||
|
||||
type AnalyticsPoint = { month: string; applied: number; responses: number };
|
||||
type TagPoint = { tag: string; count: number };
|
||||
|
||||
function clamp(n: number, a: number, b: number) {
|
||||
return Math.max(a, Math.min(b, n));
|
||||
}
|
||||
|
||||
function toPath(values: number[], w: number, h: number) {
|
||||
if (values.length === 0) return "";
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
const dx = w / Math.max(1, values.length - 1);
|
||||
const norm = (v: number) => {
|
||||
const t = max === min ? 0.5 : (v - min) / (max - min);
|
||||
return h - t * h;
|
||||
};
|
||||
return values
|
||||
.map((v, i) => `${i === 0 ? "M" : "L"} ${Math.round(i * dx)} ${Math.round(norm(v))}`)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export default function DashboardView() {
|
||||
const theme = useTheme();
|
||||
const [stats, setStats] = useState<JobStats | 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(() => {
|
||||
const now = new Date();
|
||||
const start = new Date(now.getFullYear(), now.getMonth() - 11, 1);
|
||||
return start.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[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<JobStats>("/jobapplications/stats").then((r) => setStats(r.data));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const params =
|
||||
rangeMode === "custom" && appliedCustom
|
||||
? { from: `${appliedCustom.from}-01`, to: `${appliedCustom.to}-01` }
|
||||
: { 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([]));
|
||||
}, [months, rangeMode, appliedCustom]);
|
||||
|
||||
const statusRows = useMemo(() => {
|
||||
const by = stats?.byStatus ?? {};
|
||||
return Object.entries(by).sort((a, b) => b[1] - a[1]);
|
||||
}, [stats]);
|
||||
|
||||
const max = statusRows.length ? Math.max(...statusRows.map(([, v]) => v)) : 0;
|
||||
|
||||
const metricCards = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
label: "Active applications",
|
||||
value: stats?.active ?? "-",
|
||||
sub: "Currently in progress",
|
||||
},
|
||||
{
|
||||
label: "Applied (30 days)",
|
||||
value: stats?.appliedLast30Days ?? "-",
|
||||
sub: "New applications",
|
||||
},
|
||||
{
|
||||
label: "Average days",
|
||||
value: stats?.averageDaysSinceApplied ?? "-",
|
||||
sub: "Since applied",
|
||||
},
|
||||
{
|
||||
label: "In trash",
|
||||
value: stats?.deleted ?? "-",
|
||||
sub: "Soft-deleted",
|
||||
},
|
||||
];
|
||||
}, [stats]);
|
||||
|
||||
const chartW = 860;
|
||||
const chartH = 260;
|
||||
|
||||
const appliedSeries = useMemo(() => analytics.map((x) => x.applied), [analytics]);
|
||||
const responseSeries = useMemo(() => analytics.map((x) => x.responses), [analytics]);
|
||||
|
||||
const p1 = useMemo(() => toPath(appliedSeries, chartW, chartH), [appliedSeries]);
|
||||
const p2 = useMemo(() => toPath(responseSeries, chartW, chartH), [responseSeries]);
|
||||
|
||||
const c1 = theme.palette.primary.main;
|
||||
const c2 = alpha("#94a3b8", theme.palette.mode === "dark" ? 0.9 : 0.8);
|
||||
|
||||
const tagColors = useMemo(() => {
|
||||
return [
|
||||
theme.palette.primary.main,
|
||||
theme.palette.success.main,
|
||||
theme.palette.warning.main,
|
||||
theme.palette.info.main,
|
||||
theme.palette.error.main,
|
||||
alpha("#a855f7", 0.9),
|
||||
alpha("#f97316", 0.9),
|
||||
alpha("#14b8a6", 0.9),
|
||||
alpha("#64748b", 0.9),
|
||||
alpha("#0ea5e9", 0.9),
|
||||
];
|
||||
}, [theme.palette]);
|
||||
|
||||
const tagTotal = useMemo(() => tags.reduce((acc, t) => acc + (t.count || 0), 0), [tags]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
|
||||
<Tab label="Overview" />
|
||||
<Tab label="User Behavior" disabled />
|
||||
<Tab label="Performance" disabled />
|
||||
</Tabs>
|
||||
|
||||
<Paper sx={{ p: 2.25 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", md: "repeat(4, 1fr)" },
|
||||
gap: { xs: 2, md: 0 },
|
||||
}}
|
||||
>
|
||||
{metricCards.map((m, idx) => (
|
||||
<Box key={m.label} sx={{ px: { xs: 0, md: 2 } }}>
|
||||
{idx > 0 ? (
|
||||
<Divider
|
||||
orientation="vertical"
|
||||
flexItem
|
||||
sx={{ display: { xs: "none", md: "block" }, position: "absolute", height: 68, mt: 1.5 }}
|
||||
/>
|
||||
) : null}
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>
|
||||
{m.label}
|
||||
</Typography>
|
||||
<Typography variant="h4" sx={{ mt: 0.25 }}>
|
||||
{m.value}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{m.sub}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<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 }}>
|
||||
Analysis
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
Monthly applied vs responses.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", alignItems: "center", justifyContent: "flex-end", gap: 1 }}>
|
||||
<ButtonGroup size="small" variant="outlined">
|
||||
{([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" ? (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||
<TextField
|
||||
size="small"
|
||||
label="From"
|
||||
type="month"
|
||||
value={fromMonth}
|
||||
onChange={(e) => setFromMonth(e.target.value)}
|
||||
sx={{ width: 150 }}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
label="To"
|
||||
type="month"
|
||||
value={toMonth}
|
||||
onChange={(e) => setToMonth(e.target.value)}
|
||||
sx={{ width: 150 }}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
onClick={() => setAppliedCustom({ from: fromMonth, to: toMonth })}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 2, overflowX: "auto" }}>
|
||||
<Box sx={{ minWidth: chartW }}>
|
||||
<svg width={chartW} height={chartH} viewBox={`0 0 ${chartW} ${chartH}`}>
|
||||
<defs>
|
||||
<linearGradient id="fillA" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor={alpha(c1, 0.45)} />
|
||||
<stop offset="100%" stopColor={alpha(c1, 0.02)} />
|
||||
</linearGradient>
|
||||
<linearGradient id="fillB" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor={alpha(c2, 0.35)} />
|
||||
<stop offset="100%" stopColor={alpha(c2, 0.02)} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{p2 ? (
|
||||
<>
|
||||
<path d={`${p2} L ${chartW} ${chartH} L 0 ${chartH} Z`} fill="url(#fillB)" />
|
||||
<path d={p2} fill="none" stroke={alpha(c2, 0.85)} strokeWidth="2" />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{p1 ? (
|
||||
<>
|
||||
<path d={`${p1} L ${chartW} ${chartH} L 0 ${chartH} Z`} fill="url(#fillA)" />
|
||||
<path d={p1} fill="none" stroke={alpha(c1, 0.95)} strokeWidth="2.5" />
|
||||
</>
|
||||
) : null}
|
||||
</svg>
|
||||
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", mt: 1, color: "text.secondary" }}>
|
||||
{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>
|
||||
|
||||
<Paper sx={{ mt: 2, 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
|
||||
: theme.palette.primary.main;
|
||||
|
||||
const w = max ? clamp(Math.round((value / max) * 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: theme.palette.mode === "dark" ? alpha("#94a3b8", 0.16) : alpha("#0f172a", 0.08),
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: `${w}%`,
|
||||
height: "100%",
|
||||
background: `linear-gradient(90deg, ${alpha(tone, 0.75)}, ${alpha(tone, 0.30)})`,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Typography sx={{ textAlign: "right", fontWeight: 900 }}>{value}</Typography>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>
|
||||
Top tags (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.10)} strokeWidth="14" fill="none" />
|
||||
{(() => {
|
||||
const r = 52;
|
||||
const circ = 2 * Math.PI * r;
|
||||
let offset = 0;
|
||||
return tags.map((t, i) => {
|
||||
const pct = tagTotal ? t.count / tagTotal : 0;
|
||||
const len = circ * pct;
|
||||
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)}>
|
||||
tags
|
||||
</text>
|
||||
</svg>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75, minWidth: 0 }}>
|
||||
{tags.slice(0, 8).map((t, i) => (
|
||||
<Box key={t.tag} sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, minWidth: 0 }}>
|
||||
<Box sx={{ width: 10, height: 10, borderRadius: 999, bgcolor: tagColors[i % tagColors.length], flex: "0 0 auto" }} />
|
||||
<Typography variant="body2" noWrap sx={{ fontWeight: 700 }}>
|
||||
{t.tag}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 900, flex: "0 0 auto" }}>
|
||||
{t.count}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user