style(ui): redesign Dashboard and Kanban to match mockups
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,22 +4,20 @@ import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Chip,
|
||||
LinearProgress,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Stack,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import { alpha, useTheme } from "@mui/material/styles";
|
||||
import TuneIcon from "@mui/icons-material/Tune";
|
||||
import 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 ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
|
||||
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
|
||||
|
||||
import { api } from "../api";
|
||||
import ViewStateNotice from "./ViewStateNotice";
|
||||
@@ -100,34 +98,25 @@ function buildLinePath(values: number[], width: number, height: number) {
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function MiniSpark({ values, color }: { values: number[]; color: string }) {
|
||||
const width = 180;
|
||||
const height = 52;
|
||||
const path = buildLinePath(values, width, height);
|
||||
|
||||
// Mockup cards float on the grey page background purely via the theme's Card shadow/radius --
|
||||
// this wraps MuiCard instead of Paper-with-hardcoded-shadow so every section stays in sync with
|
||||
// theme.ts (see 02-dashboard.png).
|
||||
function SectionCard({ children, sx = {} }: { children: React.ReactNode; sx?: any }) {
|
||||
return (
|
||||
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`}>
|
||||
<path d={path} fill="none" stroke={color} strokeWidth="3" strokeLinecap="round" />
|
||||
</svg>
|
||||
<Card sx={{ p: { xs: 1.5, sm: 2.25 }, ...sx }}>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionCard({ children, sx = {} }: { children: React.ReactNode; sx?: any }) {
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
p: { xs: 1.5, sm: 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>
|
||||
);
|
||||
// "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() {
|
||||
@@ -200,11 +189,17 @@ export default function DashboardView() {
|
||||
const summaryView = useMemo(() => {
|
||||
const topSource = overview?.responseRateBySource?.[0];
|
||||
const missingCvCount = reminderJobs.filter((job) => job.workflowSignal?.hasPackageGap).length;
|
||||
const interviewCount = overview?.funnel?.find((item) => item.label === "Interview")?.count ?? 0;
|
||||
const interviewPrepCount = reminderJobs.filter((job) => job.workflowSignal?.actionKey === "interview-prep").length;
|
||||
const overdueFollowUpCount = reminderJobs.filter((job) => job.workflowSignal?.actionKey === "follow-up").length;
|
||||
|
||||
return {
|
||||
funnelMax: overview?.funnel?.length ? Math.max(...overview.funnel.map((item) => item.count)) : 0,
|
||||
topSource,
|
||||
missingCvCount,
|
||||
interviewCount,
|
||||
interviewPrepCount,
|
||||
overdueFollowUpCount,
|
||||
priorityJobs: reminderJobs.slice(0, 5),
|
||||
};
|
||||
}, [overview, reminderJobs]);
|
||||
@@ -232,40 +227,37 @@ export default function DashboardView() {
|
||||
};
|
||||
}, [analytics, isMobile, tags]);
|
||||
|
||||
const metricCards = useMemo(() => ([
|
||||
{
|
||||
label: t("dashboardActiveApplications"),
|
||||
value: stats?.active ?? 0,
|
||||
sub: t("dashboardCurrentlyInProgress"),
|
||||
icon: <TrendingUpIcon fontSize="small" />,
|
||||
tone: theme.palette.primary.main,
|
||||
spark: trendsView.appliedValues,
|
||||
},
|
||||
{
|
||||
label: t("dashboardApplied30Days"),
|
||||
value: stats?.appliedLast30Days ?? 0,
|
||||
sub: t("dashboardNewApplications"),
|
||||
icon: <AutoGraphIcon fontSize="small" />,
|
||||
tone: theme.palette.success.main,
|
||||
spark: trendsView.appliedValues.slice(-6),
|
||||
},
|
||||
{
|
||||
label: t("dashboardMedianFirstResponse"),
|
||||
value: overview?.medianDaysToFirstResponse ?? "—",
|
||||
sub: t("dashboardDaysUntilFirstReply"),
|
||||
icon: <MailOutlineIcon fontSize="small" />,
|
||||
tone: theme.palette.info.main,
|
||||
spark: trendsView.responseValues,
|
||||
},
|
||||
{
|
||||
label: t("dashboardResponsesLogged"),
|
||||
value: overview?.totalResponses ?? 0,
|
||||
sub: t("dashboardAcrossActiveJobs"),
|
||||
icon: <BusinessOutlinedIcon fontSize="small" />,
|
||||
tone: theme.palette.warning.main,
|
||||
spark: trendsView.responseValues.slice(-6),
|
||||
},
|
||||
]), [overview?.medianDaysToFirstResponse, overview?.totalResponses, stats?.active, stats?.appliedLast30Days, t, theme.palette.info.main, theme.palette.primary.main, theme.palette.success.main, theme.palette.warning.main, trendsView.appliedValues, trendsView.responseValues]);
|
||||
// Mockup's 4 stat tiles (Active applications / Response rate / Interviews / Needs follow-up)
|
||||
// -- every value below is derived from data this view already fetches, no new endpoints.
|
||||
const metricCards = useMemo(() => {
|
||||
const cards: { label: string; value: string | number; trend?: { text: string; tone: "success" | "error" }; caption?: string }[] = [
|
||||
{
|
||||
label: t("dashboardActiveApplications"),
|
||||
value: stats?.active ?? 0,
|
||||
trend: (stats?.appliedLast30Days ?? 0) > 0
|
||||
? { text: `+${stats?.appliedLast30Days} ${t("dashboardNewApplications").toLowerCase()}`, tone: "success" }
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
label: t("dashboardResponseRateTile"),
|
||||
value: `${trendsView.responseRate}%`,
|
||||
caption: summaryView.topSource ? `${summaryView.topSource.label} · ${summaryView.topSource.rate}%` : t("dashboardResponseSources"),
|
||||
},
|
||||
{
|
||||
label: t("dashboardInterviews"),
|
||||
value: summaryView.interviewCount,
|
||||
caption: summaryView.interviewPrepCount > 0 ? t("dashboardUpcomingCount", { count: summaryView.interviewPrepCount }) : undefined,
|
||||
},
|
||||
{
|
||||
label: t("dashboardFollowUpsDue"),
|
||||
value: reminderJobs.length,
|
||||
trend: summaryView.overdueFollowUpCount > 0
|
||||
? { text: t("dashboardOverdueCount", { count: summaryView.overdueFollowUpCount }), tone: "error" }
|
||||
: undefined,
|
||||
},
|
||||
];
|
||||
return cards;
|
||||
}, [reminderJobs.length, stats?.active, stats?.appliedLast30Days, summaryView.interviewCount, summaryView.interviewPrepCount, summaryView.overdueFollowUpCount, summaryView.topSource, t, trendsView.responseRate]);
|
||||
|
||||
const togglePref = useCallback((key: keyof Prefs) => {
|
||||
setPrefs((current) => {
|
||||
@@ -286,85 +278,67 @@ export default function DashboardView() {
|
||||
navigate(buildWorkflowPath(job));
|
||||
}, [navigate]);
|
||||
|
||||
const timeInStageMax = overview?.timeInStage?.length ? Math.max(...overview.timeInStage.map((item) => item.medianDays), 1) : 1;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<OnboardingChecklist hasJobs={(stats?.total ?? 0) > 0} />
|
||||
<SectionCard
|
||||
sx={{
|
||||
backgroundColor: "background.paper",
|
||||
borderColor: theme.palette.mode === "dark" ? alpha(theme.palette.primary.main, 0.22) : "divider",
|
||||
boxShadow: theme.palette.mode === "dark"
|
||||
? `0 20px 48px ${alpha(theme.palette.common.black, 0.34)}`
|
||||
: "0 18px 50px rgba(15, 23, 42, 0.06)",
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
pointerEvents: 'none',
|
||||
background: theme.palette.mode === 'dark'
|
||||
? `linear-gradient(135deg, ${alpha(theme.palette.primary.main, 0.14)}, transparent 42%)`
|
||||
: `linear-gradient(135deg, ${alpha(theme.palette.primary.main, 0.08)}, transparent 42%)`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "flex-start" }}>
|
||||
<Box sx={{ maxWidth: 760 }}>
|
||||
<Typography variant="overline" sx={{ color: theme.palette.primary.main, fontWeight: 800 }}>
|
||||
{t("dashboardHeroLabel")}
|
||||
</Typography>
|
||||
<Typography variant="h4" sx={{ fontWeight: 950, mt: 0.5, letterSpacing: -0.6, color: "text.primary", overflowWrap: "anywhere" }}>
|
||||
{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: trendsView.responseRate })} />
|
||||
<Chip variant="outlined" label={`${summaryView.missingCvCount} ${t("dashboardMissingTailoredCv").toLowerCase()}`} />
|
||||
<Chip variant="outlined" label={summaryView.topSource ? `${summaryView.topSource.label}: ${summaryView.topSource.rate}%` : t("dashboardResponseSources")} />
|
||||
</Stack>
|
||||
</Box>
|
||||
<Box sx={{ mb: 2, display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "flex-start" }}>
|
||||
<Box sx={{ maxWidth: 760 }}>
|
||||
<Typography variant="overline" sx={{ color: theme.palette.primary.main, fontWeight: 800 }}>
|
||||
{t("dashboardHeroLabel")}
|
||||
</Typography>
|
||||
<Typography variant="h3" sx={{ mt: 0.5, color: "text.primary", overflowWrap: "anywhere" }}>
|
||||
{t("dashboardOverviewTitle")}
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ color: "text.secondary", mt: 1 }}>
|
||||
{t("dashboardOverviewBody")}
|
||||
</Typography>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
gap: 1,
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
width: { xs: "100%", sm: "auto" },
|
||||
'& .MuiButton-root': {
|
||||
flex: { xs: '1 1 calc(50% - 8px)', sm: '0 0 auto' },
|
||||
},
|
||||
}}
|
||||
>
|
||||
{([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>
|
||||
))}
|
||||
<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>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={1.25} sx={{ mt: 2, flexWrap: "wrap" }}>
|
||||
<Chip color="primary" variant="outlined" label={t("dashboardResponseRate", { rate: trendsView.responseRate })} />
|
||||
<Chip variant="outlined" label={`${summaryView.missingCvCount} ${t("dashboardMissingTailoredCv").toLowerCase()}`} />
|
||||
<Chip variant="outlined" label={summaryView.topSource ? `${summaryView.topSource.label}: ${summaryView.topSource.rate}%` : t("dashboardResponseSources")} />
|
||||
</Stack>
|
||||
</Box>
|
||||
</SectionCard>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
gap: 1,
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
width: { xs: "100%", sm: "auto" },
|
||||
'& .MuiButton-root': {
|
||||
flex: { xs: '1 1 calc(50% - 8px)', sm: '0 0 auto' },
|
||||
},
|
||||
}}
|
||||
>
|
||||
{([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>
|
||||
))}
|
||||
<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>
|
||||
|
||||
<ViewStateNotice
|
||||
loading={summaryResource.loading}
|
||||
@@ -386,19 +360,26 @@ export default function DashboardView() {
|
||||
<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>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>{card.label}</Typography>
|
||||
<Typography variant="h3" sx={{ mt: 0.5 }}>{card.value}</Typography>
|
||||
{card.trend || card.caption ? (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5, mt: 0.75 }}>
|
||||
{card.trend ? (
|
||||
<>
|
||||
{card.trend.tone === "success" ? (
|
||||
<ArrowUpwardIcon sx={{ fontSize: 14 }} color="success" />
|
||||
) : (
|
||||
<ArrowDownwardIcon sx={{ fontSize: 14 }} color="error" />
|
||||
)}
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, color: card.trend.tone === "success" ? "success.main" : "error.main" }}>
|
||||
{card.trend.text}
|
||||
</Typography>
|
||||
</>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{card.caption}</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 sx={{ mt: 1.5, overflowX: "auto" }}>
|
||||
<MiniSpark values={card.spark.length ? card.spark : [0, 0, 0]} color={alpha(card.tone, 0.95)} />
|
||||
</Box>
|
||||
) : null}
|
||||
</SectionCard>
|
||||
))}
|
||||
</Box>
|
||||
@@ -409,12 +390,18 @@ export default function DashboardView() {
|
||||
<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="h6">{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: trendsView.totalApplied })} variant="outlined" />
|
||||
<Chip size="small" label={t("dashboardResponsesCount", { count: trendsView.totalResponses })} variant="outlined" />
|
||||
<Stack direction="row" spacing={2} alignItems="center" flexWrap="wrap">
|
||||
<Stack direction="row" spacing={0.75} alignItems="center">
|
||||
<Box sx={{ width: 10, height: 10, borderRadius: 0.75, backgroundColor: theme.palette.primary.main }} />
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("dashboardAppliedCount", { count: trendsView.totalApplied })}</Typography>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={0.75} alignItems="center">
|
||||
<Box sx={{ width: 10, height: 10, borderRadius: 0.75, backgroundColor: theme.palette.success.main }} />
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("dashboardResponsesCount", { count: trendsView.totalResponses })}</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
@@ -432,8 +419,8 @@ export default function DashboardView() {
|
||||
strokeDasharray="6 6"
|
||||
/>
|
||||
))}
|
||||
{trendsView.responsePath ? <path d={trendsView.responsePath} fill="none" stroke={alpha(theme.palette.info.main, 0.95)} strokeWidth="3" strokeLinecap="round" /> : null}
|
||||
{trendsView.appliedPath ? <path d={trendsView.appliedPath} fill="none" stroke={alpha(theme.palette.success.main, 0.95)} strokeWidth="3" strokeLinecap="round" /> : null}
|
||||
{trendsView.responsePath ? <path d={trendsView.responsePath} fill="none" stroke={theme.palette.success.main} strokeWidth="3" strokeLinecap="round" /> : null}
|
||||
{trendsView.appliedPath ? <path d={trendsView.appliedPath} fill="none" stroke={theme.palette.primary.main} strokeWidth="3" strokeLinecap="round" /> : null}
|
||||
</svg>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", mt: 1 }}>
|
||||
{analytics.map((point) => (
|
||||
@@ -449,9 +436,55 @@ export default function DashboardView() {
|
||||
|
||||
{!summaryResource.loading && !summaryResource.error ? (
|
||||
<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}>
|
||||
<Typography variant="h6">{t("dashboardTimeInStageTitle")}</Typography>
|
||||
{overview?.timeInStage?.length ? (
|
||||
<Stack spacing={1.25} sx={{ mt: 1.5 }}>
|
||||
{overview.timeInStage.map((item, index) => (
|
||||
<Box key={item.stage}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 0.5, gap: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{statusLabel(t, item.stage)}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
{t("dashboardTimeInStageValue", { days: item.medianDays, count: item.count })}
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={clamp((item.medianDays / timeInStageMax) * 100, 4, 100)}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 999,
|
||||
backgroundColor: alpha(theme.palette.text.primary, 0.06),
|
||||
'& .MuiLinearProgress-bar': {
|
||||
borderRadius: 999,
|
||||
backgroundColor: tagColors[index % tagColors.length],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>{t("dashboardNoTagsYet")}</Typography>
|
||||
)}
|
||||
|
||||
{tags.length ? (
|
||||
<Box sx={{ mt: 2.25 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 800, mb: 1 }}>{t("dashboardTopSkills")}</Typography>
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.75 }}>
|
||||
{tags.slice(0, 4).map((tag) => (
|
||||
<Chip
|
||||
key={tag.tag}
|
||||
size="small"
|
||||
label={`${tag.tag} · ${tag.count}`}
|
||||
sx={{ backgroundColor: alpha(theme.palette.primary.main, 0.1), color: "primary.main", fontWeight: 700 }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<Typography variant="h6" sx={{ mt: 2.25 }}>{t("dashboardConversionFunnelTitle")}</Typography>
|
||||
<Stack spacing={1.2} sx={{ mt: 1 }}>
|
||||
{(overview?.funnel ?? []).map((item) => {
|
||||
const width = summaryView.funnelMax ? clamp((item.count / summaryView.funnelMax) * 100, 0, 100) : 0;
|
||||
return (
|
||||
@@ -478,25 +511,9 @@ export default function DashboardView() {
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
{overview?.timeInStage?.length ? (
|
||||
<Box sx={{ mt: 2.25 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 800, mb: 1 }}>{t("dashboardTimeInStageTitle")}</Typography>
|
||||
<Stack spacing={0.75}>
|
||||
{overview.timeInStage.map((item) => (
|
||||
<Box key={item.stage} sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{statusLabel(t, item.stage)}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
{t("dashboardTimeInStageValue", { days: item.medianDays, count: item.count })}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
) : 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 }}>{summaryView.topSource?.label ?? t("dashboardResponseSources")}</Typography>
|
||||
<Typography variant="h5" sx={{ fontWeight: 950, mt: 0.5 }}>{summaryView.topSource ? `${summaryView.topSource.rate}%` : "—"}</Typography>
|
||||
<Typography variant="h5" sx={{ mt: 0.5 }}>{summaryView.topSource ? `${summaryView.topSource.rate}%` : "—"}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.5 }}>
|
||||
{summaryView.topSource ? t("dashboardResponseConversion", { responses: summaryView.topSource.responses, total: summaryView.topSource.total }) : t("dashboardNoSourceData")}
|
||||
</Typography>
|
||||
@@ -508,7 +525,7 @@ export default function DashboardView() {
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "1.15fr 0.85fr" }, gap: 2, mt: 2 }}>
|
||||
{!summaryResource.loading && !summaryResource.error ? (
|
||||
<SectionCard>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>{t("remindersTitle")}</Typography>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>{t("remindersTitle")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("remindersSubtitle")}</Typography>
|
||||
{summaryView.priorityJobs.length === 0 ? (
|
||||
<Typography sx={{ color: "text.secondary" }}>{t("remindersNothing")}</Typography>
|
||||
@@ -516,13 +533,40 @@ export default function DashboardView() {
|
||||
<Stack spacing={1.1}>
|
||||
{summaryView.priorityJobs.map((job) => {
|
||||
const action = getReminderAction(job);
|
||||
const tone = reminderTone(job);
|
||||
const toneColor = theme.palette[tone].main;
|
||||
const urgent = tone === "error";
|
||||
return (
|
||||
<Box key={job.id} sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: alpha(theme.palette.primary.main, 0.03), display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography sx={{ fontWeight: 900, overflowWrap: "anywhere" }}>{job.company?.name ?? t("jobTableCompany")} • {job.jobTitle}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>{action?.detail ?? job.workflowSignal?.reason ?? job.followUpReason ?? t("remindersFollowUpLabel")}</Typography>
|
||||
<Box
|
||||
key={job.id}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
pl: 2,
|
||||
borderRadius: 2,
|
||||
borderLeft: `4px solid ${toneColor}`,
|
||||
backgroundColor: alpha(toneColor, 0.07),
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
gap: 2,
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ minWidth: 0, display: "flex", gap: 1.25, alignItems: "flex-start" }}>
|
||||
<Box sx={{ width: 8, height: 8, mt: 0.75, borderRadius: "50%", backgroundColor: toneColor, flexShrink: 0 }} />
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography sx={{ fontWeight: 900, overflowWrap: "anywhere" }}>{job.company?.name ?? t("jobTableCompany")} • {job.jobTitle}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>{action?.detail ?? job.workflowSignal?.reason ?? job.followUpReason ?? t("remindersFollowUpLabel")}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Button variant="outlined" onClick={() => openReminderJob(job)} sx={{ width: { xs: "100%", sm: "auto" } }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
disableElevation
|
||||
onClick={() => openReminderJob(job)}
|
||||
sx={urgent
|
||||
? { width: { xs: "100%", sm: "auto" }, bgcolor: "text.primary", color: "background.paper", boxShadow: "none", "&:hover": { bgcolor: "text.primary", boxShadow: "none" } }
|
||||
: { width: { xs: "100%", sm: "auto" }, bgcolor: alpha(toneColor, 0.15), color: toneColor, boxShadow: "none", "&:hover": { bgcolor: alpha(toneColor, 0.24), boxShadow: "none" } }}
|
||||
>
|
||||
{action?.label ?? t("remindersOpen")}
|
||||
</Button>
|
||||
</Box>
|
||||
@@ -538,7 +582,7 @@ export default function DashboardView() {
|
||||
|
||||
{!summaryResource.loading && !summaryResource.error && prefs.companies ? (
|
||||
<SectionCard>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>{t("dashboardTopCompaniesByActivity")}</Typography>
|
||||
<Typography variant="h6" sx={{ 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) }}>
|
||||
@@ -557,7 +601,7 @@ export default function DashboardView() {
|
||||
|
||||
{!trendsResource.loading && !trendsResource.error && prefs.skills ? (
|
||||
<SectionCard>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>{t("dashboardTopSkills")}</Typography>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>{t("dashboardTopSkills")}</Typography>
|
||||
{tags.length === 0 ? (
|
||||
<Typography sx={{ color: "text.secondary" }}>{t("dashboardNoTagsYet")}</Typography>
|
||||
) : (
|
||||
@@ -580,7 +624,7 @@ export default function DashboardView() {
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Typography variant="h6" sx={{ fontWeight: 950, mt: 3, mb: 1 }}>{t("dashboardSkillTrends")}</Typography>
|
||||
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}>{t("dashboardSkillTrends")}</Typography>
|
||||
{!tagTrends || tagTrends.series.length === 0 ? (
|
||||
<Typography sx={{ color: "text.secondary" }}>{t("dashboardNoTagTrendData")}</Typography>
|
||||
) : (
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuItem,
|
||||
@@ -36,6 +37,36 @@ function toneColor(theme: any, status: Status | "Other"): string {
|
||||
return TONE_PALETTE[statusTone(status)](theme);
|
||||
}
|
||||
|
||||
// Mockup's Applied-column cards carry small skill-tag pills ("React", "TS") sourced from the
|
||||
// job's own `tags` field (JSON array string) -- no new data, just parsed for display.
|
||||
function parseTags(raw?: string): string[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed.filter((x) => typeof x === "string").slice(0, 2) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
type PillTone = "error" | "warning" | "success" | "info";
|
||||
|
||||
// Mockup's single status pill per card ("Reply due in 2d", "Tomorrow 14:00", "720k NOK / yr").
|
||||
// Built entirely from fields the board already fetches (needsFollowUp/followUpAt/salary/deadline)
|
||||
// -- there's no interview-datetime field on the board's JobApplication shape, so an "Interview
|
||||
// tomorrow 14:00"-style pill isn't reproducible; deadline is used as the closest real fallback.
|
||||
function cardPill(job: JobApplication, t: (key: any, params?: any) => string): { text: string; tone: PillTone } | null {
|
||||
if (job.needsFollowUp) {
|
||||
const due = job.followUpAt ? new Date(job.followUpAt) : null;
|
||||
if (!due || due.getTime() <= Date.now()) return { text: t("kanbanFollowUpNow"), tone: "error" };
|
||||
const days = Math.max(1, Math.ceil((due.getTime() - Date.now()) / 86400000));
|
||||
return { text: t("kanbanReplyDueIn", { days }), tone: "warning" };
|
||||
}
|
||||
if (job.salary) return { text: job.salary, tone: "success" };
|
||||
if (job.deadline) return { text: new Date(job.deadline).toLocaleDateString(), tone: "info" };
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function KanbanBoard() {
|
||||
const theme = useTheme();
|
||||
const { t } = useI18n();
|
||||
@@ -129,7 +160,7 @@ export default function KanbanBoard() {
|
||||
scrollSnapAlign: { xs: "start", md: "none" },
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
background: theme.palette.mode === "dark" ? alpha(theme.palette.common.white, 0.02) : alpha(theme.palette.text.primary, 0.015),
|
||||
backgroundColor: theme.palette.grey[100],
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 1.25, px: 0.25 }}>
|
||||
@@ -139,50 +170,93 @@ export default function KanbanBoard() {
|
||||
{statusLabel(t, status)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", fontWeight: 700 }}>
|
||||
{list.length}
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
minWidth: 22,
|
||||
textAlign: "center",
|
||||
px: 0.75,
|
||||
py: 0.15,
|
||||
borderRadius: 999,
|
||||
backgroundColor: theme.palette.grey[300],
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 700 }}>
|
||||
{list.length}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
{list.map((j) => (
|
||||
<Card
|
||||
key={j.id}
|
||||
draggable
|
||||
onDragStart={() => setDragJobId(j.id)}
|
||||
onDragEnd={() => setDragJobId(null)}
|
||||
sx={{
|
||||
cursor: "grab",
|
||||
borderRadius: 2.5,
|
||||
borderLeft: `4px solid ${c}`,
|
||||
boxShadow: theme.palette.mode === "dark" ? "none" : "0 1px 3px rgba(15,23,42,0.06)",
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 1.25, "&:last-child": { pb: 1.25 } }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}>
|
||||
<Typography sx={{ fontWeight: 800, lineHeight: 1.25 }}>
|
||||
{j.jobTitle}
|
||||
{list.map((j) => {
|
||||
const pill = cardPill(j, t);
|
||||
const tags = parseTags(j.tags);
|
||||
return (
|
||||
<Card
|
||||
key={j.id}
|
||||
draggable
|
||||
onDragStart={() => setDragJobId(j.id)}
|
||||
onDragEnd={() => setDragJobId(null)}
|
||||
sx={{
|
||||
cursor: "grab",
|
||||
borderRadius: 2.5,
|
||||
borderLeft: `4px solid ${c}`,
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 1.25, "&:last-child": { pb: 1.25 } }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}>
|
||||
<Typography sx={{ fontWeight: 800, lineHeight: 1.25 }}>
|
||||
{j.jobTitle}
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMenuJobId(j.id);
|
||||
setMenuAnchor(e.currentTarget);
|
||||
}}
|
||||
>
|
||||
<MoreHorizIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
{[j.company?.name, j.location].filter(Boolean).join(" · ")}
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMenuJobId(j.id);
|
||||
setMenuAnchor(e.currentTarget);
|
||||
}}
|
||||
>
|
||||
<MoreHorizIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
{[j.company?.name, j.location].filter(Boolean).join(" · ")}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.75 }}>
|
||||
{j.daysSince}d
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{tags.length > 0 && (
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5, mt: 0.75 }}>
|
||||
{tags.map((tag) => (
|
||||
<Chip
|
||||
key={tag}
|
||||
size="small"
|
||||
label={tag}
|
||||
sx={{ height: 22, backgroundColor: alpha(theme.palette.primary.main, 0.12), color: "primary.main", fontWeight: 700 }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{pill && (
|
||||
<Box sx={{ mt: 0.75 }}>
|
||||
<Chip
|
||||
size="small"
|
||||
label={pill.text}
|
||||
sx={{
|
||||
height: 24,
|
||||
fontWeight: 700,
|
||||
backgroundColor: alpha(theme.palette[pill.tone].main, 0.14),
|
||||
color: theme.palette[pill.tone].main,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Typography variant="caption" sx={{ display: "block", color: "text.secondary", mt: 0.75 }}>
|
||||
{t("kanbanAppliedAgo", { days: j.daysSince })}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
{list.length === 0 && (
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", py: 1 }}>
|
||||
{t("kanbanDropHere")}
|
||||
|
||||
@@ -404,6 +404,11 @@ export const translations = {
|
||||
dashboardDaysUntilFirstReply: "Days until first reply",
|
||||
dashboardResponsesLogged: "Responses logged",
|
||||
dashboardAcrossActiveJobs: "Across active jobs",
|
||||
dashboardResponseRateTile: "Response rate",
|
||||
dashboardInterviews: "Interviews",
|
||||
dashboardUpcomingCount: "{count} upcoming",
|
||||
dashboardOverdueCount: "{count} overdue",
|
||||
dashboardFollowUpsDue: "Follow-ups due",
|
||||
dashboardLowReadiness: "Low readiness",
|
||||
dashboardMissingTailoredCv: "Reminder jobs missing tailored CV",
|
||||
dashboardApplicationActivity: "Application activity",
|
||||
@@ -476,6 +481,9 @@ export const translations = {
|
||||
adminUsersPassword: "Password",
|
||||
kanbanHint: "Drag cards between columns to update status.",
|
||||
kanbanDropHere: "Drop here",
|
||||
kanbanAppliedAgo: "Applied {days}d ago",
|
||||
kanbanFollowUpNow: "Follow up now",
|
||||
kanbanReplyDueIn: "Reply due in {days}d",
|
||||
adminSystemTitle: "System status",
|
||||
adminSystemSubtitle: "Production diagnostics for runtime, database, auth, email, AI service health, and OCR readiness.",
|
||||
adminSystemRunProbe: "Run probe now",
|
||||
@@ -1458,6 +1466,11 @@ export const translations = {
|
||||
dashboardDaysUntilFirstReply: "Dager til første svar",
|
||||
dashboardResponsesLogged: "Registrerte svar",
|
||||
dashboardAcrossActiveJobs: "På tvers av aktive jobber",
|
||||
dashboardResponseRateTile: "Svarrate",
|
||||
dashboardInterviews: "Intervjuer",
|
||||
dashboardUpcomingCount: "{count} kommende",
|
||||
dashboardOverdueCount: "{count} forfalt",
|
||||
dashboardFollowUpsDue: "Oppfølginger som venter",
|
||||
dashboardLowReadiness: "Lav beredskap",
|
||||
dashboardMissingTailoredCv: "Påminnelsesjobber uten skreddersydd CV",
|
||||
dashboardApplicationActivity: "Søknadsaktivitet",
|
||||
@@ -1530,6 +1543,9 @@ export const translations = {
|
||||
adminUsersPassword: "Passord",
|
||||
kanbanHint: "Dra kort mellom kolonnene for å oppdatere status.",
|
||||
kanbanDropHere: "Slipp her",
|
||||
kanbanAppliedAgo: "Søkt for {days}d siden",
|
||||
kanbanFollowUpNow: "Følg opp nå",
|
||||
kanbanReplyDueIn: "Svar forfaller om {days}d",
|
||||
adminSystemTitle: "Systemstatus",
|
||||
adminSystemSubtitle: "Produksjonsdiagnostikk for kjøretid, database, autentisering, e-post, AI-tjenestehelse og OCR-beredskap.",
|
||||
adminSystemRunProbe: "Kjør probe nå",
|
||||
|
||||
Reference in New Issue
Block a user