diff --git a/JobTrackerApi/Controllers/AdminSystemController.cs b/JobTrackerApi/Controllers/AdminSystemController.cs new file mode 100644 index 0000000..ffe7b0b --- /dev/null +++ b/JobTrackerApi/Controllers/AdminSystemController.cs @@ -0,0 +1,77 @@ +using System.Reflection; +using JobTrackerApi.Data; +using JobTrackerApi.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace JobTrackerApi.Controllers; + +[ApiController] +[Route("api/admin/system")] +[Authorize(Roles = "Admin")] +public sealed class AdminSystemController : ControllerBase +{ + private readonly IConfiguration _cfg; + private readonly AppPaths _paths; + private readonly JobTrackerContext _db; + private readonly ISummarizerService _summarizer; + private readonly IWebHostEnvironment _env; + + public AdminSystemController(IConfiguration cfg, AppPaths paths, JobTrackerContext db, ISummarizerService summarizer, IWebHostEnvironment env) + { + _cfg = cfg; + _paths = paths; + _db = db; + _summarizer = summarizer; + _env = env; + } + + public sealed record StorageStatusDto(string DataRoot, string DbPath, bool DbExists, long? DbSizeBytes, int CompanyCount, int JobCount, int DeletedCount); + public sealed record EmailStatusDto(bool Enabled, string? Host, int Port, bool EnableSsl, string? From, string? FromName); + public sealed record SystemStatusDto( + string Environment, + string ContentRoot, + string Version, + StorageStatusDto Storage, + EmailStatusDto Email, + SummarizerMetrics Summarizer + ); + + [HttpGet] + public async Task> Get(CancellationToken cancellationToken) + { + var dbPath = _paths.GetDbPath(); + var dbFile = new FileInfo(dbPath); + + var jobs = await _db.JobApplications.AsNoTracking().ToListAsync(cancellationToken); + var companies = await _db.Companies.AsNoTracking().CountAsync(cancellationToken); + var summarizer = await _summarizer.GetMetricsAsync(cancellationToken); + + var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown"; + + return Ok(new SystemStatusDto( + Environment: _env.EnvironmentName, + ContentRoot: _env.ContentRootPath, + Version: version, + Storage: new StorageStatusDto( + DataRoot: _paths.DataRoot, + DbPath: dbPath, + DbExists: dbFile.Exists, + DbSizeBytes: dbFile.Exists ? dbFile.Length : null, + CompanyCount: companies, + JobCount: jobs.Count, + DeletedCount: jobs.Count(x => x.IsDeleted) + ), + Email: new EmailStatusDto( + Enabled: _cfg.GetValue("Email:Enabled", false), + Host: (_cfg["Email:SmtpHost"] ?? "").Trim(), + Port: _cfg.GetValue("Email:SmtpPort", 587), + EnableSsl: _cfg.GetValue("Email:SmtpEnableSsl", true), + From: (_cfg["Email:From"] ?? "").Trim(), + FromName: (_cfg["Email:FromName"] ?? "").Trim() + ), + Summarizer: summarizer + )); + } +} diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index 94bb6ab..807912b 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -24,6 +24,50 @@ namespace JobTrackerApi.Controllers private string? CurrentUserId => User?.FindFirstValue(ClaimTypes.NameIdentifier) ?? User?.FindFirstValue("sub"); + private static IEnumerable SplitTags(string? s) + { + if (string.IsNullOrWhiteSpace(s)) yield break; + + var trimmed = s.Trim(); + + List? jsonTags = null; + if (trimmed.StartsWith("[") && trimmed.EndsWith("]")) + { + try + { + jsonTags = JsonSerializer.Deserialize>(trimmed); + } + catch + { + jsonTags = null; + } + } + + if (jsonTags is not null) + { + foreach (var x in jsonTags) + { + var t = (x ?? string.Empty).Trim(); + if (t.Length == 0) continue; + yield return t; + } + yield break; + } + + foreach (var raw in trimmed.Split(new[] { ',', ';', '\n', '\r', '\t' }, StringSplitOptions.RemoveEmptyEntries)) + { + var t = raw.Trim(); + if (t.Length == 0) continue; + yield return t; + } + } + + private static string NormalizeForComparison(string value) + { + if (string.IsNullOrWhiteSpace(value)) return string.Empty; + return new string(value.Trim().ToLowerInvariant().Where(char.IsLetterOrDigit).ToArray()); + } + public sealed record PagedResult(List Items, int Total, int Page, int PageSize); @@ -1016,6 +1060,256 @@ namespace JobTrackerApi.Controllers return Ok(outList); } + public sealed record FunnelStagePoint(string Label, int Count); + public sealed record ResponseRatePoint(string Label, int Total, int Responses, double Rate); + public sealed record CompanyActivityPoint(int CompanyId, string Company, int Count, int Responses, double ResponseRate); + public sealed record TagTrendSeries(string Tag, List Counts); + public sealed record TagTrendPoint(string Month, List Counts); + public sealed record AnalyticsOverviewDto( + List Funnel, + List ResponseRateBySource, + List TopCompanies, + double? MedianDaysToFirstResponse, + int TotalResponses, + int TotalActive + ); + public sealed record DuplicateCandidateDto(int Id, string JobTitle, string Company, string? JobUrl, string Status, DateTime DateApplied, string Reason); + public sealed record DuplicateCheckResult(bool HasDuplicates, List Matches); + public sealed record FollowUpDraftDto(string Subject, string Body, string Reason, DateTime SuggestedSendOn); + public sealed record TagTrendResponse(List Months, List Series); + + [HttpGet("analytics-overview")] + public async Task> GetAnalyticsOverview(CancellationToken cancellationToken) + { + var activeJobs = await _db.JobApplications + .AsNoTracking() + .Include(j => j.Company) + .Where(j => !j.IsDeleted) + .ToListAsync(cancellationToken); + + var funnelMap = new Dictionary + { + ["Applied"] = activeJobs.Count(j => string.Equals(j.Status, "Applied", StringComparison.OrdinalIgnoreCase)), + ["Interview"] = activeJobs.Count(j => string.Equals(j.Status, "Interview", StringComparison.OrdinalIgnoreCase) || string.Equals(j.Status, "Interviewing", StringComparison.OrdinalIgnoreCase)), + ["Offer"] = activeJobs.Count(j => string.Equals(j.Status, "Offer", StringComparison.OrdinalIgnoreCase)), + ["Rejected"] = activeJobs.Count(j => string.Equals(j.Status, "Rejected", StringComparison.OrdinalIgnoreCase)), + ["Ghosted"] = activeJobs.Count(j => string.Equals(j.Status, "Ghosted", StringComparison.OrdinalIgnoreCase)), + }; + + var funnel = funnelMap.Select(x => new FunnelStagePoint(x.Key, x.Value)).ToList(); + + var responseRateBySource = activeJobs + .GroupBy(j => string.IsNullOrWhiteSpace(j.Company?.Source) ? "Unknown source" : j.Company!.Source!.Trim()) + .Select(g => new ResponseRatePoint( + g.Key, + g.Count(), + g.Count(x => x.ResponseReceived || x.ResponseDate is not null), + Math.Round(g.Count(x => x.ResponseReceived || x.ResponseDate is not null) * 100d / Math.Max(1, g.Count()), 1) + )) + .OrderByDescending(x => x.Total) + .ThenByDescending(x => x.Rate) + .Take(6) + .ToList(); + + var topCompanies = activeJobs + .GroupBy(j => new { j.CompanyId, Name = j.Company.Name }) + .Select(g => new CompanyActivityPoint( + g.Key.CompanyId, + g.Key.Name, + g.Count(), + g.Count(x => x.ResponseReceived || x.ResponseDate is not null), + Math.Round(g.Count(x => x.ResponseReceived || x.ResponseDate is not null) * 100d / Math.Max(1, g.Count()), 1) + )) + .OrderByDescending(x => x.Count) + .ThenByDescending(x => x.ResponseRate) + .Take(8) + .ToList(); + + var responseDays = activeJobs + .Where(j => (j.ResponseReceived || j.ResponseDate is not null) && j.ResponseDate is not null) + .Select(j => Math.Max(0, (j.ResponseDate!.Value - j.DateApplied).TotalDays)) + .OrderBy(x => x) + .ToList(); + + double? medianDays = null; + if (responseDays.Count > 0) + { + var mid = responseDays.Count / 2; + medianDays = responseDays.Count % 2 == 0 + ? Math.Round((responseDays[mid - 1] + responseDays[mid]) / 2d, 1) + : Math.Round(responseDays[mid], 1); + } + + return Ok(new AnalyticsOverviewDto( + Funnel: funnel, + ResponseRateBySource: responseRateBySource, + TopCompanies: topCompanies, + MedianDaysToFirstResponse: medianDays, + TotalResponses: activeJobs.Count(j => j.ResponseReceived || j.ResponseDate is not null), + TotalActive: activeJobs.Count + )); + } + + [HttpGet("tag-trends")] + public async Task> GetTagTrends( + [FromQuery] int months = 6, + [FromQuery] int limit = 5, + CancellationToken cancellationToken = default) + { + if (months < 3) months = 3; + if (months > 24) months = 24; + if (limit < 3) limit = 3; + if (limit > 10) limit = 10; + + var endMonth = new DateTime(DateTime.Now.Year, DateTime.Now.Month, 1).AddMonths(1); + var startMonth = endMonth.AddMonths(-months); + + var jobs = await _db.JobApplications + .AsNoTracking() + .Where(j => !j.IsDeleted && j.DateApplied >= startMonth && j.DateApplied < endMonth) + .Select(j => new { j.DateApplied, j.Tags }) + .ToListAsync(cancellationToken); + + var overall = new Dictionary(StringComparer.OrdinalIgnoreCase); + var monthKeys = Enumerable.Range(0, months).Select(i => startMonth.AddMonths(i).ToString("yyyy-MM")).ToList(); + var seriesMap = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var job in jobs) + { + var key = $"{job.DateApplied:yyyy-MM}"; + foreach (var tag in SplitTags(job.Tags)) + { + overall[tag] = (overall.TryGetValue(tag, out var count) ? count : 0) + 1; + if (!seriesMap.TryGetValue(tag, out var byMonth)) + { + byMonth = new Dictionary(StringComparer.Ordinal); + seriesMap[tag] = byMonth; + } + byMonth[key] = (byMonth.TryGetValue(key, out var monthCount) ? monthCount : 0) + 1; + } + } + + var topTags = overall + .OrderByDescending(x => x.Value) + .ThenBy(x => x.Key, StringComparer.OrdinalIgnoreCase) + .Take(limit) + .Select(x => x.Key) + .ToList(); + + var series = topTags + .Select(tag => new TagTrendSeries( + tag, + monthKeys.Select(month => seriesMap.TryGetValue(tag, out var byMonth) && byMonth.TryGetValue(month, out var count) ? count : 0).ToList() + )) + .ToList(); + + return Ok(new TagTrendResponse(monthKeys, series)); + } + + [HttpGet("duplicate-check")] + public async Task> CheckDuplicates( + [FromQuery] int companyId, + [FromQuery] string? jobTitle, + [FromQuery] string? jobUrl, + [FromQuery] int? excludeId, + CancellationToken cancellationToken) + { + var normalizedTitle = NormalizeForComparison(jobTitle ?? string.Empty); + var normalizedUrl = (jobUrl ?? string.Empty).Trim(); + + if (companyId <= 0 && normalizedTitle.Length == 0 && normalizedUrl.Length == 0) + { + return Ok(new DuplicateCheckResult(false, new List())); + } + + var query = _db.JobApplications + .AsNoTracking() + .Include(j => j.Company) + .Where(j => !j.IsDeleted); + + if (excludeId is not null && excludeId.Value > 0) + { + query = query.Where(j => j.Id != excludeId.Value); + } + + var candidates = await query + .OrderByDescending(j => j.DateApplied) + .Take(200) + .ToListAsync(cancellationToken); + + var matches = candidates + .Select(j => + { + var reasons = new List(); + if (!string.IsNullOrWhiteSpace(normalizedUrl) && string.Equals((j.JobUrl ?? string.Empty).Trim(), normalizedUrl, StringComparison.OrdinalIgnoreCase)) + { + reasons.Add("same URL"); + } + + if (companyId > 0 && j.CompanyId == companyId && normalizedTitle.Length > 0) + { + var existingTitle = NormalizeForComparison(j.JobTitle); + if (existingTitle == normalizedTitle || existingTitle.Contains(normalizedTitle) || normalizedTitle.Contains(existingTitle)) + { + reasons.Add("same company and similar title"); + } + } + + return new { Job = j, Reasons = reasons }; + }) + .Where(x => x.Reasons.Count > 0) + .Take(5) + .Select(x => new DuplicateCandidateDto( + x.Job.Id, + x.Job.JobTitle, + x.Job.Company?.Name ?? string.Empty, + x.Job.JobUrl, + x.Job.Status, + x.Job.DateApplied, + string.Join(", ", x.Reasons) + )) + .ToList(); + + return Ok(new DuplicateCheckResult(matches.Count > 0, matches)); + } + + [HttpGet("{id:int}/followup-draft")] + public async Task> GetFollowUpDraft([FromRoute] int id, CancellationToken cancellationToken) + { + var job = await _db.JobApplications + .AsNoTracking() + .Include(j => j.Company) + .FirstOrDefaultAsync(j => j.Id == id, cancellationToken); + + if (job is null) return NotFound(); + + var lastMessage = await _db.Correspondences + .AsNoTracking() + .Where(c => c.JobApplicationId == id) + .OrderByDescending(c => c.Date) + .FirstOrDefaultAsync(cancellationToken); + + var reason = string.IsNullOrWhiteSpace(job.NextAction) + ? (job.FollowUpAt is not null && job.FollowUpAt.Value.Date <= DateTime.Today ? "Scheduled follow-up is due." : "No recent response has been logged.") + : job.NextAction!; + + var subject = $"Following up on {job.JobTitle} application"; + var companyName = job.Company?.Name ?? "your team"; + var reference = lastMessage?.Subject ?? job.JobTitle; + var summary = job.ShortSummary; + var body = string.Join("\n\n", new[] + { + $"Hi {companyName},", + $"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}.", + "Thanks for your time,\n[Your name]" + }.Where(x => !string.IsNullOrWhiteSpace(x))); + + return Ok(new FollowUpDraftDto(subject, body, reason, DateTime.Today)); + } + [HttpGet("summarizer-metrics")] public async Task> GetSummarizerMetrics(CancellationToken cancellationToken) { diff --git a/job-tracker-ui/src/App.tsx b/job-tracker-ui/src/App.tsx index f92303c..8367157 100644 --- a/job-tracker-ui/src/App.tsx +++ b/job-tracker-ui/src/App.tsx @@ -1,9 +1,7 @@ import React, { useEffect, useMemo, useState } from "react"; import { Box, Button, CssBaseline, ToggleButton, ToggleButtonGroup, Typography } from "@mui/material"; - import useMediaQuery from "@mui/material/useMediaQuery"; - import { CssVarsProvider } from "@mui/material/styles"; import DashboardIcon from "@mui/icons-material/Dashboard"; @@ -15,6 +13,8 @@ import SettingsIcon from "@mui/icons-material/Settings"; import AlarmIcon from "@mui/icons-material/Alarm"; import AccountCircleIcon from "@mui/icons-material/AccountCircle"; import ShieldIcon from "@mui/icons-material/Shield"; +import SearchIcon from "@mui/icons-material/Search"; +import MemoryIcon from "@mui/icons-material/Memory"; import { Navigate, Route, Routes, useLocation, useNavigate, createBrowserRouter, RouterProvider } from "react-router-dom"; @@ -28,33 +28,21 @@ import DashboardView from "./components/DashboardView"; import CompaniesTable from "./components/CompaniesTable"; import SettingsView from "./components/SettingsView"; import RemindersView from "./components/RemindersView"; +import QuickCommandDialog from "./components/QuickCommandDialog"; import { I18nProvider, useI18n } from "./i18n/I18nProvider"; import LoginPage from "./pages/LoginPage"; import ProfilePage from "./pages/ProfilePage"; import AdminAuditPage from "./pages/AdminAuditPage"; import AdminUsersPage from "./pages/AdminUsersPage"; +import AdminSystemPage from "./pages/AdminSystemPage"; import ResetPasswordPage from "./pages/ResetPasswordPage"; import { api } from "./api"; import { clearAuthToken, getAuthToken } from "./auth"; import AppShell, { NavItem } from "./layout/AppShell"; -import { - clearAccentColor, - getAccentColor, - getThemeModePref, - setAccentColor, - setThemeModePref, - ThemeModePref, -} from "./themePrefs"; +import { clearAccentColor, getAccentColor, getThemeModePref, setAccentColor, setThemeModePref, ThemeModePref } from "./themePrefs"; type AuthConfig = { requireAuth: boolean }; - -type MeResponse = { - provider?: "local" | "google" | "external"; - id?: string; - email?: string; - userName?: string; - roles?: string[]; -}; +type MeResponse = { provider?: "local" | "google" | "external"; id?: string; email?: string; userName?: string; roles?: string[] }; function breadcrumbsFor(path: string, t: (k: any) => string): string[] { if (path.startsWith("/dashboard")) return ["Home", "Analytics", "Overview"]; @@ -67,6 +55,7 @@ function breadcrumbsFor(path: string, t: (k: any) => string): string[] { if (path.startsWith("/profile")) return ["Home", "Account", "Profile"]; if (path.startsWith("/admin/audit")) return ["Home", "Admin", "Audit"]; if (path.startsWith("/admin/users")) return ["Home", "Admin", "Users"]; + if (path.startsWith("/admin/system")) return ["Home", "Admin", "System"]; return ["Home"]; } @@ -79,36 +68,19 @@ function titleFor(path: string, t: (k: any) => string): string { if (path.startsWith("/trash")) return t("trash"); if (path.startsWith("/settings")) return t("settings"); if (path.startsWith("/profile")) return "Profile"; - if (path.startsWith("/admin/audit")) return "Audit log"; if (path.startsWith("/admin/users")) return "Users"; + if (path.startsWith("/admin/audit")) return "Audit log"; + if (path.startsWith("/admin/users")) return "Users"; + if (path.startsWith("/admin/system")) return "System status"; return t("appTitle"); } -function Shell({ - jobPageSize, - setJobPageSize, - jobColumns, - setJobColumns, - themeMode, - onThemeModeChange, - accentColor, - onAccentColorChange, - onResetAccentColor, -}: { - jobPageSize: 15 | 20 | 25; - setJobPageSize: (n: 15 | 20 | 25) => void; - jobColumns: JobTableColumns; - setJobColumns: (c: JobTableColumns) => void; - themeMode: ThemeModePref; - onThemeModeChange: (v: ThemeModePref) => void; - accentColor: string; - onAccentColorChange: (v: string) => void; - onResetAccentColor: () => void; -}) { +function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMode, onThemeModeChange, accentColor, onAccentColorChange, onResetAccentColor }: { jobPageSize: 15 | 20 | 25; setJobPageSize: (n: 15 | 20 | 25) => void; jobColumns: JobTableColumns; setJobColumns: (c: JobTableColumns) => void; themeMode: ThemeModePref; onThemeModeChange: (v: ThemeModePref) => void; accentColor: string; onAccentColorChange: (v: string) => void; onResetAccentColor: () => void; }) { const location = useLocation(); const navigate = useNavigate(); const { language, setLanguage, t } = useI18n(); const [addOpen, setAddOpen] = useState(false); + const [quickOpen, setQuickOpen] = useState(false); const [refreshToken, setRefreshToken] = useState(0); const [requireAuth, setRequireAuth] = useState(null); const [isAdmin, setIsAdmin] = useState(false); @@ -120,62 +92,38 @@ function Shell({ const isJobs = path.startsWith("/jobs"); useEffect(() => { - api - .get("/auth/config") - .then((r) => setRequireAuth(Boolean(r.data?.requireAuth))) - .catch(() => setRequireAuth(false)); + api.get("/auth/config").then((r) => setRequireAuth(Boolean(r.data?.requireAuth))).catch(() => setRequireAuth(false)); }, []); useEffect(() => { - api - .get("/auth/me") - .then((r) => { - setMe(r.data); - setIsAdmin(Boolean(r.data?.roles?.includes("Admin"))); - }) - .catch(() => { - setMe(null); - setIsAdmin(false); - }); + api.get("/auth/me").then((r) => { setMe(r.data); setIsAdmin(Boolean(r.data?.roles?.includes("Admin"))); }).catch(() => { setMe(null); setIsAdmin(false); }); }, []); - useEffect(() => { const load = () => { - api - .get("/jobapplications/reminders", { params: { upcomingDays: 14 } }) - .then((r) => setNotifCount(Array.isArray(r.data) ? r.data.length : 0)) - .catch(() => setNotifCount(0)); + api.get("/jobapplications/reminders", { params: { upcomingDays: 14 } }).then((r) => setNotifCount(Array.isArray(r.data) ? r.data.length : 0)).catch(() => setNotifCount(0)); }; - load(); const id = window.setInterval(load, 60000); return () => window.clearInterval(id); }, []); + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") { + e.preventDefault(); + setQuickOpen(true); + } + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, []); const token = getAuthToken(); - if (requireAuth === null) { - return ( - - Loading... - - ); - } - - if (requireAuth && !token) { - return ; - } + if (requireAuth === null) return Loading...; + if (requireAuth && !token) return ; const pageTitle = titleFor(path, t); const breadcrumbs = breadcrumbsFor(path, t); - - const setAndPersistPageSize = (n: 15 | 20 | 25) => { - setJobPageSize(n); - window.localStorage.setItem("jobPageSize", String(n)); - }; - - const setAndPersistColumns = (next: JobTableColumns) => { - setJobColumns(next); - window.localStorage.setItem("jobColumns", JSON.stringify(next)); - }; + const setAndPersistPageSize = (n: 15 | 20 | 25) => { setJobPageSize(n); window.localStorage.setItem("jobPageSize", String(n)); }; + const setAndPersistColumns = (next: JobTableColumns) => { setJobColumns(next); window.localStorage.setItem("jobColumns", JSON.stringify(next)); }; const nav: NavItem[] = [ { to: "/dashboard", label: t("dashboard"), icon: , section: "Manage" }, @@ -187,23 +135,21 @@ function Shell({ ]; const navBottom: NavItem[] = [ - { to: "/admin/audit", label: "Audit log", icon: , hidden: !isAdmin, section: "Admin" }, { to: "/admin/users", label: "Users", icon: , hidden: !isAdmin, section: "Admin" }, + { to: "/admin/audit", label: "Audit log", icon: , hidden: !isAdmin, section: "Admin" }, + { to: "/admin/users", label: "Users", icon: , hidden: !isAdmin, section: "Admin" }, + { to: "/admin/system", label: "System", icon: , hidden: !isAdmin, section: "Admin" }, { to: "/profile", label: "Profile", icon: , section: "Account" }, { to: "/settings", label: t("settings"), icon: , section: "Account" }, ]; const rightActions = ( + v && setLanguage(v)}> EN NO - - {isJobs ? ( - - ) : null} + {isJobs ? : null} ); @@ -217,172 +163,76 @@ function Shell({ navBottom={navBottom} drawerOpen={mobileDrawerOpen} onToggleDrawer={setMobileDrawerOpen} - onNavigate={(to) => { - setMobileDrawerOpen(false); - navigate(to); - }} - user={{ - email: me?.email, - userName: me?.userName, - roleLabel: isAdmin ? "Super Admin" : "User", - }} - notificationsCount={notifCount} onOpenNotifications={() => navigate("/reminders")} onOpenSettings={() => navigate("/settings")} onOpenProfile={() => navigate("/profile")} onSignOut={() => { clearAuthToken(); navigate("/login"); }} rightActions={rightActions} + onNavigate={(to) => { setMobileDrawerOpen(false); navigate(to); }} + user={{ email: me?.email, userName: me?.userName, roleLabel: isAdmin ? "Super Admin" : "User" }} + notificationsCount={notifCount} + onOpenNotifications={() => navigate("/reminders")} + onOpenSettings={() => navigate("/settings")} + onOpenProfile={() => navigate("/profile")} + onSignOut={() => { clearAuthToken(); navigate("/login"); }} + rightActions={rightActions} > } /> } /> - - } - /> + } /> } /> } /> } /> } /> - } /> } /> - - } - /> - - } - /> + } /> + } /> + } /> + } /> + } /> } /> - setAddOpen(false)} - onCreated={() => { - setRefreshToken((t) => t + 1); - setAddOpen(false); - }} - /> + setAddOpen(false)} onCreated={() => { setRefreshToken((t) => t + 1); }} /> + setQuickOpen(false)} onNavigate={(to) => navigate(to)} onOpenAddJob={() => setAddOpen(true)} /> ); } export default function App() { const systemPrefersDark = useMediaQuery("(prefers-color-scheme: dark)", { defaultMatches: true, noSsr: true }); - const [themeMode, setThemeMode] = useState(() => getThemeModePref()); const [accentColor, setAccentColorState] = useState(() => getAccentColor()); - - const effectiveMode: "light" | "dark" = - themeMode === "light" ? "light" : themeMode === "dark" ? "dark" : systemPrefersDark ? "dark" : "light"; - + const effectiveMode: "light" | "dark" = themeMode === "light" ? "light" : themeMode === "dark" ? "dark" : systemPrefersDark ? "dark" : "light"; const theme = useMemo(() => getTheme(effectiveMode, accentColor), [effectiveMode, accentColor]); - useEffect(() => { - const sync = () => { - setThemeMode(getThemeModePref()); - setAccentColorState(getAccentColor()); - }; - const onAuthChanged = () => sync(); - window.addEventListener("auth-changed", onAuthChanged); - return () => window.removeEventListener("auth-changed", onAuthChanged); + useEffect(() => { + const sync = () => { setThemeMode(getThemeModePref()); setAccentColorState(getAccentColor()); }; + window.addEventListener("auth-changed", sync); + return () => window.removeEventListener("auth-changed", sync); }, []); - const onThemeModeChange = (v: ThemeModePref) => { - setThemeModePref(v); - setThemeMode(v); - }; - - const onAccentColorChange = (v: string) => { - setAccentColor(v); - setAccentColorState(getAccentColor()); - }; - - const onResetAccentColor = () => { - clearAccentColor(); - setAccentColorState(getAccentColor()); - }; + const onThemeModeChange = (v: ThemeModePref) => { setThemeModePref(v); setThemeMode(v); }; + const onAccentColorChange = (v: string) => { setAccentColor(v); setAccentColorState(getAccentColor()); }; + const onResetAccentColor = () => { clearAccentColor(); setAccentColorState(getAccentColor()); }; const [jobPageSize, setJobPageSize] = useState<15 | 20 | 25>(() => { const raw = window.localStorage.getItem("jobPageSize"); const n = raw ? Number(raw) : 15; return (n === 20 || n === 25 ? n : 15) as 15 | 20 | 25; }); - const [jobColumns, setJobColumns] = useState(() => { const raw = window.localStorage.getItem("jobColumns"); if (!raw) return { status: true, dateApplied: true, daysSince: true, jobUrl: false }; try { const p = JSON.parse(raw) as Partial; - return { - status: p.status ?? true, - dateApplied: p.dateApplied ?? true, - daysSince: p.daysSince ?? true, - jobUrl: p.jobUrl ?? false, - }; + return { status: p.status ?? true, dateApplied: p.dateApplied ?? true, daysSince: p.daysSince ?? true, jobUrl: p.jobUrl ?? false }; } catch { return { status: true, dateApplied: true, daysSince: true, jobUrl: false }; } }); - const router = useMemo( - () => - createBrowserRouter( - [ - { path: "/login", element: }, - { path: "/reset-password", element: }, - { - path: "/*", - element: ( - - ), - }, - ], - { - future: { - v7_relativeSplatPath: true, - }, - }, - ), - [jobColumns, jobPageSize, themeMode, accentColor], - ); + const router = useMemo(() => createBrowserRouter([ + { path: "/login", element: }, + { path: "/reset-password", element: }, + { path: "/*", element: }, + ], { future: { v7_relativeSplatPath: true } }), [jobColumns, jobPageSize, themeMode, accentColor]); return ( diff --git a/job-tracker-ui/src/components/AddJobModal.tsx b/job-tracker-ui/src/components/AddJobModal.tsx index a7a9657..c675e9e 100644 --- a/job-tracker-ui/src/components/AddJobModal.tsx +++ b/job-tracker-ui/src/components/AddJobModal.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useMemo, useState } from "react"; import { + Alert, Autocomplete, Box, Button, @@ -10,6 +11,9 @@ import { DialogTitle, Divider, FormControlLabel, + List, + ListItem, + ListItemText, MenuItem, TextField, Typography, @@ -28,6 +32,21 @@ interface Props { onCreated: () => void; } +type DuplicateCandidate = { + id: number; + jobTitle: string; + company: string; + jobUrl?: string | null; + status: string; + dateApplied: string; + reason: string; +}; + +type DuplicateCheckResult = { + hasDuplicates: boolean; + matches: DuplicateCandidate[]; +}; + const STATUS_OPTIONS = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const; function getTodayIso() { @@ -40,6 +59,8 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) { const [saving, setSaving] = useState(false); const [importing, setImporting] = useState(false); + const [saveAndAddAnother, setSaveAndAddAnother] = useState(false); + const [duplicateCheck, setDuplicateCheck] = useState(null); const { companies: cachedCompanies } = useCompanies(); const [companies, setCompanies] = useState([]); @@ -101,6 +122,7 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) { setHasCoverLetter(false); setHasPortfolio(false); setHasOtherAttachment(false); + setDuplicateCheck(null); }; const normalizedCompanyName = companyInput.trim(); @@ -109,8 +131,39 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) { return companies.find((c) => c.name.toLowerCase() === normalizedCompanyName.toLowerCase()) ?? null; }, [companies, normalizedCompanyName]); + const selectedCompanyId = company?.id ?? matchingCompany?.id ?? 0; const showNewCompanyFields = !company && !!normalizedCompanyName && !matchingCompany; + useEffect(() => { + if (!open) return; + + const title = jobTitle.trim(); + const url = jobUrl.trim(); + if (!selectedCompanyId && !url) { + setDuplicateCheck(null); + return; + } + if (!title && !url) { + setDuplicateCheck(null); + return; + } + + const timeout = window.setTimeout(() => { + api + .get("/jobapplications/duplicate-check", { + params: { + companyId: selectedCompanyId || undefined, + jobTitle: title || undefined, + jobUrl: url || undefined, + }, + }) + .then((r) => setDuplicateCheck(r.data)) + .catch(() => setDuplicateCheck(null)); + }, 350); + + return () => window.clearTimeout(timeout); + }, [open, selectedCompanyId, jobTitle, jobUrl]); + const createCompany = async (): Promise => { if (!normalizedCompanyName) return null; @@ -209,10 +262,12 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) { hasOtherAttachment, }); - resetForm(); onCreated(); - onClose(); toast("Job added.", "success"); + resetForm(); + if (!saveAndAddAnother) { + onClose(); + } } catch { toast("Failed to add job.", "error"); } finally { @@ -258,16 +313,8 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) { {showNewCompanyFields ? ( - setNewCompanyLocation(e.target.value)} - /> - setNewCompanySource(e.target.value)} - /> + setNewCompanyLocation(e.target.value)} /> + setNewCompanySource(e.target.value)} /> - setDateApplied(e.target.value)} - InputLabelProps={{ shrink: true }} - /> + setDateApplied(e.target.value)} InputLabelProps={{ shrink: true }} /> setStatus(e.target.value as any)}> {STATUS_OPTIONS.map((s) => ( @@ -317,94 +369,41 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) { setSalary(e.target.value)} /> setNextAction(e.target.value)} /> - setFollowUpAt(e.target.value)} - InputLabelProps={{ shrink: true }} - /> + setFollowUpAt(e.target.value)} InputLabelProps={{ shrink: true }} /> - setDeadline(e.target.value)} - InputLabelProps={{ shrink: true }} - /> + setDeadline(e.target.value)} InputLabelProps={{ shrink: true }} /> - setDescription(e.target.value)} - sx={{ gridColumn: "1 / -1" }} - /> - - setTranslatedDescription(e.target.value)} - sx={{ gridColumn: "1 / -1" }} - /> - - setDescriptionLanguage(e.target.value)} - sx={{ gridColumn: "1 / -1" }} - /> - - setNotes(e.target.value)} - sx={{ gridColumn: "1 / -1" }} - /> - - setCoverLetter(e.target.value)} - sx={{ gridColumn: "1 / -1" }} - /> + setDescription(e.target.value)} sx={{ gridColumn: "1 / -1" }} /> + setTranslatedDescription(e.target.value)} sx={{ gridColumn: "1 / -1" }} /> + setDescriptionLanguage(e.target.value)} sx={{ gridColumn: "1 / -1" }} /> + setNotes(e.target.value)} sx={{ gridColumn: "1 / -1" }} /> + setCoverLetter(e.target.value)} sx={{ gridColumn: "1 / -1" }} /> - - Attachments checklist - + Attachments checklist setHasResume(e.target.checked)} />} label="Resume" /> - setHasCoverLetter(e.target.checked)} />} - label="Cover letter" - /> - setHasPortfolio(e.target.checked)} />} - label="Portfolio" - /> - setHasOtherAttachment(e.target.checked)} />} - label="Other" - /> + setHasCoverLetter(e.target.checked)} />} label="Cover letter" /> + setHasPortfolio(e.target.checked)} />} label="Portfolio" /> + setHasOtherAttachment(e.target.checked)} />} label="Other" /> - - + + setSaveAndAddAnother(e.target.checked)} />} + label="Save and add another" + /> + + + + diff --git a/job-tracker-ui/src/components/DashboardView.tsx b/job-tracker-ui/src/components/DashboardView.tsx index 812b669..c7f6c00 100644 --- a/job-tracker-ui/src/components/DashboardView.tsx +++ b/job-tracker-ui/src/components/DashboardView.tsx @@ -1,9 +1,23 @@ import React, { useEffect, useMemo, useState } from "react"; -import { Box, Button, ButtonGroup, Divider, Paper, Tab, Tabs, TextField, Typography } from "@mui/material"; +import { + Box, + Button, + ButtonGroup, + Checkbox, + Menu, + MenuItem, + Paper, + Tab, + Tabs, + TextField, + Typography, +} from "@mui/material"; import { alpha, useTheme } from "@mui/material/styles"; +import TuneIcon from "@mui/icons-material/Tune"; import { api } from "../api"; +import { getUserKeyFromToken } from "../themePrefs"; interface JobStats { total: number; @@ -29,6 +43,41 @@ type SummarizerMetrics = { lastFailureAt?: string | null; lastError?: string | null; }; +type OverviewAnalytics = { + funnel: { label: string; count: number }[]; + responseRateBySource: { label: string; total: number; responses: number; rate: number }[]; + topCompanies: { companyId: number; company: string; count: number; responses: number; responseRate: number }[]; + medianDaysToFirstResponse?: number | null; + totalResponses: number; + totalActive: number; +}; +type TagTrendResponse = { months: string[]; series: { tag: string; counts: number[] }[] }; + +type Prefs = { + cards: boolean; + activity: boolean; + funnel: boolean; + companies: boolean; + skills: boolean; +}; + +function prefsKey() { + return `dashboardPrefs:${getUserKeyFromToken()}`; +} + +function loadPrefs(): Prefs { + try { + const raw = window.localStorage.getItem(prefsKey()); + if (!raw) return { cards: true, activity: true, funnel: true, companies: true, skills: true }; + return { cards: true, activity: true, funnel: true, companies: true, skills: true, ...JSON.parse(raw) }; + } catch { + return { cards: true, activity: true, funnel: true, companies: true, skills: true }; + } +} + +function savePrefs(next: Prefs) { + window.localStorage.setItem(prefsKey(), JSON.stringify(next)); +} function clamp(n: number, a: number, b: number) { return Math.max(a, Math.min(b, n)); @@ -43,68 +92,55 @@ function toPath(values: number[], w: number, h: number) { const t = max === min ? 0.5 : (v - min) / (max - min); return h - t * h; }; - return values - .map((v, i) => `${i === 0 ? "M" : "L"} ${Math.round(i * dx)} ${Math.round(norm(v))}`) - .join(" "); + return values.map((v, i) => `${i === 0 ? "M" : "L"} ${Math.round(i * dx)} ${Math.round(norm(v))}`).join(" "); } function formatRelative(ts?: string | null) { if (!ts) return "Never"; const d = new Date(ts); if (Number.isNaN(d.getTime())) return "Unknown"; - const deltaMs = Date.now() - d.getTime(); - const mins = Math.round(deltaMs / 60000); + const mins = Math.round((Date.now() - d.getTime()) / 60000); if (mins < 1) return "Just now"; if (mins < 60) return `${mins}m ago`; const hours = Math.round(mins / 60); if (hours < 24) return `${hours}h ago`; - const days = Math.round(hours / 24); - return `${days}d ago`; + return `${Math.round(hours / 24)}d ago`; } export default function DashboardView() { const theme = useTheme(); const [stats, setStats] = useState(null); + const [overview, setOverview] = useState(null); + const [tagTrends, setTagTrends] = useState(null); const [tab, setTab] = useState(0); const [rangeMode, setRangeMode] = useState<"preset" | "custom">("preset"); const [months, setMonths] = useState<6 | 12 | 24>(12); - const [fromMonth, setFromMonth] = useState(() => { - const now = new Date(); - const start = new Date(now.getFullYear(), now.getMonth() - 11, 1); - return start.toISOString().slice(0, 7); - }); + const [fromMonth, setFromMonth] = useState(() => new Date(new Date().getFullYear(), new Date().getMonth() - 11, 1).toISOString().slice(0, 7)); const [toMonth, setToMonth] = useState(() => new Date().toISOString().slice(0, 7)); const [appliedCustom, setAppliedCustom] = useState<{ from: string; to: string } | null>(null); const [analytics, setAnalytics] = useState([]); const [tags, setTags] = useState([]); const [summarizerMetrics, setSummarizerMetrics] = useState(null); + const [prefs, setPrefs] = useState(() => loadPrefs()); + const [prefsAnchor, setPrefsAnchor] = useState(null); useEffect(() => { api.get("/jobapplications/stats").then((r) => setStats(r.data)); + api.get("/jobapplications/analytics-overview").then((r) => setOverview(r.data)).catch(() => setOverview(null)); }, []); useEffect(() => { - const params = - rangeMode === "custom" && appliedCustom - ? { from: `${appliedCustom.from}-01`, to: `${appliedCustom.to}-01` } - : { months }; + const params = rangeMode === "custom" && appliedCustom ? { from: `${appliedCustom.from}-01`, to: `${appliedCustom.to}-01` } : { months }; - api - .get("/jobapplications/analytics", { params }) - .then((r) => setAnalytics(r.data ?? [])) - .catch(() => setAnalytics([])); - - api - .get("/jobapplications/tags", { params: { limit: 10, ...params } }) - .then((r) => setTags(r.data ?? [])) - .catch(() => setTags([])); + api.get("/jobapplications/analytics", { params }).then((r) => setAnalytics(r.data ?? [])).catch(() => setAnalytics([])); + api.get("/jobapplications/tags", { params: { limit: 10, ...params } }).then((r) => setTags(r.data ?? [])).catch(() => setTags([])); + api.get("/jobapplications/tag-trends", { params: { months: rangeMode === "custom" ? 6 : months, limit: 5 } }).then((r) => setTagTrends(r.data)).catch(() => setTagTrends(null)); }, [months, rangeMode, appliedCustom]); useEffect(() => { + if (tab !== 2) return; let cancelled = false; - let intervalId: number | undefined; - - const loadMetrics = async () => { + const load = async () => { try { const res = await api.get("/jobapplications/summarizer-metrics"); if (!cancelled) setSummarizerMetrics(res.data); @@ -112,117 +148,45 @@ export default function DashboardView() { if (!cancelled) setSummarizerMetrics(null); } }; - - if (tab === 2) { - void loadMetrics(); - intervalId = window.setInterval(() => { - void loadMetrics(); - }, 30000); - } - + void load(); + const id = window.setInterval(() => void load(), 30000); return () => { cancelled = true; - if (intervalId) window.clearInterval(intervalId); + window.clearInterval(id); }; }, [tab]); - const statusRows = useMemo(() => { - const by = stats?.byStatus ?? {}; - return Object.entries(by).sort((a, b) => b[1] - a[1]); - }, [stats]); - + const statusRows = useMemo(() => Object.entries(stats?.byStatus ?? {}).sort((a, b) => b[1] - a[1]), [stats]); const maxStatus = statusRows.length ? Math.max(...statusRows.map(([, v]) => v)) : 0; 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 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 appliedSeries = useMemo(() => analytics.map((x) => x.applied), [analytics]); - const responseSeries = useMemo(() => analytics.map((x) => x.responses), [analytics]); - const totalAppliedInRange = useMemo(() => appliedSeries.reduce((sum, value) => sum + value, 0), [appliedSeries]); - const totalResponsesInRange = useMemo(() => responseSeries.reduce((sum, value) => sum + value, 0), [responseSeries]); - const topSkill = tags[0]; - const topStatus = statusRows[0]; - const strongestMonth = useMemo(() => { - if (!analytics.length) return null; - return analytics.reduce((best, current) => (current.applied > best.applied ? current : best), analytics[0]); - }, [analytics]); + const metricCards = [ + { label: "Active applications", value: stats?.active ?? "-", sub: "Currently in progress" }, + { label: "Applied (30 days)", value: stats?.appliedLast30Days ?? "-", sub: "New applications" }, + { label: "Median first response", value: overview?.medianDaysToFirstResponse ?? "-", sub: "Days until first reply" }, + { label: "Responses logged", value: overview?.totalResponses ?? 0, sub: "Across active jobs" }, + { label: "In trash", value: stats?.deleted ?? "-", sub: "Soft-deleted" }, + ]; - const metricCards = useMemo(() => { - return [ - { - label: "Active applications", - value: stats?.active ?? "-", - sub: "Currently in progress", - }, - { - label: "Applied (30 days)", - value: stats?.appliedLast30Days ?? "-", - sub: "New applications", - }, - { - label: "Average days", - value: stats?.averageDaysSinceApplied ?? "-", - sub: "Since applied", - }, - { - label: "Responses logged", - value: totalResponsesInRange, - sub: "Current chart range", - }, - { - label: "In trash", - value: stats?.deleted ?? "-", - sub: "Soft-deleted", - }, - ]; - }, [stats, totalResponsesInRange]); - - const pApplied = useMemo(() => toPath(appliedSeries, chartW, chartH), [appliedSeries]); - const pResponses = useMemo(() => toPath(responseSeries, chartW, chartH), [responseSeries]); - - const appliedColor = theme.palette.success.main; - const responsesColor = theme.palette.info.main; - - const tagColors = useMemo(() => { - return [ - theme.palette.primary.main, - theme.palette.success.main, - theme.palette.warning.main, - theme.palette.info.main, - theme.palette.error.main, - alpha("#f97316", 0.9), - alpha("#14b8a6", 0.9), - alpha("#a855f7", 0.9), - alpha("#64748b", 0.9), - alpha("#0ea5e9", 0.9), - ]; - }, [theme.palette]); - - const tagTotal = useMemo(() => tags.reduce((acc, t) => acc + (t.count || 0), 0), [tags]); - const hitRate = useMemo(() => { - const requests = summarizerMetrics?.requests ?? 0; - if (!requests) return null; - return Math.round(((summarizerMetrics?.cacheHits ?? 0) / requests) * 100); - }, [summarizerMetrics]); + const togglePref = (key: keyof Prefs) => { + const next = { ...prefs, [key]: !prefs[key] }; + setPrefs(next); + savePrefs(next); + }; const StatCard = ({ label, value, sub }: { label: string; value: React.ReactNode; sub: string }) => ( - - - {label} - - - {value} - - - {sub} - + + {label} + {value} + {sub} ); @@ -234,167 +198,179 @@ export default function DashboardView() { + {tab !== 2 ? ( + + + setPrefsAnchor(null)}> + {[ + ["cards", "Summary cards"], + ["activity", "Activity chart"], + ["funnel", "Conversion funnel"], + ["companies", "Top companies"], + ["skills", "Skills insights"], + ].map(([key, label]) => ( + togglePref(key as keyof Prefs)}> + + {label} + + ))} + + + ) : null} + {tab === 0 ? ( <> - - - {metricCards.map((m, idx) => ( - - - - ))} - - - - - - - - Application activity - - - Color-coded monthly trend for applications and responses. - - - - - - {([6, 12, 24] as const).map((m) => ( - - ))} - - - - {rangeMode === "custom" ? ( - - setFromMonth(e.target.value)} sx={{ width: 150 }} /> - setToMonth(e.target.value)} sx={{ width: 150 }} /> - + {prefs.cards ? ( + + + {metricCards.map((m, idx) => ( + + - ) : null} + ))} - + + ) : null} - - - - Applied - - - - Responses - - - - - - - - - - - - - - - - - - {[0.25, 0.5, 0.75].map((tick) => ( - - ))} - - {pResponses ? ( + {prefs.activity ? ( + + + + Application activity + Monthly applications versus responses. + + + + {([6, 12, 24] as const).map((m) => ( + + ))} + + + {rangeMode === "custom" ? ( <> - - + setFromMonth(e.target.value)} /> + setToMonth(e.target.value)} /> + ) : null} - - {pApplied ? ( - <> - - - - ) : null} - - - - {analytics.map((p) => ( - - {p.month.slice(5)} - - ))} - - + + + + {[0.25, 0.5, 0.75].map((tick) => )} + {responsePath ? : null} + {appliedPath ? : null} + + {analytics.map((p) => {p.month.slice(5)})} + + + + ) : null} - - - Top skill - {topSkill?.tag ?? "No tags yet"} - - {topSkill ? `${topSkill.count} tagged jobs in the selected range.` : "Import or add tagged jobs to surface skill trends."} - - - - Busiest month - {strongestMonth?.month ?? "-"} - - {strongestMonth ? `${strongestMonth.applied} applications logged in your strongest month.` : "No monthly application data yet."} - - - - Top stage - {topStatus?.[0] ?? "No status data"} - - {topStatus ? `${topStatus[1]} applications are currently clustered in this stage.` : "Add a few jobs to reveal your pipeline shape."} - - + + {prefs.funnel ? ( + + Conversion funnel + + {(overview?.funnel ?? []).map((item) => ( + + {item.label} + + + + {item.count} + + ))} + + Response sources + + {(overview?.responseRateBySource ?? []).map((item) => ( + + {item.label} + {item.rate}% + + ))} + + + ) : null} + + {prefs.companies ? ( + + Top companies by activity + + {(overview?.topCompanies ?? []).map((item) => ( + + {item.company} + {item.count} jobs + {item.responseRate}% + + ))} + + + ) : null} + + {prefs.skills ? ( + + + + Top skills + {tags.length === 0 ? No tags yet. : ( + + + + + {(() => { + const r = 52; + const circ = 2 * Math.PI * r; + let offset = 0; + return tags.map((t, i) => { + const len = circ * (tagTotal ? t.count / tagTotal : 0); + const el = ; + offset += len; + return el; + }); + })()} + + {tagTotal} + skill tags + + + + {tags.slice(0, 8).map((t, i) => {t.tag}{t.count})} + + + )} + + + Skill trends + {!tagTrends || tagTrends.series.length === 0 ? No tag trend data yet. : ( + + {tagTrends.series.map((series, idx) => ( + + + {series.tag} + {series.counts.reduce((a, b) => a + b, 0)} total + + + {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)})} + + + )} + + + + ) : null} ) : null} @@ -402,214 +378,48 @@ export default function DashboardView() { - - Status breakdown - - - {statusRows.length === 0 ? ( - No data yet. - ) : ( + Status breakdown + {statusRows.length === 0 ? No data yet. : ( {statusRows.map(([status, value]) => { - const tone = - status === "Rejected" - ? theme.palette.error.main - : status === "Waiting" || status === "Ghosted" - ? theme.palette.warning.main - : status === "Offer" - ? theme.palette.success.main - : status === "Interview" - ? theme.palette.info.main - : theme.palette.primary.main; - + const tone = status === "Rejected" ? theme.palette.error.main : status === "Waiting" || status === "Ghosted" ? theme.palette.warning.main : status === "Offer" ? theme.palette.success.main : status === "Interview" ? theme.palette.info.main : theme.palette.primary.main; const w = maxStatus ? clamp(Math.round((value / maxStatus) * 100), 0, 100) : 0; - - return ( - - {status} - - - - {value} - - ); + return {status}{value}; })} )} - - - - Applications in range - {totalAppliedInRange} - - - Tracked skills - {tagTotal} - - - - - Top skills - - - {tags.length === 0 ? ( - No tags yet. - ) : ( - - - - - {(() => { - const r = 52; - const circ = 2 * Math.PI * r; - let offset = 0; - return tags.map((t, i) => { - const pct = tagTotal ? t.count / tagTotal : 0; - const len = circ * pct; - const el = ( - - ); - offset += len; - return el; - }); - })()} - - - {tagTotal} - - - skill tags - - + Response rate by source + + {(overview?.responseRateBySource ?? []).map((item) => ( + + {item.label} + {item.responses} responses from {item.total} jobs + {item.rate}% - - - {tags.slice(0, 8).map((t, i) => ( - - - - - {t.tag} - - - - {t.count} - - - ))} - - - )} + ))} + ) : null} {tab === 2 ? ( - <> - - - {[ - { - label: "Service status", - value: summarizerMetrics?.healthy ? "Healthy" : "Offline", - sub: summarizerMetrics?.model || "Summarizer health check", - }, - { - label: "Health latency", - value: summarizerMetrics?.healthLatencyMs != null ? `${summarizerMetrics.healthLatencyMs} ms` : "-", - sub: "Latest /health round-trip", - }, - { - label: "Average latency", - value: summarizerMetrics?.averageLatencyMs != null ? `${summarizerMetrics.averageLatencyMs} ms` : "-", - sub: "Across API summary requests", - }, - { - label: "Cache hit rate", - value: hitRate != null ? `${hitRate}%` : "-", - sub: "API-side memory cache reuse", - }, - ].map((m, idx) => ( - - - - ))} - + + + {[{ label: "Service status", value: summarizerMetrics?.healthy ? "Healthy" : "Offline", sub: summarizerMetrics?.model || "Summarizer health check" }, { label: "Health latency", value: summarizerMetrics?.healthLatencyMs != null ? `${summarizerMetrics.healthLatencyMs} ms` : "-", sub: "Latest /health round-trip" }, { label: "Average latency", value: summarizerMetrics?.averageLatencyMs != null ? `${summarizerMetrics.averageLatencyMs} ms` : "-", sub: "Across API summary requests" }, { label: "Last success", value: formatRelative(summarizerMetrics?.lastSuccessAt), sub: "Recent successful summary request" }].map((m) => {m.label}{m.value}{m.sub})} + + + Telemetry + Requests: {summarizerMetrics?.requests ?? 0} + Cache hits: {summarizerMetrics?.cacheHits ?? 0} + Cache misses: {summarizerMetrics?.cacheMisses ?? 0} + Failures: {summarizerMetrics?.failures ?? 0} + Last failure: {formatRelative(summarizerMetrics?.lastFailureAt)} + {summarizerMetrics?.lastError || "No recent summarizer errors recorded."} - - - - Summarizer telemetry - - - Useful for spotting slowdowns, cache misses, or service outages in the local summarizer app. - - - - - Requests - {summarizerMetrics?.requests ?? 0} - - - Hits - {summarizerMetrics?.cacheHits ?? 0} - - - Misses - {summarizerMetrics?.cacheMisses ?? 0} - - - Failures - {summarizerMetrics?.failures ?? 0} - - - - - - Last activity - - Last success: {formatRelative(summarizerMetrics?.lastSuccessAt)} - Last failure: {formatRelative(summarizerMetrics?.lastFailureAt)} - Model: {summarizerMetrics?.model || "Unknown"} - - - - - - - Last error - - {summarizerMetrics?.lastError || "No recent summarizer errors recorded."} - - - + ) : null} ); diff --git a/job-tracker-ui/src/components/JobDetailsDialog.tsx b/job-tracker-ui/src/components/JobDetailsDialog.tsx index 470aab3..7cf9092 100644 --- a/job-tracker-ui/src/components/JobDetailsDialog.tsx +++ b/job-tracker-ui/src/components/JobDetailsDialog.tsx @@ -1,6 +1,17 @@ import React, { useEffect, useState } from "react"; -import { Box, Chip, Dialog, DialogContent, DialogTitle, Tab, Tabs, Typography } from "@mui/material"; +import { + Box, + Button, + Chip, + CircularProgress, + Dialog, + DialogContent, + DialogTitle, + Tab, + Tabs, + Typography, +} from "@mui/material"; import { api } from "../api"; import { JobApplication } from "../types"; @@ -9,6 +20,13 @@ import Correspondence from "./Correspondence"; import Attachments from "./Attachments"; import JobFlowBar from "./JobFlowBar"; +type FollowUpDraft = { + subject: string; + body: string; + reason: string; + suggestedSendOn: string; +}; + interface Props { open: boolean; jobId: number | null; @@ -33,14 +51,15 @@ function statusChipColor(status: string): "default" | "primary" | "warning" | "e export default function JobDetailsDialog({ open, jobId, onClose }: Props) { const [job, setJob] = useState(null); const [tab, setTab] = useState(0); - const [history, setHistory] = useState< - { id: number; type: string; oldValue?: string; newValue?: string; note?: string; at: string }[] - >([]); + const [history, setHistory] = useState<{ id: number; type: string; oldValue?: string; newValue?: string; note?: string; at: string }[]>([]); const [isAdmin, setIsAdmin] = useState(false); + const [followUpDraft, setFollowUpDraft] = useState(null); + const [loadingDraft, setLoadingDraft] = useState(false); useEffect(() => { if (!open || !jobId) return; setTab(0); + setFollowUpDraft(null); api.get(`/jobapplications/${jobId}`).then((r) => setJob(r.data)); api .get(`/auth/me`) @@ -52,6 +71,16 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) { .catch(() => setHistory([])); }, [open, jobId]); + useEffect(() => { + if (!open || !jobId || tab !== 4 || followUpDraft) return; + setLoadingDraft(true); + api + .get(`/jobapplications/${jobId}/followup-draft`) + .then((r) => setFollowUpDraft(r.data)) + .catch(() => setFollowUpDraft(null)) + .finally(() => setLoadingDraft(false)); + }, [open, jobId, tab, followUpDraft]); + const tags: string[] = (() => { const raw = job?.tags; if (!raw) return []; @@ -64,23 +93,12 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) { })(); const title = job ? `${job.company?.name ?? ""} - ${job.jobTitle}` : "Job Application"; - const checklist = - [job?.hasResume ? "Resume" : null, job?.hasCoverLetter ? "Cover letter" : null, job?.hasPortfolio ? "Portfolio" : null, job?.hasOtherAttachment ? "Other" : null] - .filter(Boolean) - .join(", ") || ""; + const checklist = [job?.hasResume ? "Resume" : null, job?.hasCoverLetter ? "Cover letter" : null, job?.hasPortfolio ? "Portfolio" : null, job?.hasOtherAttachment ? "Other" : null].filter(Boolean).join(", ") || ""; return ( - + {title} {job && } @@ -93,6 +111,7 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) { + {isAdmin ? : null} @@ -129,19 +148,13 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) { Tags - {tags.length === 0 ? ( - - - ) : ( - tags.map((t) => ) - )} + {tags.length === 0 ? - : tags.map((t) => )} - Attachment Types {checklist} - Job URL @@ -154,26 +167,22 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) { )} - Description (original) {job?.description ?? ""} - {job?.translatedDescription ? ( Translated description {job.translatedDescription} ) : null} - {job?.fullSummary || job?.shortSummary ? ( Summary {job?.fullSummary ?? job?.shortSummary} ) : null} - Notes {job?.notes ?? ""} @@ -186,20 +195,53 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) { {tab === 3 && ( - + {job?.coverLetterText ?? ""} )} + {tab === 4 && ( + + {loadingDraft ? ( + + + + ) : followUpDraft ? ( + + + Reason + {followUpDraft.reason} + + + Suggested send date + {new Date(followUpDraft.suggestedSendOn).toLocaleDateString()} + + + Subject + {followUpDraft.subject} + + + Draft + {followUpDraft.body} + + + + + + ) : ( + No draft available. + )} + + )} - {tab === 4 && isAdmin && ( + {tab === 5 && isAdmin && ( {history.length === 0 ? ( No history yet. ) : ( - history.map((e) => ( - - )) + history.map((e) => ) )} )} @@ -208,34 +250,14 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) { ); } -function PaperRow({ - type, - oldValue, - newValue, - at, - note, -}: { - type: string; - oldValue?: string; - newValue?: string; - at: string; - note?: string; -}) { +function PaperRow({ type, oldValue, newValue, at, note }: { type: string; oldValue?: string; newValue?: string; at: string; note?: string }) { return ( - + {type} {oldValue || newValue ? ( - {" "} - ({oldValue ?? ""} {oldValue || newValue ? "->" : ""} {newValue ?? ""}) + {" "}({oldValue ?? ""} {oldValue || newValue ? "->" : ""} {newValue ?? ""}) ) : null} diff --git a/job-tracker-ui/src/components/JobTable.tsx b/job-tracker-ui/src/components/JobTable.tsx index 3031c39..1d47ac6 100644 --- a/job-tracker-ui/src/components/JobTable.tsx +++ b/job-tracker-ui/src/components/JobTable.tsx @@ -2,14 +2,15 @@ import React, { useEffect, useMemo, useState } from "react"; import { Box, + Button, + Checkbox, Chip, Collapse, - Checkbox, - FormControlLabel, FormControl, - InputLabel, + FormControlLabel, IconButton, InputAdornment, + InputLabel, Menu, MenuItem, Paper, @@ -25,9 +26,7 @@ import { Tooltip, Typography, } from "@mui/material"; - import { alpha, useTheme } from "@mui/material/styles"; - import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; import LaunchIcon from "@mui/icons-material/Launch"; @@ -41,7 +40,6 @@ import SearchIcon from "@mui/icons-material/Search"; import { api } from "../api"; import { useCompanies } from "../hooks/useCompanies"; import { useDebouncedValue } from "../hooks/useDebouncedValue"; - import JobDetailsDialog from "./JobDetailsDialog"; import EditJobDialog from "./EditJobDialog"; import { useToast } from "../toast"; @@ -54,7 +52,6 @@ interface JobApplication { dateApplied: string; daysSince: number; jobUrl?: string | null; - coverLetterText?: string | null; notes?: string | null; location?: string | null; salary?: string | null; @@ -93,77 +90,47 @@ interface Props { } function normalizeStatus(status: string): string { - if (status === "Interviewing") return "Interview"; - return status; + return status === "Interviewing" ? "Interview" : status; } -function statusTone(status: string): "applied" | "waiting" | "interview" | "offer" | "rejected" | "ghosted" { - switch (normalizeStatus(status)) { - case "Offer": - return "offer"; - case "Rejected": - return "rejected"; - case "Waiting": - return "waiting"; - case "Ghosted": - return "ghosted"; - case "Interview": - return "interview"; - case "Applied": - default: - return "applied"; +function parseTags(raw?: string | null): string[] { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter((x) => typeof x === "string") : []; + } catch { + return raw.split(/[,;\n]/).map((x) => x.trim()).filter(Boolean); } } -function StatusChip({ status }: { status: string }) { - const theme = useTheme(); - const tone = statusTone(status); - - const c = - tone === "rejected" - ? theme.palette.error.main - : tone === "waiting" || tone === "ghosted" - ? theme.palette.warning.main - : tone === "offer" - ? theme.palette.success.main - : theme.palette.primary.main; - - return ( - - ); +function statusTone(status: string): string { + switch (normalizeStatus(status)) { + case "Offer": + return "success"; + case "Rejected": + return "error"; + case "Waiting": + case "Ghosted": + return "warning"; + case "Interview": + return "info"; + default: + return "primary"; + } } -export default function JobTable({ - refreshToken, - pageSize, - onPageSizeChange, - columns, - onColumnsChange, - mode = "jobs", -}: Props) { +export default function JobTable({ refreshToken, pageSize, onPageSizeChange, columns, onColumnsChange, mode = "jobs" }: Props) { const theme = useTheme(); const { toast } = useToast(); const [jobs, setJobs] = useState([]); const [total, setTotal] = useState(0); - const [page, setPage] = useState(0); // 0-based for TablePagination + const [page, setPage] = useState(0); const [expanded, setExpanded] = useState([]); + const [selectedIds, setSelectedIds] = useState([]); const [search, setSearch] = useState(""); const debouncedSearch = useDebouncedValue(search, 250); const [includeDeleted, setIncludeDeleted] = useState(mode === "trash"); - const [columnsAnchor, setColumnsAnchor] = useState( - null, - ); + const [columnsAnchor, setColumnsAnchor] = useState(null); const [statusFilter, setStatusFilter] = useState("All"); const [locationFilter, setLocationFilter] = useState(""); const debouncedLocation = useDebouncedValue(locationFilter, 250); @@ -173,43 +140,32 @@ export default function JobTable({ const [detailsJobId, setDetailsJobId] = useState(null); const [editJobId, setEditJobId] = useState(null); const [reloadToken, setReloadToken] = useState(0); - const [fullSummaries, setFullSummaries] = useState>({}); - const [loadingFull, setLoadingFull] = useState>({}); const [statusAnchor, setStatusAnchor] = useState(null); const [statusJobId, setStatusJobId] = useState(null); - const [sortBy, setSortBy] = useState< - "dateApplied" | "company" | "jobTitle" | "status" | "daysSince" | "location" - >("dateApplied"); + const [sortBy, setSortBy] = useState<"dateApplied" | "company" | "jobTitle" | "status" | "daysSince" | "location">("dateApplied"); const [sortDir, setSortDir] = useState<"asc" | "desc">("desc"); - const params = useMemo(() => { - const q = debouncedSearch.trim(); - return { - page: page + 1, - pageSize, - q: q.length ? q : undefined, - status: statusFilter !== "All" ? statusFilter : undefined, - companyId: companyFilterId === "All" ? undefined : companyFilterId, - location: debouncedLocation.trim().length ? debouncedLocation.trim() : undefined, - includeDeleted, - deletedOnly: mode === "trash" ? true : undefined, - sortBy, - sortDir, - needsFollowUp: needsFollowUpOnly ? true : undefined, - }; - }, [ - page, + const params = useMemo(() => ({ + page: page + 1, pageSize, - debouncedSearch, + q: debouncedSearch.trim() || undefined, + status: statusFilter !== "All" ? statusFilter : undefined, + companyId: companyFilterId === "All" ? undefined : companyFilterId, + location: debouncedLocation.trim() || undefined, includeDeleted, - statusFilter, - companyFilterId, - debouncedLocation, - mode, + deletedOnly: mode === "trash" ? true : undefined, sortBy, sortDir, - needsFollowUpOnly, - ]); + needsFollowUp: needsFollowUpOnly ? true : undefined, + }), [page, pageSize, debouncedSearch, statusFilter, companyFilterId, debouncedLocation, includeDeleted, mode, sortBy, sortDir, needsFollowUpOnly]); + + useEffect(() => { + api.get>("/jobapplications", { params }).then((r) => { + setJobs(r.data.items); + setTotal(r.data.total); + setSelectedIds([]); + }); + }, [params, refreshToken, reloadToken]); const requestSort = (key: typeof sortBy) => { if (sortBy === key) setSortDir((d) => (d === "asc" ? "desc" : "asc")); @@ -220,58 +176,24 @@ export default function JobTable({ setPage(0); }; - useEffect(() => { - api - .get>("/jobapplications", { params }) - .then((r) => { - setJobs(r.data.items); - setTotal(r.data.total); - }); - }, [params, refreshToken, reloadToken]); - - const toggle = (id: number) => { - setExpanded((prev) => - prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id], - ); + const toggleExpanded = (id: number) => { + setExpanded((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])); }; - const loadFullSummary = async (id: number) => { - if (fullSummaries[id] !== undefined) { - // already loaded (could be null meaning not available) - return; - } - setLoadingFull((s) => ({ ...s, [id]: true })); - try { - const res = await api.get(`/jobapplications/${id}`); - setFullSummaries((s) => ({ ...s, [id]: res.data.fullSummary ?? res.data.shortSummary ?? null })); - // also merge shortSummary into jobs array for lists - setJobs((prev) => prev.map((j) => (j.id === id ? { ...j, shortSummary: res.data.shortSummary ?? j.shortSummary } : j))); - } catch { - setFullSummaries((s) => ({ ...s, [id]: null })); - } finally { - setLoadingFull((s) => ({ ...s, [id]: false })); - } + const selectedAllOnPage = jobs.length > 0 && jobs.every((job) => selectedIds.includes(job.id)); + + const toggleSelectAll = (checked: boolean) => { + setSelectedIds(checked ? jobs.map((job) => job.id) : []); }; - const generateOverview = (job: JobApplication) => { - const src = (job.description || job.notes || "").replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim(); - if (!src) return ""; - // Try to take up to two short sentences, fallback to truncated text. - const sentences = src.split(/(?<=[.!?])\s+/); - const first = sentences.slice(0, 2).join(" "); - const out = first.length >= 100 ? (first.length > 250 ? first.slice(0, 250) + "…" : first) : (src.length > 250 ? src.slice(0, 250) + "…" : src); - return out; + const toggleSelected = (id: number, checked: boolean) => { + setSelectedIds((prev) => checked ? [...prev, id] : prev.filter((x) => x !== id)); }; const softDelete = async (id: number) => { try { await api.delete(`/jobapplications/${id}`); - toast("Job moved to trash.", "success", { - label: "Undo", - onClick: () => { - void restore(id); - }, - }); + toast("Job moved to trash.", "success", { label: "Undo", onClick: () => { void restore(id); } }); setReloadToken((t) => t + 1); } catch { toast("Failed to delete job.", "error"); @@ -298,504 +220,156 @@ export default function JobTable({ } }; + const runBulkAction = async (action: "delete" | "restore" | "status", value?: string) => { + if (selectedIds.length === 0) return; + try { + await Promise.all(selectedIds.map((id) => { + if (action === "delete") return api.delete(`/jobapplications/${id}`); + if (action === "restore") return api.post(`/jobapplications/${id}/restore`); + return api.patch(`/jobapplications/${id}/status`, { status: value }); + })); + toast(`Updated ${selectedIds.length} jobs.`, "success"); + setReloadToken((t) => t + 1); + setSelectedIds([]); + } catch { + toast("Bulk action failed.", "error"); + } + }; + + const generateOverview = (job: JobApplication) => { + if (job.shortSummary) return job.shortSummary; + const src = (job.description || job.notes || "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim(); + return src.length > 220 ? `${src.slice(0, 220)}...` : src; + }; + return ( - + { - setSearch(e.target.value); - setPage(0); - }} + onChange={(e) => { setSearch(e.target.value); setPage(0); }} placeholder="Title, company, notes, messages" size="small" - InputProps={{ - startAdornment: ( - - - - ), - }} + InputProps={{ startAdornment: }} sx={{ minWidth: 320, flex: "1 1 320px" }} /> - Status - { setStatusFilter(e.target.value); setPage(0); }}> + {["All", "Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"].map((s) => {s})} - Company - { setCompanyFilterId(e.target.value as any); setPage(0); }}> All - {companies.map((c) => ( - - {c.name} - - ))} + {companies.map((c) => {c.name})} - { - setLocationFilter(e.target.value); - setPage(0); - }} - sx={{ minWidth: 200, flex: "1 1 200px" }} - /> + { setLocationFilter(e.target.value); setPage(0); }} sx={{ minWidth: 200, flex: "1 1 200px" }} /> - - {mode === "jobs" && ( - { - setNeedsFollowUpOnly(e.target.checked); - setPage(0); - }} - /> - } - label="Needs follow-up" - /> - )} - - {mode === "jobs" && ( - { - setIncludeDeleted(e.target.checked); - setPage(0); - }} - /> - } - label="Show deleted" - /> - )} - - { - setSearch(p.q ?? ""); - setStatusFilter(p.status ?? "All"); - setCompanyFilterId(p.companyId ?? "All"); - setLocationFilter(p.location ?? ""); - setNeedsFollowUpOnly(Boolean(p.needsFollowUp)); - setPage(0); - }} - /> - - - setColumnsAnchor(e.currentTarget)}> - - - - - setColumnsAnchor(null)} - > - {( - [ - ["status", "Status"], - ["dateApplied", "Date applied"], - ["daysSince", "Days"], - ["jobUrl", "Job URL"], - ] as const - ).map(([key, label]) => ( - - onColumnsChange({ ...columns, [key]: !columns[key] }) - } - > - - {label} - - ))} - + + {mode === "jobs" ? { setNeedsFollowUpOnly(e.target.checked); setPage(0); }} />} label="Needs follow-up" /> : null} + {mode === "jobs" ? { setIncludeDeleted(e.target.checked); setPage(0); }} />} label="Show deleted" /> : null} + { setSearch(p.q ?? ""); setStatusFilter(p.status ?? "All"); setCompanyFilterId(p.companyId ?? "All"); setLocationFilter(p.location ?? ""); setNeedsFollowUpOnly(Boolean(p.needsFollowUp)); setPage(0); }} /> + setColumnsAnchor(e.currentTarget)}> - + {selectedIds.length > 0 ? ( + + {selectedIds.length} selected + + {mode === "trash" ? : } + {mode === "jobs" ? ["Waiting", "Interview", "Rejected", "Ghosted", "Offer"].map((s) => ) : null} + + + ) : null} + + setColumnsAnchor(null)}> + {([ ["status", "Status"], ["dateApplied", "Date applied"], ["daysSince", "Days"], ["jobUrl", "Job URL"] ] as const).map(([key, label]) => ( + onColumnsChange({ ...columns, [key]: !columns[key] })}> + + {label} + + ))} + + + + 0 && !selectedAllOnPage} onChange={(e) => toggleSelectAll(e.target.checked)} /> - - requestSort("company")} - > - Company - - - - requestSort("jobTitle")} - > - Role - - - {columns.status && ( - - requestSort("status")} - > - Status - - - )} - {columns.dateApplied && ( - - requestSort("dateApplied")} - > - Date Applied - - - )} - {columns.daysSince && ( - - requestSort("daysSince")} - > - Days - - - )} - {columns.jobUrl && Job URL} - - Actions - + requestSort("company")}>Company + requestSort("jobTitle")}>Role + {columns.status ? requestSort("status")}>Status : null} + {columns.dateApplied ? requestSort("dateApplied")}>Date Applied : null} + {columns.daysSince ? requestSort("daysSince")}>Days : null} + {columns.jobUrl ? Job URL : null} + Actions - {jobs.map((job) => { const open = expanded.includes(job.id); - const tone = statusTone(job.status); - const stripe = - tone === "rejected" - ? theme.palette.error.main - : tone === "waiting" || tone === "ghosted" - ? theme.palette.warning.main - : tone === "offer" - ? theme.palette.success.main - : theme.palette.primary.main; - const bg = alpha(stripe, theme.palette.mode === "dark" ? 0.10 : 0.06); - const fg = theme.palette.text.primary; + const toneName = statusTone(job.status); + const tone = toneName === "error" ? theme.palette.error.main : toneName === "warning" ? theme.palette.warning.main : toneName === "success" ? theme.palette.success.main : toneName === "info" ? theme.palette.info.main : theme.palette.primary.main; return ( - - - toggle(job.id)}> - {open ? : } - - - + + toggleSelected(job.id, e.target.checked)} /> + toggleExpanded(job.id)}>{open ? : } {job.company?.name ?? ""} {job.jobTitle} - {job.needsFollowUp ? ( - - ) : null} + {job.needsFollowUp ? : null} - {columns.status && ( - - - - )} - {columns.dateApplied && ( - - {new Date(job.dateApplied).toLocaleDateString()} - - )} - {columns.daysSince && {job.daysSince}} - {columns.jobUrl && ( - - {job.jobUrl ? ( - - Link - - ) : ( - "" - )} - - )} - + {columns.status ? : null} + {columns.dateApplied ? {new Date(job.dateApplied).toLocaleDateString()} : null} + {columns.daysSince ? {job.daysSince} : null} + {columns.jobUrl ? {job.jobUrl ? Link : ""} : null} - - - setEditJobId(job.id)}> - - - - - { - setStatusJobId(job.id); - setStatusAnchor(e.currentTarget); - }} - > - - - - - setDetailsJobId(job.id)}> - - - - {(mode === "trash" || (includeDeleted && job.isDeleted)) ? ( - - restore(job.id)}> - - - - ) : ( - - softDelete(job.id)}> - - - - )} + + setEditJobId(job.id)}> + { setStatusJobId(job.id); setStatusAnchor(e.currentTarget); }}> + setDetailsJobId(job.id)}> + {(mode === "trash" || (includeDeleted && job.isDeleted)) ? void restore(job.id)}> : void softDelete(job.id)}>} - - {open && ( - - - - - - Location - {job.location ?? ""} - - - Skills - - {(() => { - try { - const parsed = job.tags ? JSON.parse(job.tags) : []; - return Array.isArray(parsed) ? parsed.filter((x: any) => typeof x === "string").slice(0, 8).map((t: string) => ( - - )) : —; - } catch { - return —; - } - })()} - - - - Job URL - - {job.jobUrl ? ( - - Link - - ) : ( - "" - )} - - - {/* Deleted flag removed from expanded view as requested */} - - - Overview - - {fullSummaries[job.id] ?? job.shortSummary ?? generateOverview(job)} - - - {fullSummaries[job.id] ? ( - - ) : ( - (job.shortSummary || job.description || job.notes) && ( - - ) - )} - - - - - - - )} + + + + + Location{job.location ?? "-"} + Salary{job.salary ?? "-"} + Job URL{job.jobUrl ? Open listing : "-"} + Skills{parseTags(job.tags).length ? parseTags(job.tags).slice(0, 8).map((tag) => ) : No tags} + Overview{generateOverview(job) || "No summary yet."} + + + + ); })} - - {jobs.length === 0 && ( - - - - No jobs found. - - - - )} + {jobs.length === 0 ? No jobs found. : null}
- - setPage(next)} - rowsPerPage={pageSize} - onRowsPerPageChange={(e) => { - const next = Number(e.target.value) as 15 | 20 | 25; - onPageSizeChange(next); - setPage(0); - }} - rowsPerPageOptions={[15, 20, 25]} - /> + setPage(next)} rowsPerPage={pageSize} onRowsPerPageChange={(e) => { onPageSizeChange(Number(e.target.value) as 15 | 20 | 25); setPage(0); }} rowsPerPageOptions={[15, 20, 25]} />
- setDetailsJobId(null)} - /> - - setEditJobId(null)} - onSaved={() => { - setReloadToken((t) => t + 1); - }} - /> - - { - setStatusAnchor(null); - setStatusJobId(null); - }} - > - {["Waiting", "Rejected", "Ghosted"].map((s) => ( - { - if (statusJobId) void setStatusQuick(statusJobId, s); - setStatusAnchor(null); - setStatusJobId(null); - }} - > - Set {s} - - ))} + setDetailsJobId(null)} /> + setEditJobId(null)} onSaved={() => setReloadToken((t) => t + 1)} /> + { setStatusAnchor(null); setStatusJobId(null); }}> + {["Waiting", "Interview", "Offer", "Rejected", "Ghosted"].map((s) => { if (statusJobId) void setStatusQuick(statusJobId, s); setStatusAnchor(null); setStatusJobId(null); }}>Set {s})} ); diff --git a/job-tracker-ui/src/components/QuickCommandDialog.tsx b/job-tracker-ui/src/components/QuickCommandDialog.tsx new file mode 100644 index 0000000..b636f3f --- /dev/null +++ b/job-tracker-ui/src/components/QuickCommandDialog.tsx @@ -0,0 +1,156 @@ +import React, { useEffect, useMemo, useState } from "react"; + +import { + Box, + Dialog, + DialogContent, + List, + ListItemButton, + ListItemText, + TextField, + Typography, +} from "@mui/material"; + +import SearchIcon from "@mui/icons-material/Search"; + +import { api } from "../api"; + +type CommandItem = { + id: string; + label: string; + hint?: string; + action: () => void; +}; + +type JobSearchItem = { + id: number; + jobTitle: string; + company: { name: string }; +}; + +type CompanySearchItem = { + id: number; + name: string; +}; + +interface Props { + open: boolean; + onClose: () => void; + onNavigate: (to: string) => void; + onOpenAddJob: () => void; +} + +export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAddJob }: Props) { + const [query, setQuery] = useState(""); + const [jobs, setJobs] = useState([]); + const [companies, setCompanies] = useState([]); + + useEffect(() => { + if (!open) { + setQuery(""); + setJobs([]); + setCompanies([]); + return; + } + }, [open]); + + useEffect(() => { + if (!open) return; + const q = query.trim(); + if (!q) { + setJobs([]); + setCompanies([]); + return; + } + + const timeout = window.setTimeout(() => { + api + .get<{ items: JobSearchItem[] }>("/jobapplications", { params: { q, page: 1, pageSize: 15 } }) + .then((r) => setJobs(r.data?.items ?? [])) + .catch(() => setJobs([])); + api + .get("/companies") + .then((r) => { + const matches = (r.data ?? []).filter((c) => c.name.toLowerCase().includes(q.toLowerCase())).slice(0, 8); + setCompanies(matches); + }) + .catch(() => setCompanies([])); + }, 250); + + return () => window.clearTimeout(timeout); + }, [open, query]); + + const commands = useMemo(() => { + const base: CommandItem[] = [ + { id: "go-dashboard", label: "Go to dashboard", hint: "Analytics overview", action: () => onNavigate("/dashboard") }, + { id: "go-jobs", label: "Go to jobs", hint: "Main applications table", action: () => onNavigate("/jobs") }, + { id: "go-reminders", label: "Go to reminders", hint: "Follow-up queue", action: () => onNavigate("/reminders") }, + { id: "go-companies", label: "Go to companies", hint: "CRM and source tracking", action: () => onNavigate("/companies") }, + { id: "go-settings", label: "Go to settings", hint: "Preferences and admin tools", action: () => onNavigate("/settings") }, + { id: "add-job", label: "Add new job", hint: "Open the add-job modal", action: onOpenAddJob }, + ]; + + const q = query.trim().toLowerCase(); + if (!q) return base; + return base.filter((item) => item.label.toLowerCase().includes(q) || item.hint?.toLowerCase().includes(q)); + }, [onNavigate, onOpenAddJob, query]); + + const allItems: CommandItem[] = [ + ...commands, + ...jobs.slice(0, 6).map((job) => ({ + id: `job-${job.id}`, + label: `${job.company?.name ?? "Company"} - ${job.jobTitle}`, + hint: "Open job list and search result", + action: () => onNavigate(`/jobs`), + })), + ...companies.slice(0, 6).map((company) => ({ + id: `company-${company.id}`, + label: company.name, + hint: "Open companies", + action: () => onNavigate(`/companies`), + })), + ]; + + return ( + + + + + setQuery(e.target.value)} + InputProps={{ disableUnderline: true }} + /> + + Ctrl/Cmd + K + + + + + {allItems.length === 0 ? ( + + No matching commands or records. + + ) : ( + allItems.map((item) => ( + { + item.action(); + onClose(); + }} + sx={{ borderRadius: 2 }} + > + + + )) + )} + + + + ); +} diff --git a/job-tracker-ui/src/pages/AdminSystemPage.tsx b/job-tracker-ui/src/pages/AdminSystemPage.tsx new file mode 100644 index 0000000..e03d734 --- /dev/null +++ b/job-tracker-ui/src/pages/AdminSystemPage.tsx @@ -0,0 +1,155 @@ +import React, { useEffect, useState } from "react"; + +import { Alert, Box, Button, Paper, Typography } from "@mui/material"; + +import { api } from "../api"; + +type SummarizerMetrics = { + healthy: boolean; + model?: string | null; + healthLatencyMs?: number | null; + requests: number; + cacheHits: number; + cacheMisses: number; + failures: number; + averageLatencyMs?: number | null; + lastError?: string | null; +}; + +type SystemStatus = { + environment: string; + contentRoot: string; + version: string; + storage: { + dataRoot: string; + dbPath: string; + dbExists: boolean; + dbSizeBytes?: number | null; + companyCount: number; + jobCount: number; + deletedCount: number; + }; + email: { + enabled: boolean; + host?: string | null; + port: number; + enableSsl: boolean; + from?: string | null; + fromName?: string | null; + }; + summarizer: SummarizerMetrics; +}; + +function formatBytes(bytes?: number | null) { + if (bytes == null) return "-"; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export default function AdminSystemPage() { + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const load = async () => { + setLoading(true); + setError(null); + try { + const res = await api.get("/admin/system"); + setStatus(res.data); + } catch (e: any) { + setError(e?.response?.data || e?.message || "Failed to load system status."); + setStatus(null); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void load(); + }, []); + + return ( + + + + System status + Quick operational view of storage, email, and summarizer health. + + + + + {error ? {error} : null} + + + + Environment + {status?.environment ?? "-"} + Version {status?.version ?? "-"} + + + Database + {status?.storage.dbExists ? "Ready" : "Missing"} + {formatBytes(status?.storage.dbSizeBytes)} + + + SMTP + {status?.email.enabled ? "Enabled" : "Disabled"} + {status?.email.host || "No SMTP host configured"} + + + Summarizer + {status?.summarizer.healthy ? "Healthy" : "Offline"} + {status?.summarizer.healthLatencyMs != null ? `${status.summarizer.healthLatencyMs} ms` : "No latency data"} + + + + + + Storage + Data root: {status?.storage.dataRoot || "-"} + DB path: {status?.storage.dbPath || "-"} + Companies: {status?.storage.companyCount ?? 0} + Jobs: {status?.storage.jobCount ?? 0} + Deleted jobs: {status?.storage.deletedCount ?? 0} + Content root: {status?.contentRoot || "-"} + + + + Email + From: {status?.email.from || "-"} + From name: {status?.email.fromName || "-"} + Host: {status?.email.host || "-"} + Port: {status?.email.port ?? "-"} + SSL: {status?.email.enableSsl ? "Yes" : "No"} + + + + + Summarizer telemetry + + + Requests + {status?.summarizer.requests ?? 0} + + + Cache hits + {status?.summarizer.cacheHits ?? 0} + + + Failures + {status?.summarizer.failures ?? 0} + + + Avg latency + {status?.summarizer.averageLatencyMs != null ? `${status.summarizer.averageLatencyMs} ms` : "-"} + + + {status?.summarizer.lastError ? {status.summarizer.lastError} : null} + + + ); +}