diff --git a/job-tracker-ui/src/components/DashboardView.tsx b/job-tracker-ui/src/components/DashboardView.tsx index 957b615..47128ed 100644 --- a/job-tracker-ui/src/components/DashboardView.tsx +++ b/job-tracker-ui/src/components/DashboardView.tsx @@ -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 ( - - - + + {children} + ); } -function SectionCard({ children, sx = {} }: { children: React.ReactNode; sx?: any }) { - return ( - - {children} - - ); +// "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: , - tone: theme.palette.primary.main, - spark: trendsView.appliedValues, - }, - { - label: t("dashboardApplied30Days"), - value: stats?.appliedLast30Days ?? 0, - sub: t("dashboardNewApplications"), - icon: , - tone: theme.palette.success.main, - spark: trendsView.appliedValues.slice(-6), - }, - { - label: t("dashboardMedianFirstResponse"), - value: overview?.medianDaysToFirstResponse ?? "—", - sub: t("dashboardDaysUntilFirstReply"), - icon: , - tone: theme.palette.info.main, - spark: trendsView.responseValues, - }, - { - label: t("dashboardResponsesLogged"), - value: overview?.totalResponses ?? 0, - sub: t("dashboardAcrossActiveJobs"), - icon: , - 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 ( 0} /> - - - - - {t("dashboardHeroLabel")} - - - {t("dashboardOverviewTitle")} - - - {t("dashboardOverviewBody")} - - - - - - - + + + + {t("dashboardHeroLabel")} + + + {t("dashboardOverviewTitle")} + + + {t("dashboardOverviewBody")} + - - {([6, 12, 24] as const).map((m) => ( - - ))} - - setPrefsAnchor(null)}> - {[ - ["cards", t("dashboardSummaryCards")], - ["activity", t("dashboardActivityChart")], - ["funnel", t("dashboardConversionFunnel")], - ["companies", t("dashboardTopCompanies")], - ["skills", t("dashboardSkillsInsights")], - ].map(([key, label]) => ( - togglePref(key as keyof Prefs)}> - - {label} - - ))} - - + + + + + - + + + {([6, 12, 24] as const).map((m) => ( + + ))} + + setPrefsAnchor(null)}> + {[ + ["cards", t("dashboardSummaryCards")], + ["activity", t("dashboardActivityChart")], + ["funnel", t("dashboardConversionFunnel")], + ["companies", t("dashboardTopCompanies")], + ["skills", t("dashboardSkillsInsights")], + ].map(([key, label]) => ( + togglePref(key as keyof Prefs)}> + + {label} + + ))} + + + {metricCards.map((card) => ( - - - {card.label} - {card.value} - {card.sub} + {card.label} + {card.value} + {card.trend || card.caption ? ( + + {card.trend ? ( + <> + {card.trend.tone === "success" ? ( + + ) : ( + + )} + + {card.trend.text} + + + ) : ( + {card.caption} + )} - - {card.icon} - - - - - + ) : null} ))} @@ -409,12 +390,18 @@ export default function DashboardView() { - {t("dashboardApplicationActivity")} + {t("dashboardApplicationActivity")} {t("dashboardMonthlyApplicationsResponses")} - - - + + + + {t("dashboardAppliedCount", { count: trendsView.totalApplied })} + + + + {t("dashboardResponsesCount", { count: trendsView.totalResponses })} + @@ -432,8 +419,8 @@ export default function DashboardView() { strokeDasharray="6 6" /> ))} - {trendsView.responsePath ? : null} - {trendsView.appliedPath ? : null} + {trendsView.responsePath ? : null} + {trendsView.appliedPath ? : null} {analytics.map((point) => ( @@ -449,9 +436,55 @@ export default function DashboardView() { {!summaryResource.loading && !summaryResource.error ? ( - {t("dashboardConversionFunnelTitle")} - {t("dashboardResponseSources")} - + {t("dashboardTimeInStageTitle")} + {overview?.timeInStage?.length ? ( + + {overview.timeInStage.map((item, index) => ( + + + {statusLabel(t, item.stage)} + + {t("dashboardTimeInStageValue", { days: item.medianDays, count: item.count })} + + + + + ))} + + ) : ( + {t("dashboardNoTagsYet")} + )} + + {tags.length ? ( + + {t("dashboardTopSkills")} + + {tags.slice(0, 4).map((tag) => ( + + ))} + + + ) : null} + + {t("dashboardConversionFunnelTitle")} + {(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() { })} - {overview?.timeInStage?.length ? ( - - {t("dashboardTimeInStageTitle")} - - {overview.timeInStage.map((item) => ( - - {statusLabel(t, item.stage)} - - {t("dashboardTimeInStageValue", { days: item.medianDays, count: item.count })} - - - ))} - - - ) : null} - {summaryView.topSource?.label ?? t("dashboardResponseSources")} - {summaryView.topSource ? `${summaryView.topSource.rate}%` : "—"} + {summaryView.topSource ? `${summaryView.topSource.rate}%` : "—"} {summaryView.topSource ? t("dashboardResponseConversion", { responses: summaryView.topSource.responses, total: summaryView.topSource.total }) : t("dashboardNoSourceData")} @@ -508,7 +525,7 @@ export default function DashboardView() { {!summaryResource.loading && !summaryResource.error ? ( - {t("remindersTitle")} + {t("remindersTitle")} {t("remindersSubtitle")} {summaryView.priorityJobs.length === 0 ? ( {t("remindersNothing")} @@ -516,13 +533,40 @@ export default function DashboardView() { {summaryView.priorityJobs.map((job) => { const action = getReminderAction(job); + const tone = reminderTone(job); + const toneColor = theme.palette[tone].main; + const urgent = tone === "error"; return ( - - - {job.company?.name ?? t("jobTableCompany")} • {job.jobTitle} - {action?.detail ?? job.workflowSignal?.reason ?? job.followUpReason ?? t("remindersFollowUpLabel")} + + + + + {job.company?.name ?? t("jobTableCompany")} • {job.jobTitle} + {action?.detail ?? job.workflowSignal?.reason ?? job.followUpReason ?? t("remindersFollowUpLabel")} + - @@ -538,7 +582,7 @@ export default function DashboardView() { {!summaryResource.loading && !summaryResource.error && prefs.companies ? ( - {t("dashboardTopCompaniesByActivity")} + {t("dashboardTopCompaniesByActivity")} {(overview?.topCompanies ?? []).map((item, index) => ( @@ -557,7 +601,7 @@ export default function DashboardView() { {!trendsResource.loading && !trendsResource.error && prefs.skills ? ( - {t("dashboardTopSkills")} + {t("dashboardTopSkills")} {tags.length === 0 ? ( {t("dashboardNoTagsYet")} ) : ( @@ -580,7 +624,7 @@ export default function DashboardView() { )} - {t("dashboardSkillTrends")} + {t("dashboardSkillTrends")} {!tagTrends || tagTrends.series.length === 0 ? ( {t("dashboardNoTagTrendData")} ) : ( diff --git a/job-tracker-ui/src/components/KanbanBoard.tsx b/job-tracker-ui/src/components/KanbanBoard.tsx index 81d0164..7cc5269 100644 --- a/job-tracker-ui/src/components/KanbanBoard.tsx +++ b/job-tracker-ui/src/components/KanbanBoard.tsx @@ -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], }} > @@ -139,50 +170,93 @@ export default function KanbanBoard() { {statusLabel(t, status)} - - {list.length} - + + + {list.length} + + - {list.map((j) => ( - 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)", - }} - > - - - - {j.jobTitle} + {list.map((j) => { + const pill = cardPill(j, t); + const tags = parseTags(j.tags); + return ( + setDragJobId(j.id)} + onDragEnd={() => setDragJobId(null)} + sx={{ + cursor: "grab", + borderRadius: 2.5, + borderLeft: `4px solid ${c}`, + }} + > + + + + {j.jobTitle} + + { + e.stopPropagation(); + setMenuJobId(j.id); + setMenuAnchor(e.currentTarget); + }} + > + + + + + {[j.company?.name, j.location].filter(Boolean).join(" · ")} - { - e.stopPropagation(); - setMenuJobId(j.id); - setMenuAnchor(e.currentTarget); - }} - > - - - - - {[j.company?.name, j.location].filter(Boolean).join(" · ")} - - - {j.daysSince}d - - - - ))} + + {tags.length > 0 && ( + + {tags.map((tag) => ( + + ))} + + )} + + {pill && ( + + + + )} + + + {t("kanbanAppliedAgo", { days: j.daysSince })} + + + + ); + })} {list.length === 0 && ( {t("kanbanDropHere")} diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 6268bcf..41de380 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -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å",