From 66d924e880c65329eaf558003f6244774d5c3d9f Mon Sep 17 00:00:00 2001 From: cesnimda Date: Mon, 23 Mar 2026 21:23:15 +0100 Subject: [PATCH] Refresh dashboard, adopt MUI X, and improve AI follow-ups --- .../Controllers/JobApplicationsController.cs | 53 +- job-tracker-ui/package-lock.json | 169 +++++++ job-tracker-ui/package.json | 3 + job-tracker-ui/src/components/AddJobModal.tsx | 27 +- .../src/components/DashboardView.tsx | 477 +++++++++++------- .../src/components/EditJobDialog.tsx | 22 +- job-tracker-ui/src/i18n/translations.ts | 16 + job-tracker-ui/src/index.tsx | 10 +- job-tracker-ui/src/pages/AdminUsersPage.tsx | 158 +++--- 9 files changed, 684 insertions(+), 251 deletions(-) diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index 3b21178..56135b6 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -1358,10 +1358,10 @@ Candidate CV/profile: 70); var recruiterMessageDraft = await _summarizer.SummarizeSectionAsync( - "Draft a concise recruiter message for this candidate and job. Keep it warm, direct, and under 120 words.", + "Draft a concise recruiter message for this candidate and job. Mention the exact role and one or two concrete overlaps from the posting or candidate background. Keep it warm, direct, and under 120 words.", jobContext, - 120, - 45); + 130, + 50); var guidance = new CandidateFitChannelGuidanceDto( Cv: mention.Take(4).ToList(), @@ -1541,10 +1541,10 @@ Candidate master CV: 70); var recruiterMessageDraft = await _summarizer.SummarizeSectionAsync( - "Write a short recruiter intro message for this candidate and role. Keep it warm, direct, and concise.", + "Write a short recruiter intro message for this candidate and role. Make it feel specific to the posting by mentioning the exact role, company, and one or two concrete overlaps from the candidate profile or job context. Keep it warm, direct, and concise.", packageContext, - 120, - 45); + 140, + 55); var keyPoints = SkillTagger.Detect(jobText) .Distinct(StringComparer.OrdinalIgnoreCase) @@ -1781,16 +1781,47 @@ Candidate master CV: var subject = $"Following up on {job.JobTitle} application"; var reference = lastMessage?.Subject ?? job.JobTitle; var summary = job.ShortSummary; - var body = string.Join("\n\n", new[] + var appliedDate = job.DateApplied.ToString("MMMM d, yyyy"); + var tagHighlights = SplitTags(job.Tags).Take(4).ToList(); + var companyName = job.Company?.Name ?? "your team"; + + var aiContext = $@"Candidate name: {signerName} +Role: {job.JobTitle} +Company: {companyName} +Applied on: {appliedDate} +Reason for follow-up: {reason} +Last message subject: {lastMessage?.Subject ?? "None"} +Last message date: {(lastMessage is not null ? lastMessage.Date.ToString("MMMM d, yyyy") : "None")} +Relevant skills/tags: {(tagHighlights.Count > 0 ? string.Join(", ", tagHighlights) : "None provided")} +Short fit summary: {summary ?? "None provided"} +Job description: +{job.TranslatedDescription ?? job.Description ?? "No job description available."}"; + + var aiBody = await _summarizer.SummarizeSectionAsync( + "Write a concise, professional follow-up email in first person. Mention that the candidate applied on the provided date, reference the exact role and company, mention one or two concrete details from the role or fit summary, and close with polite interest in next steps. Keep it specific, warm, and under 140 words. Return only the email body.", + aiContext, + 180, + 70); + + var fallbackBody = string.Join("\n\n", new[] { greeting, - $"I wanted to follow up on my application for the {job.JobTitle} role. I'm still very interested in the opportunity and would love to hear if there are any updates on next steps.", - !string.IsNullOrWhiteSpace(summary) ? $"Quick reminder of fit: {summary}" : null, - $"Context: {reason}", - $"If helpful, I can also provide any additional information related to {reference}.", + $"I wanted to follow up on my application for the {job.JobTitle} role that I submitted on {appliedDate}. I'm still very interested in the opportunity at {companyName}.", + !string.IsNullOrWhiteSpace(summary) + ? $"From the posting and my background, the strongest overlap seems to be {summary.Trim().TrimEnd('.')}." + : tagHighlights.Count > 0 + ? $"The role's focus on {string.Join(", ", tagHighlights.Take(2))} especially stood out to me, and it lines up well with my experience." + : null, + $"I would be glad to share any additional details that would be helpful as you move through next steps for {reference}.", $"Thanks for your time,\n{signerName}" }.Where(x => !string.IsNullOrWhiteSpace(x))); + var body = !string.IsNullOrWhiteSpace(aiBody) ? aiBody.Trim() : fallbackBody; + if (!body.StartsWith("Hi", StringComparison.OrdinalIgnoreCase)) + { + body = string.Join("\n\n", new[] { greeting, body, $"Thanks,\n{signerName}" }.Where(x => !string.IsNullOrWhiteSpace(x))); + } + return Ok(new FollowUpDraftDto(subject, body, reason, DateTime.Today)); } diff --git a/job-tracker-ui/package-lock.json b/job-tracker-ui/package-lock.json index 1a39490..c542a5c 100644 --- a/job-tracker-ui/package-lock.json +++ b/job-tracker-ui/package-lock.json @@ -13,6 +13,8 @@ "@mui/icons-material": "^7.3.9", "@mui/lab": "^7.0.1-beta.23", "@mui/material": "^7.3.9", + "@mui/x-data-grid": "^8.28.0", + "@mui/x-date-pickers": "^8.27.2", "@tanstack/react-table": "^8.21.3", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", @@ -23,6 +25,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "axios": "^1.13.6", + "date-fns": "^4.1.0", "react": "^19.2.4", "react-dom": "^19.2.4", "react-router-dom": "^6.30.3", @@ -3120,6 +3123,150 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==" }, + "node_modules/@mui/x-data-grid": { + "version": "8.28.0", + "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-8.28.0.tgz", + "integrity": "sha512-l1Kc9evMGLaK8Ap8t9r1Lb1zyWHvmA8adSYn8AlcSUaWIZS4Hpjq2uQkSHYfY0seXLYNemRzJBPW1uqRh1niTQ==", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/utils": "^7.3.5", + "@mui/x-internals": "8.26.0", + "@mui/x-virtualizer": "0.3.3", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0", + "@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/x-date-pickers": { + "version": "8.27.2", + "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-8.27.2.tgz", + "integrity": "sha512-06LFkHFRXJ2O9DMXtWAA3kY0jpbL7XH8iqa8L5cBlN+8bRx/UVLKlZYlhGv06C88jF9kuZWY1bUgrv/EoY/2Ww==", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/utils": "^7.3.5", + "@mui/x-internals": "8.26.0", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0", + "@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0", + "date-fns": "^2.25.0 || ^3.2.0 || ^4.0.0", + "date-fns-jalali": "^2.13.0-0 || ^3.2.0-0 || ^4.0.0-0", + "dayjs": "^1.10.7", + "luxon": "^3.0.2", + "moment": "^2.29.4", + "moment-hijri": "^2.1.2 || ^3.0.0", + "moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "date-fns": { + "optional": true + }, + "date-fns-jalali": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + }, + "moment-hijri": { + "optional": true + }, + "moment-jalaali": { + "optional": true + } + } + }, + "node_modules/@mui/x-internals": { + "version": "8.26.0", + "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.26.0.tgz", + "integrity": "sha512-B9OZau5IQUvIxwpJZhoFJKqRpmWf5r0yMmSXjQuqb5WuqM755EuzWJOenY48denGoENzMLT8hQpA0hRTeU2IPA==", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/utils": "^7.3.5", + "reselect": "^5.1.1", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@mui/x-virtualizer": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@mui/x-virtualizer/-/x-virtualizer-0.3.3.tgz", + "integrity": "sha512-6ugUh7UAhQYdgPgHLu181zqufh3Y8IqEU9Pe6Huzj0xkRi3NwMx/ZzvrHf2WazNOh2uLhQ5ZM2wFqDu3mxBWZA==", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/utils": "^7.3.5", + "@mui/x-internals": "8.26.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": { "version": "5.1.1-v1", "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz", @@ -6438,6 +6585,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -13609,6 +13765,11 @@ "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -15659,6 +15820,14 @@ "requires-port": "^1.0.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/job-tracker-ui/package.json b/job-tracker-ui/package.json index 39d7dff..d7fecee 100644 --- a/job-tracker-ui/package.json +++ b/job-tracker-ui/package.json @@ -8,6 +8,8 @@ "@mui/icons-material": "^7.3.9", "@mui/lab": "^7.0.1-beta.23", "@mui/material": "^7.3.9", + "@mui/x-data-grid": "^8.28.0", + "@mui/x-date-pickers": "^8.27.2", "@tanstack/react-table": "^8.21.3", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", @@ -18,6 +20,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "axios": "^1.13.6", + "date-fns": "^4.1.0", "react": "^19.2.4", "react-dom": "^19.2.4", "react-router-dom": "^6.30.3", diff --git a/job-tracker-ui/src/components/AddJobModal.tsx b/job-tracker-ui/src/components/AddJobModal.tsx index 909d2e1..045797c 100644 --- a/job-tracker-ui/src/components/AddJobModal.tsx +++ b/job-tracker-ui/src/components/AddJobModal.tsx @@ -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) { - setDateApplied(e.target.value)} InputLabelProps={{ shrink: true }} /> + setDateApplied(toPickerIso(value))} + slotProps={{ textField: { fullWidth: true } }} + /> setStatus(e.target.value as any)}> {STATUS_OPTIONS.map((s) => ( @@ -462,7 +480,12 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) { setLocation(e.target.value)} /> setSalary(e.target.value)} /> - setDeadline(e.target.value)} InputLabelProps={{ shrink: true }} /> + setDeadline(toPickerIso(value))} + slotProps={{ textField: { fullWidth: true } }} + /> diff --git a/job-tracker-ui/src/components/DashboardView.tsx b/job-tracker-ui/src/components/DashboardView.tsx index 5e4e27f..3839671 100644 --- a/job-tracker-ui/src/components/DashboardView.tsx +++ b/job-tracker-ui/src/components/DashboardView.tsx @@ -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 ( + + + + ); +} + +function SectionCard({ children, sx = {} }: { children: React.ReactNode; sx?: any }) { + return ( + + {children} + + ); } export default function DashboardView() { @@ -111,22 +151,50 @@ export default function DashboardView() { api.get("/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: , + tone: theme.palette.primary.main, + spark: appliedValues, + }, + { + label: t("dashboardApplied30Days"), + value: stats?.appliedLast30Days ?? 0, + sub: t("dashboardNewApplications"), + icon: , + tone: theme.palette.success.main, + spark: appliedValues.slice(-6), + }, + { + label: t("dashboardMedianFirstResponse"), + value: overview?.medianDaysToFirstResponse ?? "—", + sub: t("dashboardDaysUntilFirstReply"), + icon: , + tone: theme.palette.info.main, + spark: responseValues, + }, + { + label: t("dashboardResponsesLogged"), + value: overview?.totalResponses ?? 0, + sub: t("dashboardAcrossActiveJobs"), + icon: , + 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 }) => ( - - {label} - {value} - {sub} - - ); + 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 ( - - - {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} - + + + + + {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} + + ))} + + - + {prefs.cards ? ( - - - {metricCards.map((m, idx) => ( - - + + {metricCards.map((card) => ( + + + + {card.label} + {card.value} + {card.sub} + + + {card.icon} + - ))} - - + + + + + ))} + ) : null} - {prefs.activity ? ( - - {t("dashboardApplicationActivity")} - {t("dashboardMonthlyApplicationsResponses")} - - - - {[0.25, 0.5, 0.75].map((tick) => )} - {responsePath ? : null} - {appliedPath ? : null} - - {analytics.map((p) => {p.month.slice(5)})} + + {prefs.activity ? ( + + + + {t("dashboardApplicationActivity")} + {t("dashboardMonthlyApplicationsResponses")} + + + + + - - - ) : null} - - {prefs.funnel ? ( - - {t("dashboardConversionFunnelTitle")} - - {(overview?.funnel ?? []).map((item) => ( - - {item.label} - - + + + + {[0.2, 0.4, 0.6, 0.8].map((tick) => ( + + ))} + {responsePath ? : null} + {appliedPath ? : null} + + + {analytics.map((point) => ( + + {point.month.slice(5)} + + ))} + + + + + ) : null} + + + {t("dashboardConversionFunnelTitle")} + {t("dashboardResponseSources")} + + {(overview?.funnel ?? []).map((item) => { + const width = funnelMax ? clamp((item.count / funnelMax) * 100, 0, 100) : 0; + return ( + + + {item.label} + {item.count} - {item.count} + - ))} - - {t("dashboardResponseSources")} - - {(overview?.responseRateBySource ?? []).map((item) => ( - - {item.label} - {item.rate}% - - ))} - - - ) : null} + ); + })} + - {prefs.companies ? ( - - {t("dashboardTopCompaniesByActivity")} - - {(overview?.topCompanies ?? []).map((item) => ( - - {item.company} - {item.count} jobs - {item.responseRate}% - - ))} - - - ) : null} + + {topSource?.label ?? t("dashboardResponseSources")} + {topSource ? `${topSource.rate}%` : "—"} + + {topSource ? t("dashboardResponseConversion", { responses: topSource.responses, total: topSource.total }) : t("dashboardNoSourceData")} + + + - {prefs.skills ? ( - - - - {t("dashboardTopSkills")} - {tags.length === 0 ? {t("dashboardNoTagsYet")} : ( - - - - - {(() => { - 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 = ; - offset += len; - return el; - }); - })()} - - {tagTotal} - {t("dashboardSkillTags")} - - - - {tags.slice(0, 8).map((tItem, i) => {tItem.tag}{tItem.count})} + + {prefs.companies ? ( + + {t("dashboardTopCompaniesByActivity")} + + {(overview?.topCompanies ?? []).map((item, index) => ( + + + + {item.company} + {t("dashboardCompanyJobsResponses", { jobs: item.count, responses: item.responses })} + + = 50 ? "success" : item.responseRate >= 25 ? "warning" : "default"} variant="outlined" /> - )} - - - {t("dashboardSkillTrends")} - {!tagTrends || tagTrends.series.length === 0 ? {t("dashboardNoTagTrendData")} : ( - - {tagTrends.series.map((series, idx) => ( - - - {series.tag} - {series.counts.reduce((a, b) => a + b, 0)} total + ))} + + + ) : null} + + {prefs.skills ? ( + + {t("dashboardTopSkills")} + {tags.length === 0 ? ( + {t("dashboardNoTagsYet")} + ) : ( + + {tags.slice(0, 8).map((tag, index) => { + const max = Math.max(...tags.map((item) => item.count), 1); + const width = (tag.count / max) * 100; + return ( + + + {tag.tag} + {tag.count} - - {series.counts.map((count, i) => ( - 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}`} /> - ))} + + - ))} - - {tagTrends.months.map((month) => {month.slice(5)})} + ); + })} + + )} + + {t("dashboardSkillTrends")} + {!tagTrends || tagTrends.series.length === 0 ? ( + {t("dashboardNoTagTrendData")} + ) : ( + + {tagTrends.series.map((series, index) => ( + + + {series.tag} + {series.counts.reduce((sum, value) => sum + value, 0)} total + + + {series.counts.map((count, i) => ( + 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}`} + /> + ))} + - - )} - - - - ) : null} + ))} + + )} + + ) : null} + ); } diff --git a/job-tracker-ui/src/components/EditJobDialog.tsx b/job-tracker-ui/src/components/EditJobDialog.tsx index cc80ab8..f4a748f 100644 --- a/job-tracker-ui/src/components/EditJobDialog.tsx +++ b/job-tracker-ui/src/components/EditJobDialog.tsx @@ -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) c.name} value={company} onChange={(_, v) => setCompany(v)} renderInput={(params) => } /> setJobTitle(e.target.value)} /> - setDateApplied(e.target.value)} InputLabelProps={{ shrink: true }} /> + setDateApplied(toPickerIso(value))} slotProps={{ textField: { fullWidth: true } }} /> setJobUrl(e.target.value)} /> @@ -183,11 +195,11 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props) setStatus(e.target.value)}> {STATUS_OPTIONS.map((s) => {s})} - setStatusChangedAt(e.target.value)} InputLabelProps={{ shrink: true }} helperText={status === initialStatus ? t("editJobStatusChangedHelpIdle") : t("editJobStatusChangedHelpActive")} /> + setStatusChangedAt(toPickerIso(value))} slotProps={{ textField: { fullWidth: true, helperText: status === initialStatus ? t("editJobStatusChangedHelpIdle") : t("editJobStatusChangedHelpActive") } }} /> setResponseReceived(e.target.checked)} />} label={t("editJobReplyReceived")} /> - setResponseDate(e.target.value)} InputLabelProps={{ shrink: true }} /> + setResponseDate(toPickerIso(value))} slotProps={{ textField: { fullWidth: true } }} /> setNextAction(e.target.value)} /> - setFollowUpAt(e.target.value)} InputLabelProps={{ shrink: true }} /> + setFollowUpAt(toPickerIso(value))} slotProps={{ textField: { fullWidth: true } }} /> @@ -196,7 +208,7 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props) setLocation(e.target.value)} /> setSalary(e.target.value)} /> - setDeadline(e.target.value)} InputLabelProps={{ shrink: true }} /> + setDeadline(toPickerIso(value))} slotProps={{ textField: { fullWidth: true } }} /> setDescriptionLanguage(e.target.value)} /> setNotes(e.target.value)} multiline rows={4} helperText={t("correspondenceCharacters", { count: notes.length })} sx={{ gridColumn: "1 / -1" }} /> diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index eb47ca4..feed7f9 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -197,6 +197,14 @@ export const translations = { cropDialogZoom: "Zoom", cropDialogSave: "Save image", dashboardOverviewTitle: "Dashboard overview", + dashboardHeroLabel: "Jobbjakt Analytics", + dashboardResponseRate: "{rate}% response rate", + dashboardMonthsShort: "{count} mo", + dashboardAppliedCount: "{count} applied", + dashboardResponsesCount: "{count} responses", + dashboardResponseConversion: "{responses}/{total} response conversion", + dashboardNoSourceData: "No source data yet.", + dashboardCompanyJobsResponses: "{jobs} jobs · {responses} responses", dashboardOverviewBody: "High-level application activity only. System health and pipeline diagnostics now live in the System page to avoid duplicated or conflicting status data.", dashboardCustomize: "Customize dashboard", dashboardSummaryCards: "Summary cards", @@ -910,6 +918,14 @@ export const translations = { cropDialogZoom: "Zoom", cropDialogSave: "Lagre bilde", dashboardOverviewTitle: "Dashboard-oversikt", + dashboardHeroLabel: "Jobbjakt Analyse", + dashboardResponseRate: "{rate}% svarrate", + dashboardMonthsShort: "{count} md", + dashboardAppliedCount: "{count} søkt", + dashboardResponsesCount: "{count} svar", + dashboardResponseConversion: "{responses}/{total} svar-konvertering", + dashboardNoSourceData: "Ingen kildedata ennå.", + dashboardCompanyJobsResponses: "{jobs} jobber · {responses} svar", dashboardOverviewBody: "Kun overordnet aktivitet for jobbsøking vises her. Systemhelse og pipelinediagnostikk ligger nå på Systemsiden for å unngå dupliserte eller motstridende statusdata.", dashboardCustomize: "Tilpass dashboard", dashboardSummaryCards: "Oppsummeringskort", diff --git a/job-tracker-ui/src/index.tsx b/job-tracker-ui/src/index.tsx index edc3135..841ff19 100644 --- a/job-tracker-ui/src/index.tsx +++ b/job-tracker-ui/src/index.tsx @@ -1,5 +1,7 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; +import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns'; import './index.css'; import App from './App'; import reportWebVitals from './reportWebVitals'; @@ -10,9 +12,11 @@ const root = ReactDOM.createRoot( ); root.render( - - - + + + + + ); diff --git a/job-tracker-ui/src/pages/AdminUsersPage.tsx b/job-tracker-ui/src/pages/AdminUsersPage.tsx index ebcf568..523f85d 100644 --- a/job-tracker-ui/src/pages/AdminUsersPage.tsx +++ b/job-tracker-ui/src/pages/AdminUsersPage.tsx @@ -1,20 +1,16 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Box, Button, Checkbox, + Chip, FormControlLabel, Paper, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, TextField, Typography, } from "@mui/material"; +import { DataGrid, GridColDef } from "@mui/x-data-grid"; import { api } from "../api"; import { useToast } from "../toast"; @@ -58,7 +54,16 @@ export default function AdminUsersPage() { const canCreate = useMemo(() => newEmail.trim().length > 3 && newPassword.length >= 6, [newEmail, newPassword]); - const setAdminRole = async (u: UserDto, isAdmin: boolean) => { + const rows = useMemo(() => users.map((u) => ({ + id: u.id, + email: u.email || "", + userName: u.userName || "", + roles: u.roles || [], + emailConfirmed: u.emailConfirmed, + raw: u, + })), [users]); + + const setAdminRole = useCallback(async (u: UserDto, isAdmin: boolean) => { try { await api.put(`/users/${u.id}/roles`, { roles: isAdmin ? ["Admin"] : [] }); toast(t("adminUsersRolesUpdated"), "success"); @@ -67,9 +72,9 @@ export default function AdminUsersPage() { const msg = e?.response?.data || e?.message || t("adminUsersRolesUpdateFailed"); toast(String(msg), "error"); } - }; + }, [t, toast]); - const sendReset = async (u: UserDto) => { + const sendReset = useCallback(async (u: UserDto) => { try { await api.post(`/users/${u.id}/send-password-reset`); toast(t("adminUsersResetSent"), "success"); @@ -77,9 +82,9 @@ export default function AdminUsersPage() { const msg = e?.response?.data || e?.message || t("adminUsersResetFailed"); toast(String(msg), "error"); } - }; + }, [t, toast]); - const remove = async (u: UserDto) => { + const remove = useCallback(async (u: UserDto) => { const name = u.userName || u.email || u.id; if (!(await confirmAction(t("adminUsersDeleteConfirmNamed", { name }), { title: t("adminUsersDeleteConfirmTitle"), confirmLabel: t("adminUsersDelete"), destructive: true }))) return; try { @@ -89,7 +94,60 @@ export default function AdminUsersPage() { } catch { toast(t("adminUsersDeleteFailed"), "error"); } - }; + }, [confirmAction, t, toast]); + + const columns = useMemo(() => [ + { field: "email", headerName: t("profileEmail"), flex: 1.2, minWidth: 220 }, + { field: "userName", headerName: t("profileUsername"), flex: 1, minWidth: 180 }, + { + field: "roles", + headerName: t("adminUsersRolesLabel"), + flex: 1, + minWidth: 180, + sortable: false, + renderCell: (params) => { + const roles = params.row.roles as string[]; + return ( + + {roles.length ? roles.map((role) => ) : } + + ); + }, + }, + { + field: "emailConfirmed", + headerName: t("adminUsersConfirmed"), + width: 130, + renderCell: (params) => ( + + ), + }, + { + field: "actions", + headerName: t("adminUsersActions"), + minWidth: 300, + flex: 1.4, + sortable: false, + filterable: false, + renderCell: (params) => { + const user = params.row.raw as UserDto; + const isAdmin = (user.roles || []).includes("Admin"); + return ( + + + + + + ); + }, + }, + ], [remove, sendReset, setAdminRole, t]); return ( @@ -128,53 +186,33 @@ export default function AdminUsersPage() { - - - - - {t("profileEmail")} - {t("profileUsername")} - {t("adminUsersRolesLabel")} - {t("adminUsersConfirmed")} - {t("adminUsersActions")} - - - - {users.map((u) => { - const isAdmin = (u.roles || []).includes("Admin"); - return ( - - {u.email || ""} - {u.userName || ""} - {u.roles?.length ? u.roles.join(", ") : "-"} - {u.emailConfirmed ? t("yes") : t("noWord")} - - - - - - - - - ); - })} - - {!loading && users.length === 0 ? ( - - - {t("adminUsersNoUsers")} - - - ) : null} - -
-
+ + + {!loading && rows.length === 0 ? ( + {t("adminUsersNoUsers")} + ) : null} + ); }