Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions Bezalu.ProjectReporting.API/Functions/ProjectsFunction.cs
Original file line number Diff line number Diff line change
@@ -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<ProjectsFunction> logger,
IProjectReportingService reportingService)
{
[Function("GetActiveProjects")]
public async Task<IActionResult> 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
};
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@

namespace Bezalu.ProjectReporting.API.Functions;

public class ProjectCompletionReportFunction(
ILogger<ProjectCompletionReportFunction> logger,
public class ReportFunction(
ILogger<ReportFunction> logger,
IProjectReportingService reportingService)
{
[Function("GenerateProjectCompletionReport")]
Expand Down
28 changes: 25 additions & 3 deletions Bezalu.ProjectReporting.API/Services/ProjectReportingService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ namespace Bezalu.ProjectReporting.API.Services;

public interface IProjectReportingService
{
Task<List<ProjectListItem>> GetActiveProjectsAsync(CancellationToken cancellationToken = default);
Task<ProjectCompletionReportResponse> GenerateProjectCompletionReportAsync(int projectId, CancellationToken cancellationToken = default);
}

Expand All @@ -16,6 +17,27 @@ public class ProjectReportingService(
ILogger<ProjectReportingService> logger)
: IProjectReportingService
{
public async Task<List<ProjectListItem>> GetActiveProjectsAsync(CancellationToken cancellationToken = default)
{
logger.LogInformation("Retrieving active projects");

// Query ConnectWise for projects with "Active" status
var projects = await connectWiseClient.GetListAsync<CWProject>(
"project/projects?conditions=status/name contains 'In Progress'&orderBy=name",
cancellationToken) ?? new List<CWProject>();

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<ProjectCompletionReportResponse> GenerateProjectCompletionReportAsync(
int projectId,
CancellationToken cancellationToken = default)
Expand Down Expand Up @@ -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<CWProjectNote> projectNotes, Dictionary<int, List<CWTicketNote>> ticketNotes)
Expand Down Expand Up @@ -216,11 +238,11 @@ private string PrepareDataForAI(ProjectCompletionReportResponse report, List<CWP
sb.AppendLine(" Notes:");
foreach (var n in limited)
{
sb.AppendLine($" [{n.DateCreated:yyyy-MM-dd}] {Sanitize(n.Text)}");
sb.AppendLine($" � [{n.DateCreated:yyyy-MM-dd}] {Sanitize(n.Text)}");
}
if (notes.Count > limited.Count)
{
sb.AppendLine($" • … ({notes.Count - limited.Count} more notes truncated)");
sb.AppendLine($" � � ({notes.Count - limited.Count} more notes truncated)");
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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; }
Expand Down
180 changes: 145 additions & 35 deletions Bezalu.ProjectReporting.Web/Pages/Home.razor
Original file line number Diff line number Diff line change
@@ -1,30 +1,74 @@
@page "/"
@using System.Net
@inject HttpClient Http
@inject NavigationManager Nav
@inject IJSRuntime JS
@using Markdig

<PageTitle>Project Reporting</PageTitle>

<FluentCard Style="padding:1rem; margin-bottom:1rem;">
<h2>Generate Project Completion Report</h2>
<FluentStack Orientation="Orientation.Horizontal" Gap="10">
<FluentNumberField @bind-Value="ProjectId" Placeholder="Project ID" Min="1"/>
<FluentButton Appearance="Appearance.Accent" OnClick="GenerateReport" Disabled="IsLoading || ProjectId <= 0">Generate Report</FluentButton>
<FluentButton Appearance="Appearance.Outline" OnClick="DownloadPdf" Disabled="IsLoading || Report is null || IsPdfLoading">@(IsPdfLoading ? "Generating PDF..." : "Download PDF")</FluentButton>
@if (IsLoading)
@if (ShowLoginPrompt)
{
<FluentCard Style="padding:0.5rem 1rem; margin-bottom:1rem;">
<FluentStack Orientation="Orientation.Horizontal" Gap="10" VerticalAlignment="VerticalAlignment.Center">
<FluentIcon Value="@(new Icons.Regular.Size20.LockClosed())" />
<FluentStack>
<strong>@LoginPromptText</strong>
<span>Sign in again to continue.</span>
</FluentStack>
<FluentButton Appearance="Appearance.Accent" OnClick="TriggerLogin">Sign in</FluentButton>
</FluentStack>
</FluentCard>
}

@if (Report is null)
{
<FluentCard Style="padding:1rem; margin-bottom:1rem;">
<h2>Select a Project</h2>
@if (IsLoadingProjects)
{
<FluentProgressRing/>
<FluentProgressRing />
<p>Loading projects...</p>
}
@if (!string.IsNullOrEmpty(ErrorMessage))
else if (!string.IsNullOrEmpty(ErrorMessage))
{
<FluentMessageBar Intent="MessageIntent.Error">@ErrorMessage</FluentMessageBar>
}
</FluentStack>
</FluentCard>

@if (Report is not null)
else if (Projects is null || Projects.Count == 0)
{
<p>No active projects found.</p>
}
else
{
<FluentDataGrid Items="@Projects.AsQueryable()" GridTemplateColumns="2fr 1fr 1fr 1fr 1fr" GenerateFooter="false">
<PropertyColumn Property="@(p => p.ProjectName)" Title="Project Name" />
<PropertyColumn Property="@(p => p.Status)" Title="Status" />
<PropertyColumn Property="@(p => p.Manager)" Title="Manager" />
<PropertyColumn Property="@(p => p.Company)" Title="Company" />
<TemplateColumn Title="Actions">
<FluentButton Appearance="Appearance.Accent"
OnClick="@(() => GenerateReport(context.ProjectId))"
Disabled="IsGeneratingReport">
@(IsGeneratingReport && GeneratingProjectId == context.ProjectId ? "Generating..." : "Generate Report")
</FluentButton>
</TemplateColumn>
</FluentDataGrid>
}
</FluentCard>
}
else
{
<FluentCard Style="padding:1rem; margin-bottom:1rem;">
<FluentStack Orientation="Orientation.Horizontal" Gap="10">
<FluentButton Appearance="Appearance.Outline" OnClick="BackToProjectList">← Back to Project List</FluentButton>
<FluentButton Appearance="Appearance.Outline" OnClick="DownloadPdf" Disabled="IsPdfLoading">@(IsPdfLoading ? "Generating PDF..." : "Download PDF")</FluentButton>
@if (!string.IsNullOrEmpty(ErrorMessage))
{
<FluentMessageBar Intent="MessageIntent.Error">@ErrorMessage</FluentMessageBar>
}
</FluentStack>
</FluentCard>

<FluentCard Style="padding:1rem;">
<h2>@Report.ProjectName (#@Report.ProjectId)</h2>
<p>
Expand Down Expand Up @@ -53,10 +97,10 @@
else
{
<FluentDataGrid Items="@Report.Phases.AsQueryable()" GenerateFooter="false">
<PropertyColumn Property="@(p => p.PhaseName)" Title="Name"/>
<PropertyColumn Property="@(p => p.Status)" Title="Status"/>
<PropertyColumn Property="@(p => p.EstimatedHours)" Title="Est Hrs"/>
<PropertyColumn Property="@(p => p.ActualHours)" Title="Actual Hrs"/>
<PropertyColumn Property="@(p => p.PhaseName)" Title="Name" />
<PropertyColumn Property="@(p => p.Status)" Title="Status" />
<PropertyColumn Property="@(p => p.EstimatedHours)" Title="Est Hrs" />
<PropertyColumn Property="@(p => p.ActualHours)" Title="Actual Hrs" />
</FluentDataGrid>
}
</FluentTab>
Expand All @@ -68,11 +112,11 @@
else
{
<FluentDataGrid Items="@Report.Tickets.AsQueryable()" GenerateFooter="false">
<PropertyColumn Property="@(t => t.TicketNumber)" Title="Number"/>
<PropertyColumn Property="@(t => t.Summary)" Title="Summary"/>
<PropertyColumn Property="@(t => t.Status)" Title="Status"/>
<PropertyColumn Property="@(t => t.EstimatedHours)" Title="Est Hrs"/>
<PropertyColumn Property="@(t => t.ActualHours)" Title="Actual Hrs"/>
<PropertyColumn Property="@(t => t.TicketNumber)" Title="Number" />
<PropertyColumn Property="@(t => t.Summary)" Title="Summary" />
<PropertyColumn Property="@(t => t.Status)" Title="Status" />
<PropertyColumn Property="@(t => t.EstimatedHours)" Title="Est Hrs" />
<PropertyColumn Property="@(t => t.ActualHours)" Title="Actual Hrs" />
</FluentDataGrid>
}
</FluentTab>
Expand All @@ -81,42 +125,91 @@
}

@code {
int ProjectId { get; set; }
List<ProjectListItem>? 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<List<ProjectListItem>>();
}
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<ProjectCompletionReportResponse>();
}
}
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;
Expand All @@ -125,23 +218,40 @@
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;
}

var bytes = await httpResp.Content.ReadAsByteArrayAsync();
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);
}
}
Loading
Loading