From 2a63e4c069c36c7374c68612bef586cac367b208 Mon Sep 17 00:00:00 2001 From: Raymond Stone Date: Wed, 8 Apr 2026 17:33:18 +0100 Subject: [PATCH] JSA: Add signing period, contact log, and email export - Add JSA settings tab for signing period & work coach details - Support "Current"/"Previous Signing Period" quick filters - Allow standalone contact/discussion logging in JSA report - Show contact details in report and exports (PDF/Excel/Word) - Enable emailing PDF report to work coach (with attachment) - Add RecordStandaloneContactDiscussion to JobHistoryService - Improve PDF export (links, table headers, font resolver) - Use sendBeacon for reliable shutdown API call - Add unit tests for JSA period logic, reporting, and contact log --- Components/Layout/MainLayout.razor | 5 - Components/Layout/NavMenu.razor | 5 - Components/Layout/UpdateBanner.razor | 5 +- Components/Pages/JsaReport.razor | 249 +++++++- Components/Pages/Settings.razor | 71 ++- JobTracker.Tests/HistoryActionTypeTests.cs | 41 ++ JobTracker.Tests/JobHistoryServiceTests.cs | 96 +++ JobTracker.Tests/JsaReportServiceTests.cs | 672 +++++++++++++++++++++ JobTracker.Tests/JsaSigningPeriodTests.cs | 238 ++++++++ Models/AppSettings.cs | 10 + Models/JobHistory.cs | 8 +- Portable/JobTracker-x64.zip.001 | 2 +- Portable/JobTracker-x64.zip.002 | 4 +- Portable/JobTracker-x86.zip.001 | 2 +- Portable/JobTracker-x86.zip.002 | 4 +- Program.cs | 3 + Services/AppSettingsService.cs | 1 + Services/EmailService.cs | 46 ++ Services/JobHistoryService.cs | 25 + Services/JsaReportService.cs | 166 +++-- wwwroot/js/crawlPagesRunner.js | 9 +- 21 files changed, 1587 insertions(+), 75 deletions(-) create mode 100644 JobTracker.Tests/HistoryActionTypeTests.cs create mode 100644 JobTracker.Tests/JsaReportServiceTests.cs create mode 100644 JobTracker.Tests/JsaSigningPeriodTests.cs diff --git a/Components/Layout/MainLayout.razor b/Components/Layout/MainLayout.razor index f5818f5..6615012 100644 --- a/Components/Layout/MainLayout.razor +++ b/Components/Layout/MainLayout.razor @@ -4,8 +4,6 @@ @inject AuthenticationStateProvider AuthStateProvider @inject AuthService AuthService @inject IConfiguration Configuration -@inject NavigationManager NavigationManager -@inject IJSRuntime JS
- -@code { -} diff --git a/Components/Layout/NavMenu.razor b/Components/Layout/NavMenu.razor index de52301..aec475c 100644 --- a/Components/Layout/NavMenu.razor +++ b/Components/Layout/NavMenu.razor @@ -65,11 +65,6 @@ JSA Report
- @if (Configuration.GetValue("LocalMode")) { diff --git a/Components/Layout/UpdateBanner.razor b/Components/Layout/UpdateBanner.razor index b0d55b6..b6dc17c 100644 --- a/Components/Layout/UpdateBanner.razor +++ b/Components/Layout/UpdateBanner.razor @@ -3,13 +3,14 @@ @inject UpdateCheckService UpdateCheckService @inject NavigationManager NavigationManager @inject IJSRuntime JS +@inject IHttpClientFactory HttpClientFactory @if (UpdateCheckService.UpdateAvailable) {
A new version (@UpdateCheckService.LatestVersion) is available! - Download & restart +
} @@ -27,7 +28,7 @@ await JS.InvokeVoidAsync("open", url, "_blank"); } - using var http = new HttpClient(); + using var http = HttpClientFactory.CreateClient(); var baseUri = new Uri(NavigationManager.BaseUri); await http.PostAsync(new Uri(baseUri, "/api/update-download"), null); } diff --git a/Components/Pages/JsaReport.razor b/Components/Pages/JsaReport.razor index 8563174..657b1aa 100644 --- a/Components/Pages/JsaReport.razor +++ b/Components/Pages/JsaReport.razor @@ -4,6 +4,9 @@ @using Microsoft.AspNetCore.Authorization @attribute [Authorize] @inject JsaReportService ReportService +@inject JobHistoryService HistoryService +@inject AppSettingsService SettingsService +@inject EmailService EmailService @inject NavigationManager Navigation @inject IJSRuntime JSRuntime @rendermode InteractiveServer @@ -27,10 +30,19 @@ PDF + @if (!string.IsNullOrEmpty(jsaSettings.WorkCoachEmail)) + { + + } } + @@ -104,6 +116,10 @@ + @if (jsaSettings.SigningStartDate.HasValue && jsaSettings.PeriodLengthDays > 0) + { + + } @@ -249,7 +265,21 @@ @JsaReportService.GetActionTypeDisplay(entry.ActionType) - @entry.Details + + @entry.Details + @if (!string.IsNullOrEmpty(entry.ContactName)) + { +
Contact: @entry.ContactName + } + @if (!string.IsNullOrEmpty(entry.ContactReason)) + { +
Reason: @entry.ContactReason + } + @if (!string.IsNullOrEmpty(entry.ContactResult)) + { +
Result: @entry.ContactResult + } + @if (!string.IsNullOrEmpty(entry.OldValue) && !string.IsNullOrEmpty(entry.NewValue)) { @@ -269,9 +299,16 @@ @if (!string.IsNullOrEmpty(exportError)) { -
+ + } + @if (!string.IsNullOrEmpty(emailSuccessMessage)) + { + }
@@ -286,12 +323,74 @@ } +@if (showAddContactModal) +{ + +} + @code { private List reportGroups = new(); private JsaReportSummary? summary; private bool isLoading = true; private bool isExporting = false; private string? exportError; + private string? emailSuccessMessage; + + // Add Contact/Discussion modal + private bool showAddContactModal = false; + private string newContactJobTitle = ""; + private string newContactCompany = ""; + private string newContactName = ""; + private string newContactReason = ""; + private string newContactResult = ""; + private DateTime? newContactTimestamp; + + private bool IsContactFormValid => + !string.IsNullOrWhiteSpace(newContactJobTitle) && + !string.IsNullOrWhiteSpace(newContactCompany) && + !string.IsNullOrWhiteSpace(newContactName); // Filters private DateTime? filterFromDate; @@ -315,21 +414,45 @@ { HistoryActionType.CompanyUpdated, "Company Updated" }, { HistoryActionType.NotesUpdated, "Notes Updated" }, { HistoryActionType.ContactAdded, "Contact Added" }, - { HistoryActionType.InteractionAdded, "Interaction Added" } + { HistoryActionType.InteractionAdded, "Interaction Added" }, + { HistoryActionType.ContactDiscussion, "Contact/Discussion" } }; private bool HasActiveFilters => filterFromDate.HasValue || filterToDate.HasValue || !string.IsNullOrEmpty(filterChangeSource) || !string.IsNullOrEmpty(filterSearchTerm) || !selectedActionTypes.SetEquals(JsaReportService.DefaultJsaActionTypes); + private JsaSettings jsaSettings = new(); + protected override void OnInitialized() { - // Default to last 14 days (typical signing period) - filterFromDate = DateTime.Today.AddDays(-14); - filterToDate = DateTime.Today; + jsaSettings = SettingsService.GetSettings().Jsa; + var period = GetCurrentSigningDates(); + filterFromDate = period.from; + filterToDate = period.to; GenerateReport(); } + private (DateTime from, DateTime to) GetCurrentSigningDates() + { + if (jsaSettings.SigningStartDate.HasValue && jsaSettings.PeriodLengthDays > 0) + { + var start = jsaSettings.SigningStartDate.Value.Date; + var periodDays = jsaSettings.PeriodLengthDays; + var daysSinceStart = (DateTime.Today - start).Days; + if (daysSinceStart >= 0) + { + var currentPeriodIndex = daysSinceStart / periodDays; + var periodStart = start.AddDays(currentPeriodIndex * periodDays); + var periodEnd = periodStart.AddDays(periodDays - 1); + // Return full period even if end date is in the future + return (periodStart, periodEnd); + } + } + // Fallback: last 14 days + return (DateTime.Today.AddDays(-14), DateTime.Today); + } + private void GenerateReport() { isLoading = true; @@ -383,12 +506,38 @@ private void SetCurrentSigningPeriod() { - // Typical JSA signing is fortnightly - filterFromDate = DateTime.Today.AddDays(-14); - filterToDate = DateTime.Today; + var period = GetCurrentSigningDates(); + filterFromDate = period.from; + filterToDate = period.to; GenerateReport(); } + private void SetPreviousSigningPeriod() + { + if (jsaSettings.SigningStartDate.HasValue && jsaSettings.PeriodLengthDays > 0) + { + var start = jsaSettings.SigningStartDate.Value.Date; + var periodDays = jsaSettings.PeriodLengthDays; + var daysSinceStart = (DateTime.Today - start).Days; + if (daysSinceStart >= 0) + { + var currentPeriodIndex = daysSinceStart / periodDays; + var previousPeriodIndex = currentPeriodIndex - 1; + if (previousPeriodIndex >= 0) + { + var periodStart = start.AddDays(previousPeriodIndex * periodDays); + var periodEnd = periodStart.AddDays(periodDays - 1); + filterFromDate = periodStart; + filterToDate = periodEnd; + GenerateReport(); + return; + } + } + } + // Fallback if no previous period available + SetDateRange(14); + } + private void ClearFilters() { filterFromDate = null; @@ -482,6 +631,85 @@ } } + private async Task EmailReport() + { + isExporting = true; + exportError = null; + emailSuccessMessage = null; + StateHasChanged(); + await Task.Delay(50); + + try + { + var smtpSettings = SettingsService.GetSettingsDecrypted(); + if (string.IsNullOrEmpty(smtpSettings.SmtpHost)) + { + exportError = "SMTP is not configured. Please configure email settings first."; + return; + } + + var pdfBytes = ReportService.ExportToPdf(reportGroups, summary!, GetAppBaseUrl()); + var fileName = $"JSA-Report-{DateTime.Now:yyyy-MM-dd}.pdf"; + var periodText = $"{summary!.DateFrom:dd/MM/yyyy} - {summary.DateTo:dd/MM/yyyy}"; + + var htmlBody = $@"

Please find attached my JSA Job Search Activity Report for the period {periodText}.

+

Summary: {summary.TotalJobs} jobs tracked, {summary.JobsAppliedTo} applied to, {summary.TotalActivities} total activities.

+

Kind regards

"; + + var sent = await EmailService.SendEmailWithAttachmentAsync( + jsaSettings.WorkCoachEmail, + $"JSA Job Search Report - {periodText}", + htmlBody, + smtpSettings.SmtpHost, smtpSettings.SmtpPort, + smtpSettings.SmtpUsername, smtpSettings.SmtpPassword, + smtpSettings.SmtpFromEmail, smtpSettings.SmtpFromName, + pdfBytes, fileName, "application/pdf"); + + if (sent) + emailSuccessMessage = $"Report emailed to {jsaSettings.WorkCoachEmail}"; + else + exportError = "Failed to send email. Check SMTP settings."; + } + catch (Exception ex) + { + exportError = $"Email failed: {ex.Message}"; + } + finally + { + isExporting = false; + StateHasChanged(); + } + } + + private void CloseAddContactModal() + { + showAddContactModal = false; + newContactJobTitle = ""; + newContactCompany = ""; + newContactName = ""; + newContactReason = ""; + newContactResult = ""; + newContactTimestamp = null; + } + + private void SaveContactDiscussion() + { + if (!IsContactFormValid) + return; + + HistoryService.RecordStandaloneContactDiscussion( + newContactJobTitle, + newContactCompany, + newContactName, + newContactReason, + newContactResult, + newContactTimestamp + ); + + CloseAddContactModal(); + GenerateReport(); // Refresh the report to show the new entry + } + private static string GetActionBadgeClass(HistoryActionType action) { return action switch @@ -491,6 +719,7 @@ HistoryActionType.ApplicationStageChanged => "bg-info", HistoryActionType.InterestChanged => "bg-warning text-dark", HistoryActionType.SuitabilityChanged => "bg-secondary", + HistoryActionType.ContactDiscussion => "bg-dark", _ => "bg-light text-dark" }; } diff --git a/Components/Pages/Settings.razor b/Components/Pages/Settings.razor index ef40456..a715933 100644 --- a/Components/Pages/Settings.razor +++ b/Components/Pages/Settings.razor @@ -70,6 +70,11 @@ Skills + @if (StorageBackend is JsonStorageBackend) {