diff --git a/Bezalu.ProjectReporting.API/Functions/ProjectsFunction.cs b/Bezalu.ProjectReporting.API/Functions/ProjectsFunction.cs new file mode 100644 index 0000000..7308930 --- /dev/null +++ b/Bezalu.ProjectReporting.API/Functions/ProjectsFunction.cs @@ -0,0 +1,35 @@ +using Bezalu.ProjectReporting.API.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.Logging; + +namespace Bezalu.ProjectReporting.API.Functions; + +public class ProjectsFunction( + ILogger logger, + IProjectReportingService reportingService) +{ + [Function("GetActiveProjects")] + public async Task Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "projects")] + HttpRequest req, + CancellationToken cancellationToken) + { + logger.LogInformation("Processing get active projects request"); + + try + { + var projects = await reportingService.GetActiveProjectsAsync(cancellationToken); + return new OkObjectResult(projects); + } + catch (Exception ex) + { + logger.LogError(ex, "Error retrieving active projects"); + return new ObjectResult(new { error = "An error occurred while retrieving projects" }) + { + StatusCode = StatusCodes.Status500InternalServerError + }; + } + } +} diff --git a/Bezalu.ProjectReporting.API/Functions/ProjectCompletionReportFunction.cs b/Bezalu.ProjectReporting.API/Functions/ReportFunction.cs similarity index 99% rename from Bezalu.ProjectReporting.API/Functions/ProjectCompletionReportFunction.cs rename to Bezalu.ProjectReporting.API/Functions/ReportFunction.cs index e7e8bf0..68112c4 100644 --- a/Bezalu.ProjectReporting.API/Functions/ProjectCompletionReportFunction.cs +++ b/Bezalu.ProjectReporting.API/Functions/ReportFunction.cs @@ -10,8 +10,8 @@ namespace Bezalu.ProjectReporting.API.Functions; -public class ProjectCompletionReportFunction( - ILogger logger, +public class ReportFunction( + ILogger logger, IProjectReportingService reportingService) { [Function("GenerateProjectCompletionReport")] diff --git a/Bezalu.ProjectReporting.API/Services/ProjectReportingService.cs b/Bezalu.ProjectReporting.API/Services/ProjectReportingService.cs index 5dd7d9a..7c424f1 100644 --- a/Bezalu.ProjectReporting.API/Services/ProjectReportingService.cs +++ b/Bezalu.ProjectReporting.API/Services/ProjectReportingService.cs @@ -7,6 +7,7 @@ namespace Bezalu.ProjectReporting.API.Services; public interface IProjectReportingService { + Task> GetActiveProjectsAsync(CancellationToken cancellationToken = default); Task GenerateProjectCompletionReportAsync(int projectId, CancellationToken cancellationToken = default); } @@ -16,6 +17,27 @@ public class ProjectReportingService( ILogger logger) : IProjectReportingService { + public async Task> GetActiveProjectsAsync(CancellationToken cancellationToken = default) + { + logger.LogInformation("Retrieving active projects"); + + // Query ConnectWise for projects with "Active" status + var projects = await connectWiseClient.GetListAsync( + "project/projects?conditions=status/name contains 'In Progress'&orderBy=name", + cancellationToken) ?? new List(); + + return projects + .Where(p => p.Id.HasValue && p.Id.Value > 0) + .Select(p => new ProjectListItem + { + ProjectId = p.Id!.Value, + ProjectName = p.Name, + Status = p.Status?.Name, + Manager = p.Manager?.Name, + Company = p.Company?.Name + }).ToList(); + } + public async Task GenerateProjectCompletionReportAsync( int projectId, CancellationToken cancellationToken = default) @@ -161,7 +183,7 @@ private static string Sanitize(string? text, int maxLen = 500) { if (string.IsNullOrWhiteSpace(text)) return string.Empty; var cleaned = text.Replace("\r", " ").Replace("\n", " ").Trim(); - return cleaned.Length <= maxLen ? cleaned : cleaned.Substring(0, maxLen) + ""; + return cleaned.Length <= maxLen ? cleaned : cleaned.Substring(0, maxLen) + "�"; } private string PrepareDataForAI(ProjectCompletionReportResponse report, List projectNotes, Dictionary> ticketNotes) @@ -216,11 +238,11 @@ private string PrepareDataForAI(ProjectCompletionReportResponse report, List limited.Count) { - sb.AppendLine($" ({notes.Count - limited.Count} more notes truncated)"); + sb.AppendLine($" � � ({notes.Count - limited.Count} more notes truncated)"); } } } diff --git a/Bezalu.ProjectReporting.Shared/DTOs/ProjectCompletionReportModels.cs b/Bezalu.ProjectReporting.Shared/DTOs/ProjectCompletionReportModels.cs index bb64194..2169846 100644 --- a/Bezalu.ProjectReporting.Shared/DTOs/ProjectCompletionReportModels.cs +++ b/Bezalu.ProjectReporting.Shared/DTOs/ProjectCompletionReportModels.cs @@ -1,5 +1,14 @@ namespace Bezalu.ProjectReporting.Shared.DTOs; +public class ProjectListItem +{ + public int ProjectId { get; set; } + public string? ProjectName { get; set; } + public string? Status { get; set; } + public string? Manager { get; set; } + public string? Company { get; set; } +} + public class ProjectCompletionReportRequest { public int ProjectId { get; set; } diff --git a/Bezalu.ProjectReporting.Web/Pages/Home.razor b/Bezalu.ProjectReporting.Web/Pages/Home.razor index d0a374d..11519e4 100644 --- a/Bezalu.ProjectReporting.Web/Pages/Home.razor +++ b/Bezalu.ProjectReporting.Web/Pages/Home.razor @@ -1,4 +1,5 @@ @page "/" +@using System.Net @inject HttpClient Http @inject NavigationManager Nav @inject IJSRuntime JS @@ -6,25 +7,68 @@ Project Reporting - -

Generate Project Completion Report

- - - Generate Report - @(IsPdfLoading ? "Generating PDF..." : "Download PDF") - @if (IsLoading) +@if (ShowLoginPrompt) +{ + + + + + @LoginPromptText + Sign in again to continue. + + Sign in + + +} + +@if (Report is null) +{ + +

Select a Project

+ @if (IsLoadingProjects) { - + +

Loading projects...

} - @if (!string.IsNullOrEmpty(ErrorMessage)) + else if (!string.IsNullOrEmpty(ErrorMessage)) { @ErrorMessage } -
-
- -@if (Report is not null) + else if (Projects is null || Projects.Count == 0) + { +

No active projects found.

+ } + else + { + + + + + + + + @(IsGeneratingReport && GeneratingProjectId == context.ProjectId ? "Generating..." : "Generate Report") + + + + } + +} +else { + + + ← Back to Project List + @(IsPdfLoading ? "Generating PDF..." : "Download PDF") + @if (!string.IsNullOrEmpty(ErrorMessage)) + { + @ErrorMessage + } + + +

@Report.ProjectName (#@Report.ProjectId)

@@ -53,10 +97,10 @@ else { - - - - + + + + } @@ -68,11 +112,11 @@ else { - - - - - + + + + + } @@ -81,42 +125,91 @@ } @code { - int ProjectId { get; set; } + List? Projects; ProjectCompletionReportResponse? Report; - bool IsLoading; + bool IsLoadingProjects; + bool IsGeneratingReport; + int GeneratingProjectId; bool IsPdfLoading; + bool ShowLoginPrompt; + string LoginPromptText = "Please sign in to continue."; string? ErrorMessage; string AiSummaryHtml => Report?.AiGeneratedSummary is null ? string.Empty : Markdown.ToHtml(Report.AiGeneratedSummary); - async Task GenerateReport() + protected override async Task OnInitializedAsync() + { + await LoadProjects(); + } + + async Task LoadProjects() { ErrorMessage = null; - IsLoading = true; - Report = null; + IsLoadingProjects = true; + try + { + var response = await Http.GetAsync("api/projects"); + if (response.StatusCode == HttpStatusCode.Unauthorized) + { + HandleUnauthorized("Please sign in to load projects."); + return; + } + if (!response.IsSuccessStatusCode) + { + ErrorMessage = $"Failed to load projects: {response.StatusCode}"; + return; + } + Projects = await response.Content.ReadFromJsonAsync>(); + } + catch (Exception) + { + ErrorMessage = "An error occurred while loading projects. Please try again."; + } + finally + { + IsLoadingProjects = false; + } + } + + async Task GenerateReport(int projectId) + { + ErrorMessage = null; + IsGeneratingReport = true; + GeneratingProjectId = projectId; StateHasChanged(); try { - var req = new ProjectCompletionReportRequest { ProjectId = ProjectId }; + var req = new ProjectCompletionReportRequest { ProjectId = projectId }; var httpResp = await Http.PostAsJsonAsync("api/reports/project-completion", req); + if (httpResp.StatusCode == HttpStatusCode.Unauthorized) + { + HandleUnauthorized("Please sign in to generate a report."); + return; + } if (!httpResp.IsSuccessStatusCode) { - ErrorMessage = $"Failed: {httpResp.StatusCode}"; + ErrorMessage = $"Failed to generate report: {httpResp.StatusCode}"; } else { Report = await httpResp.Content.ReadFromJsonAsync(); } } - catch (Exception ex) + catch (Exception) { - ErrorMessage = ex.Message; + ErrorMessage = "An error occurred while generating the report. Please try again."; } finally { - IsLoading = false; + IsGeneratingReport = false; } } + void BackToProjectList() + { + Report = null; + ErrorMessage = null; + } + async Task DownloadPdf() { if (Report is null) return; @@ -125,9 +218,14 @@ try { var httpResp = await Http.PostAsJsonAsync("api/reports/project-completion/pdf", Report); + if (httpResp.StatusCode == HttpStatusCode.Unauthorized) + { + HandleUnauthorized("Please sign in to download the PDF."); + return; + } if (!httpResp.IsSuccessStatusCode) { - ErrorMessage = $"PDF failed: {httpResp.StatusCode}"; + ErrorMessage = $"Failed to generate PDF: {httpResp.StatusCode}"; return; } @@ -135,13 +233,25 @@ var base64 = Convert.ToBase64String(bytes); await JS.InvokeVoidAsync("saveFile", base64, $"project-{Report.ProjectId}-completion-report.pdf", "application/pdf"); } - catch (Exception ex) + catch (Exception) { - ErrorMessage = ex.Message; + ErrorMessage = "An error occurred while generating the PDF. Please try again."; } finally { IsPdfLoading = false; } } + + void HandleUnauthorized(string message) + { + LoginPromptText = message; + ShowLoginPrompt = true; + ErrorMessage = null; + } + + void TriggerLogin() + { + Nav.NavigateTo("/login?post_login_redirect_uri=.referrer", forceLoad: true); + } } \ No newline at end of file diff --git a/Bezalu.ProjectReporting.Web/staticwebapp.config.json b/Bezalu.ProjectReporting.Web/staticwebapp.config.json index 3eeff23..0143e37 100644 --- a/Bezalu.ProjectReporting.Web/staticwebapp.config.json +++ b/Bezalu.ProjectReporting.Web/staticwebapp.config.json @@ -1,14 +1,30 @@ { "routes": [ - { - "route": "/api/*", - "allowedRoles": ["authenticated"] - }, { "route": "/login", "rewrite": "/.auth/login/aad" + }, + { + "route": "/*", + "allowedRoles": ["authenticated"] } ], + "navigationFallback": { + "rewrite": "index.html", + "exclude": [ + "/.auth/*", + "/_framework/*", + "/_content/*", + "/css/*", + "/js/*", + "/favicon.ico", + "/icon-*.png", + "/manifest.webmanifest", + "/service-worker.js", + "/service-worker.published.js", + "/service-worker-assets.js" + ] + }, "responseOverrides": { "401": { "redirect": "/.auth/login/aad?post_login_redirect_uri=.referrer", diff --git a/README.md b/README.md index 3f05339..d6c960d 100644 --- a/README.md +++ b/README.md @@ -134,10 +134,16 @@ CW-ProjectReporting/ 1. User accesses the Blazor WebAssembly app hosted on Azure Static Web Apps 2. Static Web Apps enforces authentication via Azure AD (configured in `staticwebapp.config.json`) -3. Authenticated users receive session cookies from SWA -4. API requests include SWA authentication cookies automatically -5. SWA validates authentication before forwarding requests to Azure Functions -6. Azure Functions trust the SWA authentication layer (no additional function-level auth required) +3. Unauthenticated users are automatically redirected to `/.auth/login/aad` (Azure AD login) +4. Authenticated users receive session cookies from SWA +5. API requests include SWA authentication cookies automatically +6. SWA validates authentication before forwarding requests to Azure Functions +7. Azure Functions trust the SWA authentication layer (no additional function-level auth required) + +**Important**: The `staticwebapp.config.json` must include: +- `navigationFallback` with `/.auth/*` exclusion to ensure authentication endpoints work correctly +- `responseOverrides` for 401 status to automatically redirect unauthenticated users +- Exclusions for Blazor static assets (`_framework/*`, `_content/*`) from the SPA fallback ## Development Notes diff --git a/docs/deployment.md b/docs/deployment.md index 0e185d6..cc085d5 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -31,7 +31,10 @@ - Configure authentication provider in Azure Portal under SWA > Settings > Authentication - `staticwebapp.config.json` enforces authentication: - `/api/*` routes require `authenticated` role - - Unauthenticated users redirected to `/.auth/login/aad` + - `/login` route rewrites to `/.auth/login/aad` for easy access + - Unauthenticated users automatically redirected to Azure AD via `responseOverrides` (401 → `/.auth/login/aad`) + - `navigationFallback` configured with `/.auth/*` exclusion to ensure authentication endpoints work correctly + - Blazor static assets (`_framework/*`, `_content/*`, CSS, JS) excluded from SPA fallback - No function keys needed - Azure Functions use `AuthorizationLevel.Anonymous` and trust SWA authentication - Session managed by SWA; cookies automatically included in API requests