Refresh dashboard, adopt MUI X, and improve AI follow-ups

This commit is contained in:
cesnimda
2026-03-23 21:23:15 +01:00
parent 7293582376
commit 66d924e880
9 changed files with 684 additions and 251 deletions
+25 -2
View File
@@ -1,5 +1,7 @@
import React, { useEffect, useMemo, useState } from "react";
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import {
Alert,
Autocomplete,
@@ -65,6 +67,17 @@ function getTodayIso() {
return new Date().toISOString().slice(0, 10);
}
function parsePickerDate(value?: string | null): Date | null {
if (!value) return null;
const parsed = new Date(value);
return Number.isNaN(+parsed) ? null : parsed;
}
function toPickerIso(value: Date | null) {
if (!value || Number.isNaN(+value)) return "";
return value.toISOString().slice(0, 10);
}
function emptyAttachmentBuckets(): AttachmentBuckets {
return {
resume: [],
@@ -448,7 +461,12 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
</Button>
</Box>
<TextField label={t("addJobModalDateApplied")} type="date" value={dateApplied} onChange={(e) => setDateApplied(e.target.value)} InputLabelProps={{ shrink: true }} />
<DatePicker
label={t("addJobModalDateApplied")}
value={parsePickerDate(dateApplied)}
onChange={(value) => setDateApplied(toPickerIso(value))}
slotProps={{ textField: { fullWidth: true } }}
/>
<TextField select label={t("addJobModalStatus")} value={status} onChange={(e) => setStatus(e.target.value as any)}>
{STATUS_OPTIONS.map((s) => (
@@ -462,7 +480,12 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
<TextField label={t("location")} value={location} onChange={(e) => setLocation(e.target.value)} />
<TextField label={t("addJobModalSalary")} value={salary} onChange={(e) => setSalary(e.target.value)} />
<TextField label={t("addJobModalDeadline")} type="date" value={deadline} onChange={(e) => setDeadline(e.target.value)} InputLabelProps={{ shrink: true }} />
<DatePicker
label={t("addJobModalDeadline")}
value={parsePickerDate(deadline)}
onChange={(value) => setDeadline(toPickerIso(value))}
slotProps={{ textField: { fullWidth: true } }}
/>
<Box sx={{ gridColumn: "1 / -1" }}>
<TagsInput value={tags} onChange={setTags} />
+307 -170
View File
@@ -4,13 +4,20 @@ import {
Box,
Button,
Checkbox,
Chip,
LinearProgress,
Menu,
MenuItem,
Paper,
Stack,
Typography,
} from "@mui/material";
import { alpha, useTheme } from "@mui/material/styles";
import TuneIcon from "@mui/icons-material/Tune";
import TrendingUpIcon from "@mui/icons-material/TrendingUp";
import MailOutlineIcon from "@mui/icons-material/MailOutline";
import BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined";
import AutoGraphIcon from "@mui/icons-material/AutoGraph";
import { api } from "../api";
import { getUserKeyFromToken } from "../themePrefs";
@@ -73,16 +80,49 @@ 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 "";
function buildLinePath(values: number[], width: number, height: number) {
if (!values.length) 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;
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((v, i) => `${i === 0 ? "M" : "L"} ${Math.round(i * dx)} ${Math.round(norm(v))}`).join(" ");
return values
.map((value, index) => `${index === 0 ? "M" : "L"} ${Math.round(index * step)} ${Math.round(yFor(value))}`)
.join(" ");
}
function MiniSpark({ values, color }: { values: number[]; color: string }) {
const width = 180;
const height = 52;
const path = buildLinePath(values, width, height);
return (
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`}>
<path d={path} fill="none" stroke={color} strokeWidth="3" strokeLinecap="round" />
</svg>
);
}
function SectionCard({ children, sx = {} }: { children: React.ReactNode; sx?: any }) {
return (
<Paper
sx={{
p: 2.25,
borderRadius: 4,
border: "1px solid",
borderColor: "divider",
background: "background.paper",
boxShadow: "0 18px 50px rgba(15, 23, 42, 0.06)",
...sx,
}}
>
{children}
</Paper>
);
}
export default function DashboardView() {
@@ -111,22 +151,50 @@ export default function DashboardView() {
api.get<TagTrendResponse>("/jobapplications/tag-trends", { params: { months, limit: 5 } }).then((r) => setTagTrends(r.data)).catch(() => setTagTrends(null));
}, [months]);
const chartW = 860;
const chartH = 260;
const appliedSeries = analytics.map((x) => x.applied);
const responseSeries = analytics.map((x) => x.responses);
const appliedPath = toPath(appliedSeries, chartW, chartH);
const responsePath = toPath(responseSeries, chartW, chartH);
const appliedValues = analytics.map((x) => x.applied);
const responseValues = analytics.map((x) => x.responses);
const chartWidth = 860;
const chartHeight = 250;
const appliedPath = buildLinePath(appliedValues, chartWidth, chartHeight);
const responsePath = buildLinePath(responseValues, chartWidth, chartHeight);
const tagColors = [theme.palette.primary.main, theme.palette.success.main, theme.palette.warning.main, theme.palette.info.main, theme.palette.error.main];
const tagTotal = tags.reduce((acc, item) => acc + item.count, 0);
const funnelMax = overview?.funnel?.length ? Math.max(...overview.funnel.map((x) => x.count)) : 0;
const funnelMax = overview?.funnel?.length ? Math.max(...overview.funnel.map((item) => item.count)) : 0;
const topSource = overview?.responseRateBySource?.[0];
const missingCvCount = reminderJobs.filter((job) => !job.tailoredCvText).length;
const metricCards = [
{ 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") },
{
label: t("dashboardActiveApplications"),
value: stats?.active ?? 0,
sub: t("dashboardCurrentlyInProgress"),
icon: <TrendingUpIcon fontSize="small" />,
tone: theme.palette.primary.main,
spark: appliedValues,
},
{
label: t("dashboardApplied30Days"),
value: stats?.appliedLast30Days ?? 0,
sub: t("dashboardNewApplications"),
icon: <AutoGraphIcon fontSize="small" />,
tone: theme.palette.success.main,
spark: appliedValues.slice(-6),
},
{
label: t("dashboardMedianFirstResponse"),
value: overview?.medianDaysToFirstResponse ?? "—",
sub: t("dashboardDaysUntilFirstReply"),
icon: <MailOutlineIcon fontSize="small" />,
tone: theme.palette.info.main,
spark: responseValues,
},
{
label: t("dashboardResponsesLogged"),
value: overview?.totalResponses ?? 0,
sub: t("dashboardAcrossActiveJobs"),
icon: <BusinessOutlinedIcon fontSize="small" />,
tone: theme.palette.warning.main,
spark: responseValues.slice(-6),
},
];
const togglePref = (key: keyof Prefs) => {
@@ -135,179 +203,248 @@ export default function DashboardView() {
savePrefs(next);
};
const StatCard = ({ label, value, sub }: { label: string; value: React.ReactNode; sub: string }) => (
<Box sx={{ p: 2, minHeight: 118, display: "flex", flexDirection: "column", justifyContent: "center" }}>
<Typography variant="overline" sx={{ color: "text.secondary" }}>{label}</Typography>
<Typography variant="h4" sx={{ mt: 0.5, fontWeight: 950 }}>{value}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.75 }}>{sub}</Typography>
</Box>
);
const totalApplied = appliedValues.reduce((sum, value) => sum + value, 0);
const totalResponses = responseValues.reduce((sum, value) => sum + value, 0);
const responseRate = totalApplied > 0 ? Math.round((totalResponses / totalApplied) * 100) : 0;
return (
<Box>
<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)}>
{t("dashboardCustomize")}
</Button>
<Menu anchorEl={prefsAnchor} open={Boolean(prefsAnchor)} onClose={() => setPrefsAnchor(null)}>
{[
["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]} />
{label}
</MenuItem>
<SectionCard
sx={{
background: theme.palette.mode === "dark"
? `radial-gradient(circle at top left, ${alpha(theme.palette.primary.main, 0.26)}, transparent 35%), linear-gradient(135deg, rgba(15,23,42,0.94), rgba(15,23,42,0.78))`
: `radial-gradient(circle at top left, ${alpha(theme.palette.primary.main, 0.18)}, transparent 35%), linear-gradient(135deg, rgba(255,255,255,0.98), rgba(248,250,252,0.96))`,
overflow: "hidden",
position: "relative",
}}
>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "flex-start" }}>
<Box sx={{ maxWidth: 760 }}>
<Typography variant="overline" sx={{ color: "primary.main", fontWeight: 800 }}>
{t("dashboardHeroLabel")}
</Typography>
<Typography variant="h4" sx={{ fontWeight: 950, mt: 0.5, letterSpacing: -0.6 }}>
{t("dashboardOverviewTitle")}
</Typography>
<Typography variant="body1" sx={{ color: "text.secondary", mt: 1.25, maxWidth: 680 }}>
{t("dashboardOverviewBody")}
</Typography>
<Stack direction={{ xs: "column", md: "row" }} spacing={1.25} sx={{ mt: 2.25, flexWrap: "wrap" }}>
<Chip color="primary" variant="outlined" label={t("dashboardResponseRate", { rate: responseRate })} />
<Chip variant="outlined" label={`${missingCvCount} ${t("dashboardMissingTailoredCv").toLowerCase()}`} />
<Chip variant="outlined" label={topSource ? `${topSource.label}: ${topSource.rate}%` : t("dashboardResponseSources")} />
</Stack>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
{([6, 12, 24] as const).map((m) => (
<Button key={m} size="small" variant={months === m ? "contained" : "outlined"} onClick={() => setMonths(m)}>
{t("dashboardMonthsShort", { count: m })}
</Button>
))}
</Menu>
<Button variant="outlined" startIcon={<TuneIcon />} onClick={(e) => setPrefsAnchor(e.currentTarget)}>
{t("dashboardCustomize")}
</Button>
<Menu anchorEl={prefsAnchor} open={Boolean(prefsAnchor)} onClose={() => setPrefsAnchor(null)}>
{[
["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]} />
{label}
</MenuItem>
))}
</Menu>
</Box>
</Box>
</Box>
</SectionCard>
{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 sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(2, 1fr)", xl: "repeat(4, 1fr)" }, gap: 2, mt: 2 }}>
{metricCards.map((card) => (
<SectionCard key={card.label}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2 }}>
<Box>
<Typography variant="overline" sx={{ color: "text.secondary" }}>{card.label}</Typography>
<Typography variant="h4" sx={{ fontWeight: 950, mt: 0.5 }}>{card.value}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.75 }}>{card.sub}</Typography>
</Box>
<Box sx={{ width: 42, height: 42, borderRadius: 3, display: "grid", placeItems: "center", backgroundColor: alpha(card.tone, 0.12), color: card.tone }}>
{card.icon}
</Box>
</Box>
))}
</Box>
</Paper>
<Box sx={{ mt: 1.5 }}>
<MiniSpark values={card.spark.length ? card.spark : [0, 0, 0]} color={alpha(card.tone, 0.95)} />
</Box>
</SectionCard>
))}
</Box>
) : null}
{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 sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "minmax(0, 1.8fr) minmax(320px, 0.9fr)" }, gap: 2, mt: 2 }}>
{prefs.activity ? (
<SectionCard>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "flex-start" }}>
<Box>
<Typography variant="h6" sx={{ fontWeight: 950 }}>{t("dashboardApplicationActivity")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("dashboardMonthlyApplicationsResponses")}</Typography>
</Box>
<Stack direction="row" spacing={1} flexWrap="wrap">
<Chip size="small" label={t("dashboardAppliedCount", { count: totalApplied })} variant="outlined" />
<Chip size="small" label={t("dashboardResponsesCount", { count: totalResponses })} variant="outlined" />
</Stack>
</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 }}>{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 sx={{ mt: 2, overflowX: "auto" }}>
<Box sx={{ minWidth: chartWidth }}>
<svg width={chartWidth} height={chartHeight} viewBox={`0 0 ${chartWidth} ${chartHeight}`}>
{[0.2, 0.4, 0.6, 0.8].map((tick) => (
<line
key={tick}
x1="0"
x2={chartWidth}
y1={Math.round(chartHeight * tick)}
y2={Math.round(chartHeight * tick)}
stroke={alpha(theme.palette.text.primary, 0.08)}
strokeDasharray="6 6"
/>
))}
{responsePath ? <path d={responsePath} fill="none" stroke={alpha(theme.palette.info.main, 0.95)} strokeWidth="3" strokeLinecap="round" /> : null}
{appliedPath ? <path d={appliedPath} fill="none" stroke={alpha(theme.palette.success.main, 0.95)} strokeWidth="3" strokeLinecap="round" /> : null}
</svg>
<Box sx={{ display: "flex", justifyContent: "space-between", mt: 1 }}>
{analytics.map((point) => (
<Typography key={point.month} variant="caption" sx={{ width: `${100 / Math.max(1, analytics.length)}%`, textAlign: "center", color: "text.secondary" }}>
{point.month.slice(5)}
</Typography>
))}
</Box>
</Box>
</Box>
</SectionCard>
) : null}
<SectionCard>
<Typography variant="h6" sx={{ fontWeight: 950 }}>{t("dashboardConversionFunnelTitle")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("dashboardResponseSources")}</Typography>
<Stack spacing={1.2}>
{(overview?.funnel ?? []).map((item) => {
const width = funnelMax ? clamp((item.count / funnelMax) * 100, 0, 100) : 0;
return (
<Box key={item.label}>
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{item.label}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{item.count}</Typography>
</Box>
<Typography sx={{ textAlign: "right", fontWeight: 900 }}>{item.count}</Typography>
<LinearProgress
variant="determinate"
value={width}
sx={{
height: 10,
borderRadius: 999,
backgroundColor: alpha(theme.palette.primary.main, 0.08),
'& .MuiLinearProgress-bar': {
borderRadius: 999,
background: `linear-gradient(90deg, ${theme.palette.primary.main}, ${alpha(theme.palette.success.main, 0.85)})`,
},
}}
/>
</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}
);
})}
</Stack>
{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 sx={{ mt: 2.25, p: 1.5, borderRadius: 3, backgroundColor: alpha(theme.palette.primary.main, 0.05) }}>
<Typography variant="body2" sx={{ fontWeight: 800 }}>{topSource?.label ?? t("dashboardResponseSources")}</Typography>
<Typography variant="h5" sx={{ fontWeight: 950, mt: 0.5 }}>{topSource ? `${topSource.rate}%` : "—"}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.5 }}>
{topSource ? t("dashboardResponseConversion", { responses: topSource.responses, total: topSource.total }) : t("dashboardNoSourceData")}
</Typography>
</Box>
</SectionCard>
</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 sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "1.15fr 0.85fr" }, gap: 2, mt: 2 }}>
{prefs.companies ? (
<SectionCard>
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>{t("dashboardTopCompaniesByActivity")}</Typography>
<Stack spacing={1.25}>
{(overview?.topCompanies ?? []).map((item, index) => (
<Box key={item.companyId} sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: alpha(theme.palette.primary.main, index === 0 ? 0.05 : 0.02) }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
<Box>
<Typography sx={{ fontWeight: 900 }}>{item.company}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("dashboardCompanyJobsResponses", { jobs: item.count, responses: item.responses })}</Typography>
</Box>
<Chip label={`${item.responseRate}%`} color={item.responseRate >= 50 ? "success" : item.responseRate >= 25 ? "warning" : "default"} variant="outlined" />
</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>
))}
</Stack>
</SectionCard>
) : null}
{prefs.skills ? (
<SectionCard>
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>{t("dashboardTopSkills")}</Typography>
{tags.length === 0 ? (
<Typography sx={{ color: "text.secondary" }}>{t("dashboardNoTagsYet")}</Typography>
) : (
<Stack spacing={1.15}>
{tags.slice(0, 8).map((tag, index) => {
const max = Math.max(...tags.map((item) => item.count), 1);
const width = (tag.count / max) * 100;
return (
<Box key={tag.tag}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 800 }}>{tag.tag}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{tag.count}</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 sx={{ height: 10, borderRadius: 999, bgcolor: alpha(theme.palette.text.primary, 0.08), overflow: "hidden" }}>
<Box sx={{ width: `${width}%`, height: "100%", borderRadius: 999, bgcolor: tagColors[index % tagColors.length] }} />
</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>)}
);
})}
</Stack>
)}
<Typography variant="h6" sx={{ fontWeight: 950, mt: 3, mb: 1 }}>{t("dashboardSkillTrends")}</Typography>
{!tagTrends || tagTrends.series.length === 0 ? (
<Typography sx={{ color: "text.secondary" }}>{t("dashboardNoTagTrendData")}</Typography>
) : (
<Stack spacing={1.1}>
{tagTrends.series.map((series, index) => (
<Box key={series.tag}>
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 800 }}>{series.tag}</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>{series.counts.reduce((sum, value) => sum + value, 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: 18,
borderRadius: 1.25,
backgroundColor: count > 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}`}
/>
))}
</Box>
</Box>
</Box>
)}
</Box>
</Box>
</Paper>
) : null}
))}
</Stack>
)}
</SectionCard>
) : null}
</Box>
</Box>
);
}
@@ -16,6 +16,7 @@ import {
Typography,
Chip,
} from "@mui/material";
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import { api } from "../api";
import { Company, JobApplication } from "../types";
@@ -40,6 +41,17 @@ function toDateInputValue(isoLike?: string): string {
return d.toISOString().slice(0, 10);
}
function parsePickerDate(value?: string | null): Date | null {
if (!value) return null;
const parsed = new Date(value);
return Number.isNaN(+parsed) ? null : parsed;
}
function toPickerIso(value: Date | null): string {
if (!value || Number.isNaN(+value)) return "";
return value.toISOString().slice(0, 10);
}
function parseTags(raw: any): string[] {
if (!raw) return [];
if (Array.isArray(raw)) return raw.filter((x) => typeof x === "string");
@@ -172,7 +184,7 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2, mt: 1 }}>
<Autocomplete options={companies} getOptionLabel={(c) => c.name} value={company} onChange={(_, v) => setCompany(v)} renderInput={(params) => <TextField {...params} label={t("company")} />} />
<TextField label={t("editJobJobTitle")} value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} />
<TextField label={t("editJobAppliedOn")} type="date" value={dateApplied} onChange={(e) => setDateApplied(e.target.value)} InputLabelProps={{ shrink: true }} />
<DatePicker label={t("editJobAppliedOn")} value={parsePickerDate(dateApplied)} onChange={(value) => setDateApplied(toPickerIso(value))} slotProps={{ textField: { fullWidth: true } }} />
<TextField label={t("addJobModalJobUrl")} value={jobUrl} onChange={(e) => setJobUrl(e.target.value)} />
</Box>
</Paper>
@@ -183,11 +195,11 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
<TextField select label={t("editJobCurrentStatus")} value={status} onChange={(e) => setStatus(e.target.value)}>
{STATUS_OPTIONS.map((s) => <MenuItem key={s} value={s}>{s}</MenuItem>)}
</TextField>
<TextField label={t("editJobStatusChangedOn")} type="date" value={statusChangedAt} onChange={(e) => setStatusChangedAt(e.target.value)} InputLabelProps={{ shrink: true }} helperText={status === initialStatus ? t("editJobStatusChangedHelpIdle") : t("editJobStatusChangedHelpActive")} />
<DatePicker label={t("editJobStatusChangedOn")} value={parsePickerDate(statusChangedAt)} onChange={(value) => setStatusChangedAt(toPickerIso(value))} slotProps={{ textField: { fullWidth: true, helperText: status === initialStatus ? t("editJobStatusChangedHelpIdle") : t("editJobStatusChangedHelpActive") } }} />
<Box sx={{ display: "flex", alignItems: "center" }}><FormControlLabel control={<Checkbox checked={responseReceived} onChange={(e) => setResponseReceived(e.target.checked)} />} label={t("editJobReplyReceived")} /></Box>
<TextField label={t("editJobReplyReceivedOn")} type="date" disabled={!responseReceived} value={responseDate} onChange={(e) => setResponseDate(e.target.value)} InputLabelProps={{ shrink: true }} />
<DatePicker label={t("editJobReplyReceivedOn")} disabled={!responseReceived} value={parsePickerDate(responseDate)} onChange={(value) => setResponseDate(toPickerIso(value))} slotProps={{ textField: { fullWidth: true } }} />
<TextField label={t("editJobNextAction")} value={nextAction} onChange={(e) => setNextAction(e.target.value)} />
<TextField label={t("editJobFollowUpOn")} type="date" value={followUpAt} onChange={(e) => setFollowUpAt(e.target.value)} InputLabelProps={{ shrink: true }} />
<DatePicker label={t("editJobFollowUpOn")} value={parsePickerDate(followUpAt)} onChange={(value) => setFollowUpAt(toPickerIso(value))} slotProps={{ textField: { fullWidth: true } }} />
</Box>
</Paper>
@@ -196,7 +208,7 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2, mt: 1 }}>
<TextField label={t("location")} value={location} onChange={(e) => setLocation(e.target.value)} />
<TextField label={t("addJobModalSalary")} value={salary} onChange={(e) => setSalary(e.target.value)} />
<TextField label={t("editJobDeadline")} type="date" value={deadline} onChange={(e) => setDeadline(e.target.value)} InputLabelProps={{ shrink: true }} />
<DatePicker label={t("editJobDeadline")} value={parsePickerDate(deadline)} onChange={(value) => setDeadline(toPickerIso(value))} slotProps={{ textField: { fullWidth: true } }} />
<TextField label={t("editJobDescriptionLanguage")} value={descriptionLanguage} onChange={(e) => setDescriptionLanguage(e.target.value)} />
<Box sx={{ gridColumn: "1 / -1" }}><TagsInput value={tags} onChange={setTags} /></Box>
<TextField label={t("editJobNotes")} value={notes} onChange={(e) => setNotes(e.target.value)} multiline rows={4} helperText={t("correspondenceCharacters", { count: notes.length })} sx={{ gridColumn: "1 / -1" }} />