Dashboard upgrades, workflows added and assitant emailer
This commit is contained in:
@@ -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<ActionResult<SystemStatusDto>> 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
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,50 @@ namespace JobTrackerApi.Controllers
|
||||
private string? CurrentUserId =>
|
||||
User?.FindFirstValue(ClaimTypes.NameIdentifier) ?? User?.FindFirstValue("sub");
|
||||
|
||||
private static IEnumerable<string> SplitTags(string? s)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(s)) yield break;
|
||||
|
||||
var trimmed = s.Trim();
|
||||
|
||||
List<string>? jsonTags = null;
|
||||
if (trimmed.StartsWith("[") && trimmed.EndsWith("]"))
|
||||
{
|
||||
try
|
||||
{
|
||||
jsonTags = JsonSerializer.Deserialize<List<string>>(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<T>(List<T> 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<int> Counts);
|
||||
public sealed record TagTrendPoint(string Month, List<int> Counts);
|
||||
public sealed record AnalyticsOverviewDto(
|
||||
List<FunnelStagePoint> Funnel,
|
||||
List<ResponseRatePoint> ResponseRateBySource,
|
||||
List<CompanyActivityPoint> 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<DuplicateCandidateDto> Matches);
|
||||
public sealed record FollowUpDraftDto(string Subject, string Body, string Reason, DateTime SuggestedSendOn);
|
||||
public sealed record TagTrendResponse(List<string> Months, List<TagTrendSeries> Series);
|
||||
|
||||
[HttpGet("analytics-overview")]
|
||||
public async Task<ActionResult<AnalyticsOverviewDto>> GetAnalyticsOverview(CancellationToken cancellationToken)
|
||||
{
|
||||
var activeJobs = await _db.JobApplications
|
||||
.AsNoTracking()
|
||||
.Include(j => j.Company)
|
||||
.Where(j => !j.IsDeleted)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var funnelMap = new Dictionary<string, int>
|
||||
{
|
||||
["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<ActionResult<TagTrendResponse>> 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<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
var monthKeys = Enumerable.Range(0, months).Select(i => startMonth.AddMonths(i).ToString("yyyy-MM")).ToList();
|
||||
var seriesMap = new Dictionary<string, Dictionary<string, int>>(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<string, int>(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<ActionResult<DuplicateCheckResult>> 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<DuplicateCandidateDto>()));
|
||||
}
|
||||
|
||||
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<string>();
|
||||
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<ActionResult<FollowUpDraftDto>> 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<ActionResult<SummarizerMetrics>> GetSummarizerMetrics(CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
+64
-214
@@ -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<boolean | null>(null);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
@@ -120,62 +92,38 @@ function Shell({
|
||||
const isJobs = path.startsWith("/jobs");
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get<AuthConfig>("/auth/config")
|
||||
.then((r) => setRequireAuth(Boolean(r.data?.requireAuth)))
|
||||
.catch(() => setRequireAuth(false));
|
||||
api.get<AuthConfig>("/auth/config").then((r) => setRequireAuth(Boolean(r.data?.requireAuth))).catch(() => setRequireAuth(false));
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
api
|
||||
.get<MeResponse>("/auth/me")
|
||||
.then((r) => {
|
||||
setMe(r.data);
|
||||
setIsAdmin(Boolean(r.data?.roles?.includes("Admin")));
|
||||
})
|
||||
.catch(() => {
|
||||
setMe(null);
|
||||
setIsAdmin(false);
|
||||
});
|
||||
api.get<MeResponse>("/auth/me").then((r) => { setMe(r.data); setIsAdmin(Boolean(r.data?.roles?.includes("Admin"))); }).catch(() => { setMe(null); setIsAdmin(false); });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const load = () => {
|
||||
api
|
||||
.get<any[]>("/jobapplications/reminders", { params: { upcomingDays: 14 } })
|
||||
.then((r) => setNotifCount(Array.isArray(r.data) ? r.data.length : 0))
|
||||
.catch(() => setNotifCount(0));
|
||||
api.get<any[]>("/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 (
|
||||
<Box sx={{ p: 4 }}>
|
||||
<Typography variant="h6">Loading...</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (requireAuth && !token) {
|
||||
return <Navigate to="/login" replace state={{ from: path }} />;
|
||||
}
|
||||
if (requireAuth === null) return <Box sx={{ p: 4 }}><Typography variant="h6">Loading...</Typography></Box>;
|
||||
if (requireAuth && !token) return <Navigate to="/login" replace state={{ from: path }} />;
|
||||
|
||||
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: <DashboardIcon fontSize="small" />, section: "Manage" },
|
||||
@@ -187,23 +135,21 @@ function Shell({
|
||||
];
|
||||
|
||||
const navBottom: NavItem[] = [
|
||||
{ to: "/admin/audit", label: "Audit log", icon: <ShieldIcon fontSize="small" />, hidden: !isAdmin, section: "Admin" }, { to: "/admin/users", label: "Users", icon: <AccountCircleIcon fontSize="small" />, hidden: !isAdmin, section: "Admin" },
|
||||
{ to: "/admin/audit", label: "Audit log", icon: <ShieldIcon fontSize="small" />, hidden: !isAdmin, section: "Admin" },
|
||||
{ to: "/admin/users", label: "Users", icon: <AccountCircleIcon fontSize="small" />, hidden: !isAdmin, section: "Admin" },
|
||||
{ to: "/admin/system", label: "System", icon: <MemoryIcon fontSize="small" />, hidden: !isAdmin, section: "Admin" },
|
||||
{ to: "/profile", label: "Profile", icon: <AccountCircleIcon fontSize="small" />, section: "Account" },
|
||||
{ to: "/settings", label: t("settings"), icon: <SettingsIcon fontSize="small" />, section: "Account" },
|
||||
];
|
||||
|
||||
const rightActions = (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||
<Button variant="outlined" startIcon={<SearchIcon />} onClick={() => setQuickOpen(true)}>Quick Search</Button>
|
||||
<ToggleButtonGroup size="small" exclusive value={language} onChange={(_, v) => v && setLanguage(v)}>
|
||||
<ToggleButton value="en">EN</ToggleButton>
|
||||
<ToggleButton value="no">NO</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
|
||||
{isJobs ? (
|
||||
<Button variant="contained" onClick={() => setAddOpen(true)}>
|
||||
{t("addJob")}
|
||||
</Button>
|
||||
) : null}
|
||||
{isJobs ? <Button variant="contained" onClick={() => setAddOpen(true)}>{t("addJob")}</Button> : null}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/jobs" replace />} />
|
||||
<Route path="/dashboard" element={<DashboardView />} />
|
||||
<Route
|
||||
path="/jobs"
|
||||
element={
|
||||
<JobTable
|
||||
refreshToken={refreshToken}
|
||||
pageSize={jobPageSize}
|
||||
onPageSizeChange={setAndPersistPageSize}
|
||||
columns={jobColumns}
|
||||
onColumnsChange={setAndPersistColumns}
|
||||
mode="jobs"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/jobs" element={<JobTable refreshToken={refreshToken} pageSize={jobPageSize} onPageSizeChange={setAndPersistPageSize} columns={jobColumns} onColumnsChange={setAndPersistColumns} mode="jobs" />} />
|
||||
<Route path="/reminders" element={<RemindersView />} />
|
||||
<Route path="/kanban" element={<KanbanBoard />} />
|
||||
<Route path="/companies" element={<CompaniesTable />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/admin/audit" element={<AdminAuditPage />} /> <Route path="/admin/users" element={<AdminUsersPage />} />
|
||||
<Route
|
||||
path="/trash"
|
||||
element={
|
||||
<JobTable
|
||||
refreshToken={refreshToken}
|
||||
pageSize={jobPageSize}
|
||||
onPageSizeChange={setAndPersistPageSize}
|
||||
columns={jobColumns}
|
||||
onColumnsChange={setAndPersistColumns}
|
||||
mode="trash"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<SettingsView
|
||||
pageSize={jobPageSize}
|
||||
onPageSizeChange={setAndPersistPageSize}
|
||||
columns={jobColumns}
|
||||
onColumnsChange={setAndPersistColumns}
|
||||
themeMode={themeMode}
|
||||
onThemeModeChange={onThemeModeChange}
|
||||
accentColor={accentColor}
|
||||
onAccentColorChange={onAccentColorChange}
|
||||
onResetAccentColor={onResetAccentColor}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/admin/audit" element={<AdminAuditPage />} />
|
||||
<Route path="/admin/users" element={<AdminUsersPage />} />
|
||||
<Route path="/admin/system" element={<AdminSystemPage />} />
|
||||
<Route path="/trash" element={<JobTable refreshToken={refreshToken} pageSize={jobPageSize} onPageSizeChange={setAndPersistPageSize} columns={jobColumns} onColumnsChange={setAndPersistColumns} mode="trash" />} />
|
||||
<Route path="/settings" element={<SettingsView pageSize={jobPageSize} onPageSizeChange={setAndPersistPageSize} columns={jobColumns} onColumnsChange={setAndPersistColumns} themeMode={themeMode} onThemeModeChange={onThemeModeChange} accentColor={accentColor} onAccentColorChange={onAccentColorChange} onResetAccentColor={onResetAccentColor} />} />
|
||||
<Route path="*" element={<Navigate to="/jobs" replace />} />
|
||||
</Routes>
|
||||
</AppShell>
|
||||
|
||||
<AddJobModal
|
||||
open={addOpen}
|
||||
onClose={() => setAddOpen(false)}
|
||||
onCreated={() => {
|
||||
setRefreshToken((t) => t + 1);
|
||||
setAddOpen(false);
|
||||
}}
|
||||
/>
|
||||
<AddJobModal open={addOpen} onClose={() => setAddOpen(false)} onCreated={() => { setRefreshToken((t) => t + 1); }} />
|
||||
<QuickCommandDialog open={quickOpen} onClose={() => 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<ThemeModePref>(() => getThemeModePref());
|
||||
const [accentColor, setAccentColorState] = useState<string>(() => 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<JobTableColumns>(() => {
|
||||
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<JobTableColumns>;
|
||||
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: <LoginPage /> },
|
||||
{ path: "/reset-password", element: <ResetPasswordPage /> },
|
||||
{
|
||||
path: "/*",
|
||||
element: (
|
||||
<Shell
|
||||
jobPageSize={jobPageSize}
|
||||
setJobPageSize={setJobPageSize}
|
||||
jobColumns={jobColumns}
|
||||
setJobColumns={setJobColumns}
|
||||
themeMode={themeMode}
|
||||
onThemeModeChange={onThemeModeChange}
|
||||
accentColor={accentColor}
|
||||
onAccentColorChange={onAccentColorChange}
|
||||
onResetAccentColor={onResetAccentColor}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
{
|
||||
future: {
|
||||
v7_relativeSplatPath: true,
|
||||
},
|
||||
},
|
||||
),
|
||||
[jobColumns, jobPageSize, themeMode, accentColor],
|
||||
);
|
||||
const router = useMemo(() => createBrowserRouter([
|
||||
{ path: "/login", element: <LoginPage /> },
|
||||
{ path: "/reset-password", element: <ResetPasswordPage /> },
|
||||
{ path: "/*", element: <Shell jobPageSize={jobPageSize} setJobPageSize={setJobPageSize} jobColumns={jobColumns} setJobColumns={setJobColumns} themeMode={themeMode} onThemeModeChange={onThemeModeChange} accentColor={accentColor} onAccentColorChange={onAccentColorChange} onResetAccentColor={onResetAccentColor} /> },
|
||||
], { future: { v7_relativeSplatPath: true } }), [jobColumns, jobPageSize, themeMode, accentColor]);
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
|
||||
@@ -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<DuplicateCheckResult | null>(null);
|
||||
|
||||
const { companies: cachedCompanies } = useCompanies();
|
||||
const [companies, setCompanies] = useState<Company[]>([]);
|
||||
@@ -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<DuplicateCheckResult>("/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<Company | null> => {
|
||||
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 ? (
|
||||
<Box sx={{ mt: 1, display: "grid", gridTemplateColumns: "1fr 1fr", gap: 2 }}>
|
||||
<TextField
|
||||
label="Company location"
|
||||
value={newCompanyLocation}
|
||||
onChange={(e) => setNewCompanyLocation(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="Company source"
|
||||
value={newCompanySource}
|
||||
onChange={(e) => setNewCompanySource(e.target.value)}
|
||||
/>
|
||||
<TextField label="Company location" value={newCompanyLocation} onChange={(e) => setNewCompanyLocation(e.target.value)} />
|
||||
<TextField label="Company source" value={newCompanySource} onChange={(e) => setNewCompanySource(e.target.value)} />
|
||||
<Box sx={{ gridColumn: "1 / -1" }}>
|
||||
<Button variant="outlined" onClick={() => void createCompany()}>
|
||||
Create "{normalizedCompanyName}"
|
||||
@@ -276,6 +323,22 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{duplicateCheck?.hasDuplicates ? (
|
||||
<Alert severity="warning" sx={{ mt: 2 }}>
|
||||
<Typography sx={{ fontWeight: 800, mb: 0.75 }}>Possible duplicates found</Typography>
|
||||
<List dense sx={{ py: 0 }}>
|
||||
{duplicateCheck.matches.map((match) => (
|
||||
<ListItem key={match.id} sx={{ px: 0 }}>
|
||||
<ListItemText
|
||||
primary={`${match.company} - ${match.jobTitle}`}
|
||||
secondary={`${match.reason} • ${match.status} • ${new Date(match.dateApplied).toLocaleDateString()}`}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Typography variant="overline" sx={{ display: "block" }}>
|
||||
@@ -283,25 +346,14 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 2, mt: 1 }}>
|
||||
<TextField
|
||||
label="Job URL"
|
||||
value={jobUrl}
|
||||
onChange={(e) => setJobUrl(e.target.value)}
|
||||
sx={{ gridColumn: "1 / -1" }}
|
||||
/>
|
||||
<TextField label="Job URL" value={jobUrl} onChange={(e) => setJobUrl(e.target.value)} sx={{ gridColumn: "1 / -1" }} />
|
||||
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end" }}>
|
||||
<Button onClick={() => void importFromUrl()} disabled={importing || !jobUrl.trim()}>
|
||||
{importing ? "Importing..." : "Import from URL"}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
label="Date applied"
|
||||
type="date"
|
||||
value={dateApplied}
|
||||
onChange={(e) => setDateApplied(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<TextField label="Date applied" type="date" value={dateApplied} onChange={(e) => setDateApplied(e.target.value)} InputLabelProps={{ shrink: true }} />
|
||||
|
||||
<TextField select label="Status" value={status} onChange={(e) => setStatus(e.target.value as any)}>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
@@ -317,94 +369,41 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
<TextField label="Salary" value={salary} onChange={(e) => setSalary(e.target.value)} />
|
||||
<TextField label="Next action" value={nextAction} onChange={(e) => setNextAction(e.target.value)} />
|
||||
|
||||
<TextField
|
||||
label="Follow up"
|
||||
type="date"
|
||||
value={followUpAt}
|
||||
onChange={(e) => setFollowUpAt(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<TextField label="Follow up" type="date" value={followUpAt} onChange={(e) => setFollowUpAt(e.target.value)} InputLabelProps={{ shrink: true }} />
|
||||
|
||||
<TextField
|
||||
label="Deadline"
|
||||
type="date"
|
||||
value={deadline}
|
||||
onChange={(e) => setDeadline(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<TextField label="Deadline" type="date" value={deadline} onChange={(e) => setDeadline(e.target.value)} InputLabelProps={{ shrink: true }} />
|
||||
|
||||
<Box sx={{ gridColumn: "1 / -1" }}>
|
||||
<TagsInput value={tags} onChange={setTags} />
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
label="Description (original)"
|
||||
multiline
|
||||
rows={6}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
sx={{ gridColumn: "1 / -1" }}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Translated description"
|
||||
multiline
|
||||
rows={6}
|
||||
value={translatedDescription}
|
||||
onChange={(e) => setTranslatedDescription(e.target.value)}
|
||||
sx={{ gridColumn: "1 / -1" }}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Description language (optional)"
|
||||
value={descriptionLanguage}
|
||||
onChange={(e) => setDescriptionLanguage(e.target.value)}
|
||||
sx={{ gridColumn: "1 / -1" }}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Notes"
|
||||
multiline
|
||||
rows={3}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
sx={{ gridColumn: "1 / -1" }}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Cover letter"
|
||||
multiline
|
||||
rows={6}
|
||||
value={coverLetter}
|
||||
onChange={(e) => setCoverLetter(e.target.value)}
|
||||
sx={{ gridColumn: "1 / -1" }}
|
||||
/>
|
||||
<TextField label="Description (original)" multiline rows={6} value={description} onChange={(e) => setDescription(e.target.value)} sx={{ gridColumn: "1 / -1" }} />
|
||||
<TextField label="Translated description" multiline rows={6} value={translatedDescription} onChange={(e) => setTranslatedDescription(e.target.value)} sx={{ gridColumn: "1 / -1" }} />
|
||||
<TextField label="Description language (optional)" value={descriptionLanguage} onChange={(e) => setDescriptionLanguage(e.target.value)} sx={{ gridColumn: "1 / -1" }} />
|
||||
<TextField label="Notes" multiline rows={3} value={notes} onChange={(e) => setNotes(e.target.value)} sx={{ gridColumn: "1 / -1" }} />
|
||||
<TextField label="Cover letter" multiline rows={6} value={coverLetter} onChange={(e) => setCoverLetter(e.target.value)} sx={{ gridColumn: "1 / -1" }} />
|
||||
|
||||
<Box sx={{ gridColumn: "1 / -1" }}>
|
||||
<Typography variant="overline" sx={{ display: "block", mt: 1 }}>
|
||||
Attachments checklist
|
||||
</Typography>
|
||||
<Typography variant="overline" sx={{ display: "block", mt: 1 }}>Attachments checklist</Typography>
|
||||
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
|
||||
<FormControlLabel control={<Checkbox checked={hasResume} onChange={(e) => setHasResume(e.target.checked)} />} label="Resume" />
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={hasCoverLetter} onChange={(e) => setHasCoverLetter(e.target.checked)} />}
|
||||
label="Cover letter"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={hasPortfolio} onChange={(e) => setHasPortfolio(e.target.checked)} />}
|
||||
label="Portfolio"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={hasOtherAttachment} onChange={(e) => setHasOtherAttachment(e.target.checked)} />}
|
||||
label="Other"
|
||||
/>
|
||||
<FormControlLabel control={<Checkbox checked={hasCoverLetter} onChange={(e) => setHasCoverLetter(e.target.checked)} />} label="Cover letter" />
|
||||
<FormControlLabel control={<Checkbox checked={hasPortfolio} onChange={(e) => setHasPortfolio(e.target.checked)} />} label="Portfolio" />
|
||||
<FormControlLabel control={<Checkbox checked={hasOtherAttachment} onChange={(e) => setHasOtherAttachment(e.target.checked)} />} label="Other" />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end", mt: 1 }}>
|
||||
<Button variant="contained" onClick={() => void createJob()} disabled={saving || !canSave}>
|
||||
{saving ? "Adding..." : "Add job"}
|
||||
</Button>
|
||||
<Box sx={{ gridColumn: "1 / -1", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 2, mt: 1, flexWrap: "wrap" }}>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={saveAndAddAnother} onChange={(e) => setSaveAndAddAnother(e.target.checked)} />}
|
||||
label="Save and add another"
|
||||
/>
|
||||
<Box sx={{ display: "flex", gap: 1 }}>
|
||||
<Button variant="outlined" onClick={onClose}>Cancel</Button>
|
||||
<Button variant="contained" onClick={() => void createJob()} disabled={saving || !canSave}>
|
||||
{saving ? "Adding..." : saveAndAddAnother ? "Save and continue" : "Add job"}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
|
||||
@@ -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<JobStats | null>(null);
|
||||
const [overview, setOverview] = useState<OverviewAnalytics | null>(null);
|
||||
const [tagTrends, setTagTrends] = useState<TagTrendResponse | null>(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<AnalyticsPoint[]>([]);
|
||||
const [tags, setTags] = useState<TagPoint[]>([]);
|
||||
const [summarizerMetrics, setSummarizerMetrics] = useState<SummarizerMetrics | null>(null);
|
||||
const [prefs, setPrefs] = useState<Prefs>(() => loadPrefs());
|
||||
const [prefsAnchor, setPrefsAnchor] = useState<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<JobStats>("/jobapplications/stats").then((r) => setStats(r.data));
|
||||
api.get<OverviewAnalytics>("/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<AnalyticsPoint[]>("/jobapplications/analytics", { params })
|
||||
.then((r) => setAnalytics(r.data ?? []))
|
||||
.catch(() => setAnalytics([]));
|
||||
|
||||
api
|
||||
.get<TagPoint[]>("/jobapplications/tags", { params: { limit: 10, ...params } })
|
||||
.then((r) => setTags(r.data ?? []))
|
||||
.catch(() => setTags([]));
|
||||
api.get<AnalyticsPoint[]>("/jobapplications/analytics", { params }).then((r) => setAnalytics(r.data ?? [])).catch(() => setAnalytics([]));
|
||||
api.get<TagPoint[]>("/jobapplications/tags", { params: { limit: 10, ...params } }).then((r) => setTags(r.data ?? [])).catch(() => setTags([]));
|
||||
api.get<TagTrendResponse>("/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<SummarizerMetrics>("/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 }) => (
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
minHeight: 118,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary", lineHeight: 1.4 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="h4" sx={{ mt: 0.5, fontWeight: 950, lineHeight: 1.1 }}>
|
||||
{value}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.75 }}>
|
||||
{sub}
|
||||
</Typography>
|
||||
<Box sx={{ p: 2, minHeight: 118, display: "flex", flexDirection: "column", justifyContent: "center" }}>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>{label}</Typography>
|
||||
<Typography variant="h4" sx={{ mt: 0.5, fontWeight: 950 }}>{value}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.75 }}>{sub}</Typography>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -234,167 +198,179 @@ export default function DashboardView() {
|
||||
<Tab label="Summarizer" />
|
||||
</Tabs>
|
||||
|
||||
{tab !== 2 ? (
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 1 }}>
|
||||
<Button variant="outlined" startIcon={<TuneIcon />} onClick={(e) => setPrefsAnchor(e.currentTarget)}>
|
||||
Customize dashboard
|
||||
</Button>
|
||||
<Menu anchorEl={prefsAnchor} open={Boolean(prefsAnchor)} onClose={() => setPrefsAnchor(null)}>
|
||||
{[
|
||||
["cards", "Summary cards"],
|
||||
["activity", "Activity chart"],
|
||||
["funnel", "Conversion funnel"],
|
||||
["companies", "Top companies"],
|
||||
["skills", "Skills insights"],
|
||||
].map(([key, label]) => (
|
||||
<MenuItem key={key} onClick={() => togglePref(key as keyof Prefs)}>
|
||||
<Checkbox checked={prefs[key as keyof Prefs]} />
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{tab === 0 ? (
|
||||
<>
|
||||
<Paper sx={{ p: 0.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "repeat(2, 1fr)", xl: "repeat(5, 1fr)" },
|
||||
}}
|
||||
>
|
||||
{metricCards.map((m, idx) => (
|
||||
<Box
|
||||
key={m.label}
|
||||
sx={{
|
||||
borderLeft: { xs: "none", xl: idx === 0 ? "none" : `1px solid ${theme.palette.divider}` },
|
||||
borderTop: {
|
||||
xs: idx === 0 ? "none" : `1px solid ${theme.palette.divider}`,
|
||||
sm: idx < 2 ? "none" : `1px solid ${theme.palette.divider}`,
|
||||
xl: "none",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<StatCard label={m.label} value={m.value} sub={m.sub} />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ mt: 2, p: 2.25 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950 }}>
|
||||
Application activity
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
Color-coded monthly trend for applications and responses.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", alignItems: "center", justifyContent: "flex-end", gap: 1 }}>
|
||||
<ButtonGroup size="small" variant="outlined">
|
||||
{([6, 12, 24] as const).map((m) => (
|
||||
<Button
|
||||
key={m}
|
||||
variant={rangeMode === "preset" && months === m ? "contained" : "outlined"}
|
||||
onClick={() => {
|
||||
setRangeMode("preset");
|
||||
setMonths(m);
|
||||
}}
|
||||
>
|
||||
{m} mo
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
variant={rangeMode === "custom" ? "contained" : "outlined"}
|
||||
onClick={() => {
|
||||
setRangeMode("custom");
|
||||
setAppliedCustom({ from: fromMonth, to: toMonth });
|
||||
}}
|
||||
>
|
||||
Custom
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
|
||||
{rangeMode === "custom" ? (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||
<TextField size="small" label="From" type="month" value={fromMonth} onChange={(e) => setFromMonth(e.target.value)} sx={{ width: 150 }} />
|
||||
<TextField size="small" label="To" type="month" value={toMonth} onChange={(e) => setToMonth(e.target.value)} sx={{ width: 150 }} />
|
||||
<Button size="small" variant="contained" onClick={() => setAppliedCustom({ from: fromMonth, to: toMonth })}>
|
||||
Apply
|
||||
</Button>
|
||||
{prefs.cards ? (
|
||||
<Paper sx={{ p: 0.5 }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, 1fr)", xl: "repeat(5, 1fr)" } }}>
|
||||
{metricCards.map((m, idx) => (
|
||||
<Box key={m.label} sx={{ borderLeft: { xs: "none", xl: idx === 0 ? "none" : `1px solid ${theme.palette.divider}` }, borderTop: { xs: idx === 0 ? "none" : `1px solid ${theme.palette.divider}`, sm: idx < 2 ? "none" : `1px solid ${theme.palette.divider}`, xl: "none" } }}>
|
||||
<StatCard label={m.label} value={m.value} sub={m.sub} />
|
||||
</Box>
|
||||
) : null}
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap", mt: 1.5 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<Box sx={{ width: 12, height: 12, borderRadius: 999, bgcolor: appliedColor }} />
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>Applied</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<Box sx={{ width: 12, height: 12, borderRadius: 999, bgcolor: responsesColor }} />
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>Responses</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 2, overflowX: "auto" }}>
|
||||
<Box sx={{ minWidth: chartW }}>
|
||||
<svg width={chartW} height={chartH} viewBox={`0 0 ${chartW} ${chartH}`}>
|
||||
<defs>
|
||||
<linearGradient id="fillApplied" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor={alpha(appliedColor, 0.35)} />
|
||||
<stop offset="100%" stopColor={alpha(appliedColor, 0.03)} />
|
||||
</linearGradient>
|
||||
<linearGradient id="fillResponses" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor={alpha(responsesColor, 0.28)} />
|
||||
<stop offset="100%" stopColor={alpha(responsesColor, 0.03)} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{[0.25, 0.5, 0.75].map((tick) => (
|
||||
<line
|
||||
key={tick}
|
||||
x1="0"
|
||||
x2={chartW}
|
||||
y1={Math.round(chartH * tick)}
|
||||
y2={Math.round(chartH * tick)}
|
||||
stroke={alpha(theme.palette.text.primary, 0.08)}
|
||||
strokeDasharray="5 5"
|
||||
/>
|
||||
))}
|
||||
|
||||
{pResponses ? (
|
||||
{prefs.activity ? (
|
||||
<Paper sx={{ mt: 2, p: 2.25 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950 }}>Application activity</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>Monthly applications versus responses.</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 1 }}>
|
||||
<ButtonGroup size="small">
|
||||
{([6, 12, 24] as const).map((m) => (
|
||||
<Button key={m} variant={rangeMode === "preset" && months === m ? "contained" : "outlined"} onClick={() => { setRangeMode("preset"); setMonths(m); }}>{m} mo</Button>
|
||||
))}
|
||||
<Button variant={rangeMode === "custom" ? "contained" : "outlined"} onClick={() => { setRangeMode("custom"); setAppliedCustom({ from: fromMonth, to: toMonth }); }}>Custom</Button>
|
||||
</ButtonGroup>
|
||||
{rangeMode === "custom" ? (
|
||||
<>
|
||||
<path d={`${pResponses} L ${chartW} ${chartH} L 0 ${chartH} Z`} fill="url(#fillResponses)" />
|
||||
<path d={pResponses} fill="none" stroke={alpha(responsesColor, 0.95)} strokeWidth="2.5" />
|
||||
<TextField size="small" label="From" type="month" value={fromMonth} onChange={(e) => setFromMonth(e.target.value)} />
|
||||
<TextField size="small" label="To" type="month" value={toMonth} onChange={(e) => setToMonth(e.target.value)} />
|
||||
<Button size="small" variant="contained" onClick={() => setAppliedCustom({ from: fromMonth, to: toMonth })}>Apply</Button>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{pApplied ? (
|
||||
<>
|
||||
<path d={`${pApplied} L ${chartW} ${chartH} L 0 ${chartH} Z`} fill="url(#fillApplied)" />
|
||||
<path d={pApplied} fill="none" stroke={alpha(appliedColor, 0.95)} strokeWidth="2.5" />
|
||||
</>
|
||||
) : null}
|
||||
</svg>
|
||||
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", mt: 1, color: "text.secondary" }}>
|
||||
{analytics.map((p) => (
|
||||
<Typography key={p.month} variant="caption" sx={{ width: `${100 / Math.max(1, analytics.length)}%`, textAlign: "center" }}>
|
||||
{p.month.slice(5)}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
<Box sx={{ mt: 2, overflowX: "auto" }}>
|
||||
<Box sx={{ minWidth: chartW }}>
|
||||
<svg width={chartW} height={chartH} viewBox={`0 0 ${chartW} ${chartH}`}>
|
||||
{[0.25, 0.5, 0.75].map((tick) => <line key={tick} x1="0" x2={chartW} y1={Math.round(chartH * tick)} y2={Math.round(chartH * tick)} stroke={alpha(theme.palette.text.primary, 0.08)} strokeDasharray="5 5" />)}
|
||||
{responsePath ? <path d={responsePath} fill="none" stroke={alpha(theme.palette.info.main, 0.95)} strokeWidth="2.5" /> : null}
|
||||
{appliedPath ? <path d={appliedPath} fill="none" stroke={alpha(theme.palette.success.main, 0.95)} strokeWidth="2.5" /> : null}
|
||||
</svg>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", mt: 1 }}>{analytics.map((p) => <Typography key={p.month} variant="caption" sx={{ width: `${100 / Math.max(1, analytics.length)}%`, textAlign: "center" }}>{p.month.slice(5)}</Typography>)}</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
<Box sx={{ mt: 2, display: "grid", gridTemplateColumns: { xs: "1fr", lg: "repeat(3, 1fr)" }, gap: 2 }}>
|
||||
<Paper sx={{ p: 2.25 }}>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>Top skill</Typography>
|
||||
<Typography variant="h5" sx={{ mt: 0.5, fontWeight: 950 }}>{topSkill?.tag ?? "No tags yet"}</Typography>
|
||||
<Typography variant="body2" sx={{ mt: 1, color: "text.secondary" }}>
|
||||
{topSkill ? `${topSkill.count} tagged jobs in the selected range.` : "Import or add tagged jobs to surface skill trends."}
|
||||
</Typography>
|
||||
</Paper>
|
||||
<Paper sx={{ p: 2.25 }}>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>Busiest month</Typography>
|
||||
<Typography variant="h5" sx={{ mt: 0.5, fontWeight: 950 }}>{strongestMonth?.month ?? "-"}</Typography>
|
||||
<Typography variant="body2" sx={{ mt: 1, color: "text.secondary" }}>
|
||||
{strongestMonth ? `${strongestMonth.applied} applications logged in your strongest month.` : "No monthly application data yet."}
|
||||
</Typography>
|
||||
</Paper>
|
||||
<Paper sx={{ p: 2.25 }}>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>Top stage</Typography>
|
||||
<Typography variant="h5" sx={{ mt: 0.5, fontWeight: 950 }}>{topStatus?.[0] ?? "No status data"}</Typography>
|
||||
<Typography variant="body2" sx={{ mt: 1, color: "text.secondary" }}>
|
||||
{topStatus ? `${topStatus[1]} applications are currently clustered in this stage.` : "Add a few jobs to reveal your pipeline shape."}
|
||||
</Typography>
|
||||
</Paper>
|
||||
<Box sx={{ mt: 2, display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr" }, gap: 2 }}>
|
||||
{prefs.funnel ? (
|
||||
<Paper sx={{ p: 2.25 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>Conversion funnel</Typography>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||
{(overview?.funnel ?? []).map((item) => (
|
||||
<Box key={item.label} sx={{ display: "grid", gridTemplateColumns: "140px 1fr 50px", gap: 1, alignItems: "center" }}>
|
||||
<Typography sx={{ fontWeight: 800 }}>{item.label}</Typography>
|
||||
<Box sx={{ height: 10, borderRadius: 999, bgcolor: alpha(theme.palette.text.primary, 0.08), overflow: "hidden" }}>
|
||||
<Box sx={{ width: `${funnelMax ? clamp((item.count / funnelMax) * 100, 0, 100) : 0}%`, height: "100%", background: `linear-gradient(90deg, ${alpha(theme.palette.primary.main, 0.9)}, ${alpha(theme.palette.primary.main, 0.3)})` }} />
|
||||
</Box>
|
||||
<Typography sx={{ textAlign: "right", fontWeight: 900 }}>{item.count}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 2 }}>Response sources</Typography>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1, mt: 1 }}>
|
||||
{(overview?.responseRateBySource ?? []).map((item) => (
|
||||
<Box key={item.label} sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}>
|
||||
<Typography variant="body2">{item.label}</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 800 }}>{item.rate}%</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
{prefs.companies ? (
|
||||
<Paper sx={{ p: 2.25 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>Top companies by activity</Typography>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||
{(overview?.topCompanies ?? []).map((item) => (
|
||||
<Box key={item.companyId} sx={{ display: "grid", gridTemplateColumns: "1fr auto auto", gap: 2, alignItems: "center" }}>
|
||||
<Typography sx={{ fontWeight: 800 }}>{item.company}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{item.count} jobs</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 900 }}>{item.responseRate}%</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
{prefs.skills ? (
|
||||
<Paper sx={{ mt: 2, p: 2.25 }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr" }, gap: 2.5 }}>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>Top skills</Typography>
|
||||
{tags.length === 0 ? <Typography sx={{ color: "text.secondary" }}>No tags yet.</Typography> : (
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "132px 1fr", gap: 2, alignItems: "center" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "center" }}>
|
||||
<svg width="132" height="132" viewBox="0 0 132 132">
|
||||
<circle cx="66" cy="66" r="52" stroke={alpha(theme.palette.text.primary, 0.1)} strokeWidth="14" fill="none" />
|
||||
{(() => {
|
||||
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 = <circle key={t.tag} cx="66" cy="66" r={r} fill="none" stroke={tagColors[i % tagColors.length]} strokeWidth="14" strokeDasharray={`${len} ${circ}`} strokeDashoffset={-offset} transform="rotate(-90 66 66)" />;
|
||||
offset += len;
|
||||
return el;
|
||||
});
|
||||
})()}
|
||||
<circle cx="66" cy="66" r="39" fill={theme.palette.background.paper} />
|
||||
<text x="66" y="62" textAnchor="middle" fontSize="16" fontWeight="900" fill={theme.palette.text.primary}>{tagTotal}</text>
|
||||
<text x="66" y="80" textAnchor="middle" fontSize="11" fill={alpha(theme.palette.text.primary, 0.65)}>skill tags</text>
|
||||
</svg>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75 }}>
|
||||
{tags.slice(0, 8).map((t, i) => <Box key={t.tag} sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}><Box sx={{ display: "flex", alignItems: "center", gap: 1 }}><Box sx={{ width: 10, height: 10, borderRadius: 999, bgcolor: tagColors[i % tagColors.length] }} /><Typography variant="body2" sx={{ fontWeight: 700 }}>{t.tag}</Typography></Box><Typography variant="body2" sx={{ fontWeight: 900 }}>{t.count}</Typography></Box>)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>Skill trends</Typography>
|
||||
{!tagTrends || tagTrends.series.length === 0 ? <Typography sx={{ color: "text.secondary" }}>No tag trend data yet.</Typography> : (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||
{tagTrends.series.map((series, idx) => (
|
||||
<Box key={series.tag}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 0.5 }}>
|
||||
<Typography sx={{ fontWeight: 800 }}>{series.tag}</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>{series.counts.reduce((a, b) => a + b, 0)} total</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: `repeat(${series.counts.length}, 1fr)`, gap: 0.5 }}>
|
||||
{series.counts.map((count, i) => (
|
||||
<Box key={`${series.tag}-${i}`} sx={{ height: 14, borderRadius: 1, bgcolor: count > 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}`} />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: `repeat(${tagTrends.months.length}, 1fr)`, gap: 0.5 }}>
|
||||
{tagTrends.months.map((month) => <Typography key={month} variant="caption" sx={{ textAlign: "center", color: "text.secondary" }}>{month.slice(5)}</Typography>)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
@@ -402,214 +378,48 @@ export default function DashboardView() {
|
||||
<Paper sx={{ p: 2.25 }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1.2fr 0.8fr" }, gap: 2.5 }}>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>
|
||||
Status breakdown
|
||||
</Typography>
|
||||
|
||||
{statusRows.length === 0 ? (
|
||||
<Typography sx={{ color: "text.secondary" }}>No data yet.</Typography>
|
||||
) : (
|
||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>Status breakdown</Typography>
|
||||
{statusRows.length === 0 ? <Typography sx={{ color: "text.secondary" }}>No data yet.</Typography> : (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||
{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 (
|
||||
<Box key={status} sx={{ display: "grid", gridTemplateColumns: "160px 1fr 60px", gap: 1, alignItems: "center" }}>
|
||||
<Typography sx={{ fontWeight: 850 }}>{status}</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
height: 10,
|
||||
borderRadius: 999,
|
||||
background: theme.palette.mode === "dark" ? alpha("#94a3b8", 0.16) : alpha("#0f172a", 0.08),
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: `${w}%`,
|
||||
height: "100%",
|
||||
background: `linear-gradient(90deg, ${alpha(tone, 0.85)}, ${alpha(tone, 0.32)})`,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Typography sx={{ textAlign: "right", fontWeight: 900 }}>{value}</Typography>
|
||||
</Box>
|
||||
);
|
||||
return <Box key={status} sx={{ display: "grid", gridTemplateColumns: "160px 1fr 60px", gap: 1, alignItems: "center" }}><Typography sx={{ fontWeight: 850 }}>{status}</Typography><Box sx={{ height: 10, borderRadius: 999, background: alpha(theme.palette.text.primary, 0.08), overflow: "hidden" }}><Box sx={{ width: `${w}%`, height: "100%", background: `linear-gradient(90deg, ${alpha(tone, 0.85)}, ${alpha(tone, 0.32)})` }} /></Box><Typography sx={{ textAlign: "right", fontWeight: 900 }}>{value}</Typography></Box>;
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, 1fr)" }, gap: 1.5, mt: 2.25 }}>
|
||||
<Paper variant="outlined" sx={{ p: 1.75, borderRadius: 2.5 }}>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>Applications in range</Typography>
|
||||
<Typography variant="h5" sx={{ fontWeight: 950 }}>{totalAppliedInRange}</Typography>
|
||||
</Paper>
|
||||
<Paper variant="outlined" sx={{ p: 1.75, borderRadius: 2.5 }}>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>Tracked skills</Typography>
|
||||
<Typography variant="h5" sx={{ fontWeight: 950 }}>{tagTotal}</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>
|
||||
Top skills
|
||||
</Typography>
|
||||
|
||||
{tags.length === 0 ? (
|
||||
<Typography sx={{ color: "text.secondary" }}>No tags yet.</Typography>
|
||||
) : (
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "132px 1fr", gap: 2, alignItems: "center" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "center" }}>
|
||||
<svg width="132" height="132" viewBox="0 0 132 132">
|
||||
<circle cx="66" cy="66" r="52" stroke={alpha(theme.palette.text.primary, 0.10)} strokeWidth="14" fill="none" />
|
||||
{(() => {
|
||||
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 = (
|
||||
<circle
|
||||
key={t.tag}
|
||||
cx="66"
|
||||
cy="66"
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke={tagColors[i % tagColors.length]}
|
||||
strokeWidth="14"
|
||||
strokeDasharray={`${len} ${circ}`}
|
||||
strokeDashoffset={-offset}
|
||||
transform="rotate(-90 66 66)"
|
||||
/>
|
||||
);
|
||||
offset += len;
|
||||
return el;
|
||||
});
|
||||
})()}
|
||||
<circle cx="66" cy="66" r="39" fill={theme.palette.background.paper} />
|
||||
<text x="66" y="62" textAnchor="middle" fontSize="16" fontWeight="900" fill={theme.palette.text.primary}>
|
||||
{tagTotal}
|
||||
</text>
|
||||
<text x="66" y="80" textAnchor="middle" fontSize="11" fill={alpha(theme.palette.text.primary, 0.65)}>
|
||||
skill tags
|
||||
</text>
|
||||
</svg>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>Response rate by source</Typography>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||
{(overview?.responseRateBySource ?? []).map((item) => (
|
||||
<Box key={item.label} sx={{ p: 1.25, border: "1px solid", borderColor: "divider", borderRadius: 2 }}>
|
||||
<Typography sx={{ fontWeight: 800 }}>{item.label}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{item.responses} responses from {item.total} jobs</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 900, mt: 0.5 }}>{item.rate}%</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75, minWidth: 0 }}>
|
||||
{tags.slice(0, 8).map((t, i) => (
|
||||
<Box key={t.tag} sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, minWidth: 0 }}>
|
||||
<Box sx={{ width: 10, height: 10, borderRadius: 999, bgcolor: tagColors[i % tagColors.length], flex: "0 0 auto" }} />
|
||||
<Typography variant="body2" noWrap sx={{ fontWeight: 700 }}>
|
||||
{t.tag}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 900, flex: "0 0 auto" }}>
|
||||
{t.count}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
{tab === 2 ? (
|
||||
<>
|
||||
<Paper sx={{ p: 0.5 }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, 1fr)", xl: "repeat(4, 1fr)" } }}>
|
||||
{[
|
||||
{
|
||||
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) => (
|
||||
<Box key={m.label} sx={{ borderLeft: { xs: "none", xl: idx === 0 ? "none" : `1px solid ${theme.palette.divider}` }, borderTop: { xs: idx === 0 ? "none" : `1px solid ${theme.palette.divider}`, sm: idx < 2 ? "none" : `1px solid ${theme.palette.divider}`, xl: "none" } }}>
|
||||
<StatCard label={m.label} value={m.value} sub={m.sub} />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Paper sx={{ p: 2.25 }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(4, 1fr)" }, gap: 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: "Last success", value: formatRelative(summarizerMetrics?.lastSuccessAt), sub: "Recent successful summary request" }].map((m) => <Paper key={m.label} variant="outlined" sx={{ p: 2 }}><Typography variant="overline" sx={{ color: "text.secondary" }}>{m.label}</Typography><Typography variant="h5" sx={{ fontWeight: 950 }}>{m.value}</Typography><Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>{m.sub}</Typography></Paper>)}
|
||||
</Box>
|
||||
<Paper variant="outlined" sx={{ p: 2, mt: 2 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>Telemetry</Typography>
|
||||
<Typography variant="body2"><strong>Requests:</strong> {summarizerMetrics?.requests ?? 0}</Typography>
|
||||
<Typography variant="body2"><strong>Cache hits:</strong> {summarizerMetrics?.cacheHits ?? 0}</Typography>
|
||||
<Typography variant="body2"><strong>Cache misses:</strong> {summarizerMetrics?.cacheMisses ?? 0}</Typography>
|
||||
<Typography variant="body2"><strong>Failures:</strong> {summarizerMetrics?.failures ?? 0}</Typography>
|
||||
<Typography variant="body2"><strong>Last failure:</strong> {formatRelative(summarizerMetrics?.lastFailureAt)}</Typography>
|
||||
<Typography variant="body2" sx={{ mt: 1, color: summarizerMetrics?.lastError ? "warning.main" : "text.secondary" }}>{summarizerMetrics?.lastError || "No recent summarizer errors recorded."}</Typography>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ mt: 2, p: 2.25 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>
|
||||
Summarizer telemetry
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 2 }}>
|
||||
Useful for spotting slowdowns, cache misses, or service outages in the local summarizer app.
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(2, 1fr)" }, gap: 2 }}>
|
||||
<Paper variant="outlined" sx={{ p: 2, borderRadius: 2.5 }}>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>Requests</Typography>
|
||||
<Typography variant="h5" sx={{ fontWeight: 950, mb: 1.5 }}>{summarizerMetrics?.requests ?? 0}</Typography>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 1 }}>
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>Hits</Typography>
|
||||
<Typography sx={{ fontWeight: 900 }}>{summarizerMetrics?.cacheHits ?? 0}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>Misses</Typography>
|
||||
<Typography sx={{ fontWeight: 900 }}>{summarizerMetrics?.cacheMisses ?? 0}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>Failures</Typography>
|
||||
<Typography sx={{ fontWeight: 900 }}>{summarizerMetrics?.failures ?? 0}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Paper variant="outlined" sx={{ p: 2, borderRadius: 2.5 }}>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>Last activity</Typography>
|
||||
<Box sx={{ mt: 1.25, display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
<Typography variant="body2"><strong>Last success:</strong> {formatRelative(summarizerMetrics?.lastSuccessAt)}</Typography>
|
||||
<Typography variant="body2"><strong>Last failure:</strong> {formatRelative(summarizerMetrics?.lastFailureAt)}</Typography>
|
||||
<Typography variant="body2"><strong>Model:</strong> {summarizerMetrics?.model || "Unknown"}</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>Last error</Typography>
|
||||
<Typography variant="body2" sx={{ mt: 0.75, color: summarizerMetrics?.lastError ? "warning.main" : "text.secondary" }}>
|
||||
{summarizerMetrics?.lastError || "No recent summarizer errors recorded."}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</>
|
||||
</Paper>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -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<JobApplication | null>(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<FollowUpDraft | null>(null);
|
||||
const [loadingDraft, setLoadingDraft] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !jobId) return;
|
||||
setTab(0);
|
||||
setFollowUpDraft(null);
|
||||
api.get<JobApplication>(`/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<FollowUpDraft>(`/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 (
|
||||
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
|
||||
<DialogTitle>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 2,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
|
||||
<Typography variant="h6">{title}</Typography>
|
||||
{job && <Chip label={job.status} color={statusChipColor(job.status)} size="small" />}
|
||||
</Box>
|
||||
@@ -93,6 +111,7 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
||||
<Tab label="Correspondence" />
|
||||
<Tab label="Attachments" />
|
||||
<Tab label="Cover Letter" />
|
||||
<Tab label="Follow-up draft" />
|
||||
{isAdmin ? <Tab label="History" /> : null}
|
||||
</Tabs>
|
||||
|
||||
@@ -129,19 +148,13 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
||||
<Box>
|
||||
<Typography variant="overline">Tags</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.5 }}>
|
||||
{tags.length === 0 ? (
|
||||
<Typography sx={{ color: "text.secondary" }}>-</Typography>
|
||||
) : (
|
||||
tags.map((t) => <Chip key={t} label={t} size="small" />)
|
||||
)}
|
||||
{tags.length === 0 ? <Typography sx={{ color: "text.secondary" }}>-</Typography> : tags.map((t) => <Chip key={t} label={t} size="small" />)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ gridColumn: "1 / -1" }}>
|
||||
<Typography variant="overline">Attachment Types</Typography>
|
||||
<Typography>{checklist}</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ gridColumn: "1 / -1" }}>
|
||||
<Typography variant="overline">Job URL</Typography>
|
||||
<Typography>
|
||||
@@ -154,26 +167,22 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
||||
)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ gridColumn: "1 / -1" }}>
|
||||
<Typography variant="overline">Description (original)</Typography>
|
||||
<Typography sx={{ whiteSpace: "pre-wrap" }}>{job?.description ?? ""}</Typography>
|
||||
</Box>
|
||||
|
||||
{job?.translatedDescription ? (
|
||||
<Box sx={{ gridColumn: "1 / -1" }}>
|
||||
<Typography variant="overline">Translated description</Typography>
|
||||
<Typography sx={{ whiteSpace: "pre-wrap" }}>{job.translatedDescription}</Typography>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{job?.fullSummary || job?.shortSummary ? (
|
||||
<Box sx={{ gridColumn: "1 / -1", mt: 1 }}>
|
||||
<Typography variant="overline">Summary</Typography>
|
||||
<Typography sx={{ whiteSpace: "pre-wrap" }}>{job?.fullSummary ?? job?.shortSummary}</Typography>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<Box sx={{ gridColumn: "1 / -1" }}>
|
||||
<Typography variant="overline">Notes</Typography>
|
||||
<Typography sx={{ whiteSpace: "pre-wrap" }}>{job?.notes ?? ""}</Typography>
|
||||
@@ -186,20 +195,53 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
||||
{tab === 3 && (
|
||||
<Box>
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 1 }}>
|
||||
<button onClick={() => navigator.clipboard.writeText(job?.coverLetterText || "")}>Copy</button>
|
||||
<Button size="small" variant="outlined" onClick={() => navigator.clipboard.writeText(job?.coverLetterText || "")}>Copy</Button>
|
||||
</Box>
|
||||
<Typography sx={{ whiteSpace: "pre-wrap" }}>{job?.coverLetterText ?? ""}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{tab === 4 && (
|
||||
<Box>
|
||||
{loadingDraft ? (
|
||||
<Box sx={{ py: 4, display: "flex", justifyContent: "center" }}>
|
||||
<CircularProgress size={28} />
|
||||
</Box>
|
||||
) : followUpDraft ? (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="overline">Reason</Typography>
|
||||
<Typography>{followUpDraft.reason}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="overline">Suggested send date</Typography>
|
||||
<Typography>{new Date(followUpDraft.suggestedSendOn).toLocaleDateString()}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="overline">Subject</Typography>
|
||||
<Typography sx={{ fontWeight: 700 }}>{followUpDraft.subject}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="overline">Draft</Typography>
|
||||
<Typography sx={{ whiteSpace: "pre-wrap" }}>{followUpDraft.body}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<Button variant="contained" onClick={() => navigator.clipboard.writeText(`${followUpDraft.subject}\n\n${followUpDraft.body}`)}>
|
||||
Copy draft
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography sx={{ color: "text.secondary" }}>No draft available.</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{tab === 4 && isAdmin && (
|
||||
{tab === 5 && isAdmin && (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
{history.length === 0 ? (
|
||||
<Typography sx={{ color: "text.secondary" }}>No history yet.</Typography>
|
||||
) : (
|
||||
history.map((e) => (
|
||||
<PaperRow key={e.id} type={e.type} oldValue={e.oldValue} newValue={e.newValue} at={e.at} note={e.note} />
|
||||
))
|
||||
history.map((e) => <PaperRow key={e.id} type={e.type} oldValue={e.oldValue} newValue={e.newValue} at={e.at} note={e.note} />)
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
@@ -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 (
|
||||
<Box
|
||||
sx={{
|
||||
border: "1px solid rgba(15,23,42,0.08)",
|
||||
borderRadius: 2,
|
||||
p: 1.25,
|
||||
background: "rgba(255,255,255,0.6)",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ border: "1px solid rgba(15,23,42,0.08)", borderRadius: 2, p: 1.25, background: "rgba(255,255,255,0.6)" }}>
|
||||
<Typography sx={{ fontWeight: 900, lineHeight: 1.25 }}>
|
||||
{type}
|
||||
{oldValue || newValue ? (
|
||||
<span style={{ fontWeight: 700, opacity: 0.7 }}>
|
||||
{" "}
|
||||
({oldValue ?? ""} {oldValue || newValue ? "->" : ""} {newValue ?? ""})
|
||||
{" "}({oldValue ?? ""} {oldValue || newValue ? "->" : ""} {newValue ?? ""})
|
||||
</span>
|
||||
) : null}
|
||||
</Typography>
|
||||
|
||||
@@ -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 (
|
||||
<Chip
|
||||
label={normalizeStatus(status)}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 24,
|
||||
fontWeight: 800,
|
||||
letterSpacing: "0.01em",
|
||||
color: alpha(c, theme.palette.mode === "dark" ? 0.95 : 0.92),
|
||||
backgroundColor: alpha(c, theme.palette.mode === "dark" ? 0.16 : 0.12),
|
||||
border: `1px solid ${alpha(c, theme.palette.mode === "dark" ? 0.35 : 0.22)}`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
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<JobApplication[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(0); // 0-based for TablePagination
|
||||
const [page, setPage] = useState(0);
|
||||
const [expanded, setExpanded] = useState<number[]>([]);
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSearch = useDebouncedValue(search, 250);
|
||||
const [includeDeleted, setIncludeDeleted] = useState(mode === "trash");
|
||||
const [columnsAnchor, setColumnsAnchor] = useState<null | HTMLElement>(
|
||||
null,
|
||||
);
|
||||
const [columnsAnchor, setColumnsAnchor] = useState<null | HTMLElement>(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<number | null>(null);
|
||||
const [editJobId, setEditJobId] = useState<number | null>(null);
|
||||
const [reloadToken, setReloadToken] = useState(0);
|
||||
const [fullSummaries, setFullSummaries] = useState<Record<number, string | null | undefined>>({});
|
||||
const [loadingFull, setLoadingFull] = useState<Record<number, boolean>>({});
|
||||
const [statusAnchor, setStatusAnchor] = useState<null | HTMLElement>(null);
|
||||
const [statusJobId, setStatusJobId] = useState<number | null>(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<PagedResult<JobApplication>>("/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<PagedResult<JobApplication>>("/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<JobApplication>(`/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 (
|
||||
<Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
gap: 2,
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
mt: 2,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", gap: 2, alignItems: "center", justifyContent: "space-between", mt: 2, flexWrap: "wrap" }}>
|
||||
<TextField
|
||||
label="Search"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||
placeholder="Title, company, notes, messages"
|
||||
size="small"
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon fontSize="small" />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
InputProps={{ startAdornment: <InputAdornment position="start"><SearchIcon fontSize="small" /></InputAdornment> }}
|
||||
sx={{ minWidth: 320, flex: "1 1 320px" }}
|
||||
/>
|
||||
|
||||
<FormControl sx={{ minWidth: 160 }} size="small">
|
||||
<InputLabel id="status-filter-label">Status</InputLabel>
|
||||
<Select
|
||||
labelId="status-filter-label"
|
||||
label="Status"
|
||||
value={statusFilter}
|
||||
onChange={(e) => {
|
||||
setStatusFilter(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
>
|
||||
{["All", "Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"].map((s) => (
|
||||
<MenuItem key={s} value={s}>
|
||||
{s}
|
||||
</MenuItem>
|
||||
))}
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select value={statusFilter} label="Status" onChange={(e) => { setStatusFilter(e.target.value); setPage(0); }}>
|
||||
{["All", "Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"].map((s) => <MenuItem key={s} value={s}>{s}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl sx={{ minWidth: 220 }} size="small">
|
||||
<InputLabel id="company-filter-label">Company</InputLabel>
|
||||
<Select
|
||||
labelId="company-filter-label"
|
||||
label="Company"
|
||||
value={companyFilterId}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value as unknown as number | "All";
|
||||
setCompanyFilterId(v);
|
||||
setPage(0);
|
||||
}}
|
||||
>
|
||||
<InputLabel>Company</InputLabel>
|
||||
<Select value={companyFilterId} label="Company" onChange={(e) => { setCompanyFilterId(e.target.value as any); setPage(0); }}>
|
||||
<MenuItem value="All">All</MenuItem>
|
||||
{companies.map((c) => (
|
||||
<MenuItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
{companies.map((c) => <MenuItem key={c.id} value={c.id}>{c.name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
label="Location"
|
||||
value={locationFilter}
|
||||
onChange={(e) => {
|
||||
setLocationFilter(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
sx={{ minWidth: 200, flex: "1 1 200px" }}
|
||||
/>
|
||||
<TextField label="Location" value={locationFilter} onChange={(e) => { setLocationFilter(e.target.value); setPage(0); }} sx={{ minWidth: 200, flex: "1 1 200px" }} />
|
||||
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
{mode === "jobs" && (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={needsFollowUpOnly}
|
||||
onChange={(e) => {
|
||||
setNeedsFollowUpOnly(e.target.checked);
|
||||
setPage(0);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label="Needs follow-up"
|
||||
/>
|
||||
)}
|
||||
|
||||
{mode === "jobs" && (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={includeDeleted}
|
||||
onChange={(e) => {
|
||||
setIncludeDeleted(e.target.checked);
|
||||
setPage(0);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label="Show deleted"
|
||||
/>
|
||||
)}
|
||||
|
||||
<SavedViewsMenu
|
||||
current={{
|
||||
q: search.trim().length ? search.trim() : undefined,
|
||||
status: statusFilter !== "All" ? statusFilter : undefined,
|
||||
companyId: companyFilterId === "All" ? undefined : (companyFilterId as number),
|
||||
location: locationFilter.trim().length ? locationFilter.trim() : undefined,
|
||||
needsFollowUp: needsFollowUpOnly ? true : undefined,
|
||||
}}
|
||||
onApply={(p: SavedViewParams) => {
|
||||
setSearch(p.q ?? "");
|
||||
setStatusFilter(p.status ?? "All");
|
||||
setCompanyFilterId(p.companyId ?? "All");
|
||||
setLocationFilter(p.location ?? "");
|
||||
setNeedsFollowUpOnly(Boolean(p.needsFollowUp));
|
||||
setPage(0);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Tooltip title="Columns">
|
||||
<IconButton onClick={(e) => setColumnsAnchor(e.currentTarget)}>
|
||||
<ViewColumnIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Menu
|
||||
anchorEl={columnsAnchor}
|
||||
open={Boolean(columnsAnchor)}
|
||||
onClose={() => setColumnsAnchor(null)}
|
||||
>
|
||||
{(
|
||||
[
|
||||
["status", "Status"],
|
||||
["dateApplied", "Date applied"],
|
||||
["daysSince", "Days"],
|
||||
["jobUrl", "Job URL"],
|
||||
] as const
|
||||
).map(([key, label]) => (
|
||||
<MenuItem
|
||||
key={key}
|
||||
onClick={() =>
|
||||
onColumnsChange({ ...columns, [key]: !columns[key] })
|
||||
}
|
||||
>
|
||||
<Checkbox checked={columns[key]} />
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||
{mode === "jobs" ? <FormControlLabel control={<Checkbox checked={needsFollowUpOnly} onChange={(e) => { setNeedsFollowUpOnly(e.target.checked); setPage(0); }} />} label="Needs follow-up" /> : null}
|
||||
{mode === "jobs" ? <FormControlLabel control={<Checkbox checked={includeDeleted} onChange={(e) => { setIncludeDeleted(e.target.checked); setPage(0); }} />} label="Show deleted" /> : null}
|
||||
<SavedViewsMenu current={{ q: search.trim() || undefined, status: statusFilter !== "All" ? statusFilter : undefined, companyId: companyFilterId === "All" ? undefined : (companyFilterId as number), location: locationFilter.trim() || undefined, needsFollowUp: needsFollowUpOnly ? true : undefined }} onApply={(p: SavedViewParams) => { setSearch(p.q ?? ""); setStatusFilter(p.status ?? "All"); setCompanyFilterId(p.companyId ?? "All"); setLocationFilter(p.location ?? ""); setNeedsFollowUpOnly(Boolean(p.needsFollowUp)); setPage(0); }} />
|
||||
<Tooltip title="Columns"><IconButton onClick={(e) => setColumnsAnchor(e.currentTarget)}><ViewColumnIcon /></IconButton></Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Paper sx={{ mt: 0 }}>
|
||||
{selectedIds.length > 0 ? (
|
||||
<Paper sx={{ mt: 2, p: 1.5, display: "flex", alignItems: "center", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
|
||||
<Typography sx={{ fontWeight: 800 }}>{selectedIds.length} selected</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
{mode === "trash" ? <Button variant="outlined" onClick={() => void runBulkAction("restore")}>Restore selected</Button> : <Button variant="outlined" color="error" onClick={() => void runBulkAction("delete")}>Delete selected</Button>}
|
||||
{mode === "jobs" ? ["Waiting", "Interview", "Rejected", "Ghosted", "Offer"].map((s) => <Button key={s} variant="outlined" onClick={() => void runBulkAction("status", s)}>{s}</Button>) : null}
|
||||
</Box>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
<Menu anchorEl={columnsAnchor} open={Boolean(columnsAnchor)} onClose={() => setColumnsAnchor(null)}>
|
||||
{([ ["status", "Status"], ["dateApplied", "Date applied"], ["daysSince", "Days"], ["jobUrl", "Job URL"] ] as const).map(([key, label]) => (
|
||||
<MenuItem key={key} onClick={() => onColumnsChange({ ...columns, [key]: !columns[key] })}>
|
||||
<Checkbox checked={columns[key]} />
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
|
||||
<Paper sx={{ mt: 2 }}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell padding="checkbox"><Checkbox checked={selectedAllOnPage} indeterminate={selectedIds.length > 0 && !selectedAllOnPage} onChange={(e) => toggleSelectAll(e.target.checked)} /></TableCell>
|
||||
<TableCell width={1} />
|
||||
<TableCell sortDirection={sortBy === "company" ? sortDir : false}>
|
||||
<TableSortLabel
|
||||
active={sortBy === "company"}
|
||||
direction={sortBy === "company" ? sortDir : "asc"}
|
||||
onClick={() => requestSort("company")}
|
||||
>
|
||||
Company
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
<TableCell sortDirection={sortBy === "jobTitle" ? sortDir : false}>
|
||||
<TableSortLabel
|
||||
active={sortBy === "jobTitle"}
|
||||
direction={sortBy === "jobTitle" ? sortDir : "asc"}
|
||||
onClick={() => requestSort("jobTitle")}
|
||||
>
|
||||
Role
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
{columns.status && (
|
||||
<TableCell sortDirection={sortBy === "status" ? sortDir : false}>
|
||||
<TableSortLabel
|
||||
active={sortBy === "status"}
|
||||
direction={sortBy === "status" ? sortDir : "asc"}
|
||||
onClick={() => requestSort("status")}
|
||||
>
|
||||
Status
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
)}
|
||||
{columns.dateApplied && (
|
||||
<TableCell sortDirection={sortBy === "dateApplied" ? sortDir : false}>
|
||||
<TableSortLabel
|
||||
active={sortBy === "dateApplied"}
|
||||
direction={sortBy === "dateApplied" ? sortDir : "asc"}
|
||||
onClick={() => requestSort("dateApplied")}
|
||||
>
|
||||
Date Applied
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
)}
|
||||
{columns.daysSince && (
|
||||
<TableCell sortDirection={sortBy === "daysSince" ? sortDir : false}>
|
||||
<TableSortLabel
|
||||
active={sortBy === "daysSince"}
|
||||
direction={sortBy === "daysSince" ? sortDir : "asc"}
|
||||
onClick={() => requestSort("daysSince")}
|
||||
>
|
||||
Days
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
)}
|
||||
{columns.jobUrl && <TableCell>Job URL</TableCell>}
|
||||
<TableCell width={1} align="right">
|
||||
Actions
|
||||
</TableCell>
|
||||
<TableCell sortDirection={sortBy === "company" ? sortDir : false}><TableSortLabel active={sortBy === "company"} direction={sortBy === "company" ? sortDir : "asc"} onClick={() => requestSort("company")}>Company</TableSortLabel></TableCell>
|
||||
<TableCell sortDirection={sortBy === "jobTitle" ? sortDir : false}><TableSortLabel active={sortBy === "jobTitle"} direction={sortBy === "jobTitle" ? sortDir : "asc"} onClick={() => requestSort("jobTitle")}>Role</TableSortLabel></TableCell>
|
||||
{columns.status ? <TableCell sortDirection={sortBy === "status" ? sortDir : false}><TableSortLabel active={sortBy === "status"} direction={sortBy === "status" ? sortDir : "asc"} onClick={() => requestSort("status")}>Status</TableSortLabel></TableCell> : null}
|
||||
{columns.dateApplied ? <TableCell sortDirection={sortBy === "dateApplied" ? sortDir : false}><TableSortLabel active={sortBy === "dateApplied"} direction={sortBy === "dateApplied" ? sortDir : "asc"} onClick={() => requestSort("dateApplied")}>Date Applied</TableSortLabel></TableCell> : null}
|
||||
{columns.daysSince ? <TableCell sortDirection={sortBy === "daysSince" ? sortDir : false}><TableSortLabel active={sortBy === "daysSince"} direction={sortBy === "daysSince" ? sortDir : "asc"} onClick={() => requestSort("daysSince")}>Days</TableSortLabel></TableCell> : null}
|
||||
{columns.jobUrl ? <TableCell>Job URL</TableCell> : null}
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
|
||||
<TableBody>
|
||||
{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 (
|
||||
<React.Fragment key={job.id}>
|
||||
<TableRow
|
||||
sx={{
|
||||
backgroundColor: bg,
|
||||
color: fg,
|
||||
"& a": { color: fg },
|
||||
"& td": { borderBottomColor: alpha(stripe, 0.12) },
|
||||
}}
|
||||
>
|
||||
<TableCell>
|
||||
<IconButton size="small" onClick={() => toggle(job.id)}>
|
||||
{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
|
||||
<TableRow sx={{ backgroundColor: alpha(tone, theme.palette.mode === "dark" ? 0.1 : 0.06) }}>
|
||||
<TableCell padding="checkbox"><Checkbox checked={selectedIds.includes(job.id)} onChange={(e) => toggleSelected(job.id, e.target.checked)} /></TableCell>
|
||||
<TableCell><IconButton size="small" onClick={() => toggleExpanded(job.id)}>{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}</IconButton></TableCell>
|
||||
<TableCell>{job.company?.name ?? ""}</TableCell>
|
||||
<TableCell>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||
<span>{job.jobTitle}</span>
|
||||
{job.needsFollowUp ? (
|
||||
<Chip
|
||||
size="small"
|
||||
label="Follow up"
|
||||
title={job.followUpReason ?? undefined}
|
||||
sx={{ fontWeight: 800 }}
|
||||
/>
|
||||
) : null}
|
||||
{job.needsFollowUp ? <Chip size="small" label="Follow up" title={job.followUpReason ?? undefined} sx={{ fontWeight: 800 }} /> : null}
|
||||
</Box>
|
||||
</TableCell>
|
||||
{columns.status && (
|
||||
<TableCell>
|
||||
<StatusChip status={job.status} />
|
||||
</TableCell>
|
||||
)}
|
||||
{columns.dateApplied && (
|
||||
<TableCell>
|
||||
{new Date(job.dateApplied).toLocaleDateString()}
|
||||
</TableCell>
|
||||
)}
|
||||
{columns.daysSince && <TableCell>{job.daysSince}</TableCell>}
|
||||
{columns.jobUrl && (
|
||||
<TableCell>
|
||||
{job.jobUrl ? (
|
||||
<a href={job.jobUrl} target="_blank" rel="noreferrer">
|
||||
Link
|
||||
</a>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
{columns.status ? <TableCell><Chip label={normalizeStatus(job.status)} size="small" color={toneName as any} /></TableCell> : null}
|
||||
{columns.dateApplied ? <TableCell>{new Date(job.dateApplied).toLocaleDateString()}</TableCell> : null}
|
||||
{columns.daysSince ? <TableCell>{job.daysSince}</TableCell> : null}
|
||||
{columns.jobUrl ? <TableCell>{job.jobUrl ? <a href={job.jobUrl} target="_blank" rel="noreferrer">Link</a> : ""}</TableCell> : null}
|
||||
<TableCell align="right">
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
alignItems: "center",
|
||||
gap: 0.5,
|
||||
flexWrap: "nowrap",
|
||||
}}
|
||||
>
|
||||
<Tooltip title="Edit">
|
||||
<IconButton size="small" onClick={() => setEditJobId(job.id)}>
|
||||
<EditOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Quick status">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
setStatusJobId(job.id);
|
||||
setStatusAnchor(e.currentTarget);
|
||||
}}
|
||||
>
|
||||
<MoreHorizIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Open">
|
||||
<IconButton size="small" onClick={() => setDetailsJobId(job.id)}>
|
||||
<LaunchIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{(mode === "trash" || (includeDeleted && job.isDeleted)) ? (
|
||||
<Tooltip title="Restore">
|
||||
<IconButton size="small" onClick={() => restore(job.id)}>
|
||||
<RestoreFromTrashOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title="Soft delete">
|
||||
<IconButton size="small" onClick={() => softDelete(job.id)}>
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 0.5 }}>
|
||||
<Tooltip title="Edit"><IconButton size="small" onClick={() => setEditJobId(job.id)}><EditOutlinedIcon fontSize="small" /></IconButton></Tooltip>
|
||||
<Tooltip title="Quick status"><IconButton size="small" onClick={(e) => { setStatusJobId(job.id); setStatusAnchor(e.currentTarget); }}><MoreHorizIcon fontSize="small" /></IconButton></Tooltip>
|
||||
<Tooltip title="Open"><IconButton size="small" onClick={() => setDetailsJobId(job.id)}><LaunchIcon fontSize="small" /></IconButton></Tooltip>
|
||||
{(mode === "trash" || (includeDeleted && job.isDeleted)) ? <Tooltip title="Restore"><IconButton size="small" onClick={() => void restore(job.id)}><RestoreFromTrashOutlinedIcon fontSize="small" /></IconButton></Tooltip> : <Tooltip title="Soft delete"><IconButton size="small" onClick={() => void softDelete(job.id)}><DeleteOutlineIcon fontSize="small" /></IconButton></Tooltip>}
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{open && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} sx={{ py: 0 }}>
|
||||
<Collapse in={open} timeout="auto" unmountOnExit>
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr 1fr",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="overline">Location</Typography>
|
||||
<Typography>{job.location ?? ""}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="overline">Skills</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.5 }}>
|
||||
{(() => {
|
||||
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) => (
|
||||
<Chip key={t} label={t} size="small" />
|
||||
)) : <Typography sx={{ color: "text.secondary" }}>—</Typography>;
|
||||
} catch {
|
||||
return <Typography sx={{ color: "text.secondary" }}>—</Typography>;
|
||||
}
|
||||
})()}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="overline">Job URL</Typography>
|
||||
<Typography>
|
||||
{job.jobUrl ? (
|
||||
<a href={job.jobUrl} target="_blank" rel="noreferrer">
|
||||
Link
|
||||
</a>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</Typography>
|
||||
</Box>
|
||||
{/* Deleted flag removed from expanded view as requested */}
|
||||
|
||||
<Box sx={{ gridColumn: "1 / -1" }}>
|
||||
<Typography variant="overline">Overview</Typography>
|
||||
<Typography sx={{ whiteSpace: "pre-wrap" }}>
|
||||
{fullSummaries[job.id] ?? job.shortSummary ?? generateOverview(job)}
|
||||
</Typography>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
{fullSummaries[job.id] ? (
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
color: theme.palette.primary.main,
|
||||
fontWeight: 800,
|
||||
}}
|
||||
onClick={() => setFullSummaries((s) => ({ ...s, [job.id]: undefined }))}
|
||||
>
|
||||
Show less
|
||||
</button>
|
||||
) : (
|
||||
(job.shortSummary || job.description || job.notes) && (
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
color: theme.palette.primary.main,
|
||||
fontWeight: 800,
|
||||
}}
|
||||
onClick={() => void loadFullSummary(job.id)}
|
||||
>
|
||||
{loadingFull[job.id] ? "Loading…" : "Show more"}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
<TableRow>
|
||||
<TableCell sx={{ py: 0 }} colSpan={columns.status && columns.dateApplied && columns.daysSince && columns.jobUrl ? 9 : 8}>
|
||||
<Collapse in={open} timeout="auto" unmountOnExit>
|
||||
<Box sx={{ p: 2, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr" }, gap: 2 }}>
|
||||
<Box><Typography variant="overline">Location</Typography><Typography>{job.location ?? "-"}</Typography></Box>
|
||||
<Box><Typography variant="overline">Salary</Typography><Typography>{job.salary ?? "-"}</Typography></Box>
|
||||
<Box><Typography variant="overline">Job URL</Typography><Typography>{job.jobUrl ? <a href={job.jobUrl} target="_blank" rel="noreferrer">Open listing</a> : "-"}</Typography></Box>
|
||||
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">Skills</Typography><Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.5 }}>{parseTags(job.tags).length ? parseTags(job.tags).slice(0, 8).map((tag) => <Chip key={tag} label={tag} size="small" />) : <Typography sx={{ color: "text.secondary" }}>No tags</Typography>}</Box></Box>
|
||||
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">Overview</Typography><Typography sx={{ whiteSpace: "pre-wrap" }}>{generateOverview(job) || "No summary yet."}</Typography></Box>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
{jobs.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8}>
|
||||
<Typography sx={{ py: 2, textAlign: "center" }}>
|
||||
No jobs found.
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{jobs.length === 0 ? <TableRow><TableCell colSpan={9}><Typography sx={{ py: 2, textAlign: "center" }}>No jobs found.</Typography></TableCell></TableRow> : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={total}
|
||||
page={page}
|
||||
onPageChange={(_, next) => setPage(next)}
|
||||
rowsPerPage={pageSize}
|
||||
onRowsPerPageChange={(e) => {
|
||||
const next = Number(e.target.value) as 15 | 20 | 25;
|
||||
onPageSizeChange(next);
|
||||
setPage(0);
|
||||
}}
|
||||
rowsPerPageOptions={[15, 20, 25]}
|
||||
/>
|
||||
<TablePagination component="div" count={total} page={page} onPageChange={(_, next) => setPage(next)} rowsPerPage={pageSize} onRowsPerPageChange={(e) => { onPageSizeChange(Number(e.target.value) as 15 | 20 | 25); setPage(0); }} rowsPerPageOptions={[15, 20, 25]} />
|
||||
</Paper>
|
||||
|
||||
<JobDetailsDialog
|
||||
open={detailsJobId !== null}
|
||||
jobId={detailsJobId}
|
||||
onClose={() => setDetailsJobId(null)}
|
||||
/>
|
||||
|
||||
<EditJobDialog
|
||||
open={editJobId !== null}
|
||||
jobId={editJobId}
|
||||
onClose={() => setEditJobId(null)}
|
||||
onSaved={() => {
|
||||
setReloadToken((t) => t + 1);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Menu
|
||||
anchorEl={statusAnchor}
|
||||
open={Boolean(statusAnchor)}
|
||||
onClose={() => {
|
||||
setStatusAnchor(null);
|
||||
setStatusJobId(null);
|
||||
}}
|
||||
>
|
||||
{["Waiting", "Rejected", "Ghosted"].map((s) => (
|
||||
<MenuItem
|
||||
key={s}
|
||||
onClick={() => {
|
||||
if (statusJobId) void setStatusQuick(statusJobId, s);
|
||||
setStatusAnchor(null);
|
||||
setStatusJobId(null);
|
||||
}}
|
||||
>
|
||||
Set {s}
|
||||
</MenuItem>
|
||||
))}
|
||||
<JobDetailsDialog open={detailsJobId !== null} jobId={detailsJobId} onClose={() => setDetailsJobId(null)} />
|
||||
<EditJobDialog open={editJobId !== null} jobId={editJobId} onClose={() => setEditJobId(null)} onSaved={() => setReloadToken((t) => t + 1)} />
|
||||
<Menu anchorEl={statusAnchor} open={Boolean(statusAnchor)} onClose={() => { setStatusAnchor(null); setStatusJobId(null); }}>
|
||||
{["Waiting", "Interview", "Offer", "Rejected", "Ghosted"].map((s) => <MenuItem key={s} onClick={() => { if (statusJobId) void setStatusQuick(statusJobId, s); setStatusAnchor(null); setStatusJobId(null); }}>Set {s}</MenuItem>)}
|
||||
</Menu>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -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<JobSearchItem[]>([]);
|
||||
const [companies, setCompanies] = useState<CompanySearchItem[]>([]);
|
||||
|
||||
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<CompanySearchItem[]>("/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<CommandItem[]>(() => {
|
||||
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 (
|
||||
<Dialog open={open} onClose={onClose} fullWidth maxWidth="sm">
|
||||
<DialogContent sx={{ p: 1.5 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, px: 1, pt: 0.5 }}>
|
||||
<SearchIcon fontSize="small" />
|
||||
<TextField
|
||||
autoFocus
|
||||
variant="standard"
|
||||
fullWidth
|
||||
placeholder="Search jobs, companies, or actions"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
InputProps={{ disableUnderline: true }}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", whiteSpace: "nowrap" }}>
|
||||
Ctrl/Cmd + K
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<List sx={{ mt: 1 }}>
|
||||
{allItems.length === 0 ? (
|
||||
<Box sx={{ px: 2, py: 3 }}>
|
||||
<Typography sx={{ color: "text.secondary" }}>No matching commands or records.</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
allItems.map((item) => (
|
||||
<ListItemButton
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
item.action();
|
||||
onClose();
|
||||
}}
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
<ListItemText primary={item.label} secondary={item.hint} />
|
||||
</ListItemButton>
|
||||
))
|
||||
)}
|
||||
</List>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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<SystemStatus | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.get<SystemStatus>("/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 (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 950 }}>System status</Typography>
|
||||
<Typography sx={{ color: "text.secondary" }}>Quick operational view of storage, email, and summarizer health.</Typography>
|
||||
</Box>
|
||||
<Button variant="contained" onClick={() => void load()} disabled={loading}>
|
||||
{loading ? "Refreshing..." : "Refresh"}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{error ? <Alert severity="error">{error}</Alert> : null}
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(4, 1fr)" }, gap: 2 }}>
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>Environment</Typography>
|
||||
<Typography variant="h5" sx={{ fontWeight: 950 }}>{status?.environment ?? "-"}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>Version {status?.version ?? "-"}</Typography>
|
||||
</Paper>
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>Database</Typography>
|
||||
<Typography variant="h5" sx={{ fontWeight: 950 }}>{status?.storage.dbExists ? "Ready" : "Missing"}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>{formatBytes(status?.storage.dbSizeBytes)}</Typography>
|
||||
</Paper>
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>SMTP</Typography>
|
||||
<Typography variant="h5" sx={{ fontWeight: 950 }}>{status?.email.enabled ? "Enabled" : "Disabled"}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>{status?.email.host || "No SMTP host configured"}</Typography>
|
||||
</Paper>
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>Summarizer</Typography>
|
||||
<Typography variant="h5" sx={{ fontWeight: 950 }}>{status?.summarizer.healthy ? "Healthy" : "Offline"}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>{status?.summarizer.healthLatencyMs != null ? `${status.summarizer.healthLatencyMs} ms` : "No latency data"}</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr" }, gap: 2 }}>
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>Storage</Typography>
|
||||
<Typography variant="body2"><strong>Data root:</strong> {status?.storage.dataRoot || "-"}</Typography>
|
||||
<Typography variant="body2"><strong>DB path:</strong> {status?.storage.dbPath || "-"}</Typography>
|
||||
<Typography variant="body2"><strong>Companies:</strong> {status?.storage.companyCount ?? 0}</Typography>
|
||||
<Typography variant="body2"><strong>Jobs:</strong> {status?.storage.jobCount ?? 0}</Typography>
|
||||
<Typography variant="body2"><strong>Deleted jobs:</strong> {status?.storage.deletedCount ?? 0}</Typography>
|
||||
<Typography variant="body2"><strong>Content root:</strong> {status?.contentRoot || "-"}</Typography>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>Email</Typography>
|
||||
<Typography variant="body2"><strong>From:</strong> {status?.email.from || "-"}</Typography>
|
||||
<Typography variant="body2"><strong>From name:</strong> {status?.email.fromName || "-"}</Typography>
|
||||
<Typography variant="body2"><strong>Host:</strong> {status?.email.host || "-"}</Typography>
|
||||
<Typography variant="body2"><strong>Port:</strong> {status?.email.port ?? "-"}</Typography>
|
||||
<Typography variant="body2"><strong>SSL:</strong> {status?.email.enableSsl ? "Yes" : "No"}</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>Summarizer telemetry</Typography>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(4, 1fr)" }, gap: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>Requests</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.requests ?? 0}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>Cache hits</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.cacheHits ?? 0}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>Failures</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.failures ?? 0}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>Avg latency</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.averageLatencyMs != null ? `${status.summarizer.averageLatencyMs} ms` : "-"}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
{status?.summarizer.lastError ? <Alert severity="warning" sx={{ mt: 2 }}>{status.summarizer.lastError}</Alert> : null}
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user