style(ui): redesign Dashboard and Kanban to match mockups
CI and Deploy / test (push) Successful in 2m16s
CI and Deploy / deploy (push) Successful in 42s

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-13 09:05:54 +02:00
parent 1ab92e5c9d
commit 81512db1cb
3 changed files with 358 additions and 224 deletions
+227 -183
View File
@@ -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>
) : (
+115 -41
View File
@@ -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")}
+16
View File
@@ -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å",