diff --git a/Bezalu.ProjectReporting.API/Bezalu.ProjectReporting.API.csproj b/Bezalu.ProjectReporting.API/Bezalu.ProjectReporting.API.csproj index a3687a8..6d72cbf 100644 --- a/Bezalu.ProjectReporting.API/Bezalu.ProjectReporting.API.csproj +++ b/Bezalu.ProjectReporting.API/Bezalu.ProjectReporting.API.csproj @@ -1,7 +1,7 @@  - net9.0 + net10.0 v4 Exe enable @@ -12,12 +12,21 @@ - + - + - + + + + + + + + + + diff --git a/Bezalu.ProjectReporting.API/DTOs/ProjectCompletionReportRequest.cs b/Bezalu.ProjectReporting.API/DTOs/ProjectCompletionReportRequest.cs deleted file mode 100644 index d19670b..0000000 --- a/Bezalu.ProjectReporting.API/DTOs/ProjectCompletionReportRequest.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Bezalu.ProjectReporting.API.DTOs; - -public class ProjectCompletionReportRequest -{ - public int ProjectId { get; set; } -} diff --git a/Bezalu.ProjectReporting.API/DTOs/ProjectCompletionReportResponse.cs b/Bezalu.ProjectReporting.API/DTOs/ProjectCompletionReportResponse.cs deleted file mode 100644 index 1779c4b..0000000 --- a/Bezalu.ProjectReporting.API/DTOs/ProjectCompletionReportResponse.cs +++ /dev/null @@ -1,74 +0,0 @@ -namespace Bezalu.ProjectReporting.API.DTOs; - -public class ProjectCompletionReportResponse -{ - public int ProjectId { get; set; } - public string? ProjectName { get; set; } - public ProjectSummary? Summary { get; set; } - public TimelineAnalysis? Timeline { get; set; } - public BudgetAnalysis? Budget { get; set; } - public List? Phases { get; set; } - public List? Tickets { get; set; } - public string? AiGeneratedSummary { get; set; } - public DateTime GeneratedAt { get; set; } -} - -public class ProjectSummary -{ - public string? Status { get; set; } - public DateTime? ActualStart { get; set; } - public DateTime? ActualEnd { get; set; } - public DateTime? PlannedStart { get; set; } - public DateTime? PlannedEnd { get; set; } - public string? Manager { get; set; } - public string? Company { get; set; } -} - -public class TimelineAnalysis -{ - public int TotalDays { get; set; } - public int PlannedDays { get; set; } - public int VarianceDays { get; set; } - public string? ScheduleAdherence { get; set; } - public string? SchedulePerformance { get; set; } -} - -public class BudgetAnalysis -{ - public decimal EstimatedHours { get; set; } - public decimal ActualHours { get; set; } - public decimal VarianceHours { get; set; } - public decimal EstimatedCost { get; set; } - public decimal ActualCost { get; set; } - public decimal VarianceCost { get; set; } - public string? BudgetAdherence { get; set; } - public string? CostPerformance { get; set; } -} - -public class PhaseDetail -{ - public int PhaseId { get; set; } - public string? PhaseName { get; set; } - public string? Status { get; set; } - public DateTime? ActualStart { get; set; } - public DateTime? ActualEnd { get; set; } - public decimal EstimatedHours { get; set; } - public decimal ActualHours { get; set; } - public List? Notes { get; set; } - public string? Summary { get; set; } -} - -public class TicketSummary -{ - public int TicketId { get; set; } - public string? TicketNumber { get; set; } - public string? Summary { get; set; } - public string? Status { get; set; } - public string? Type { get; set; } - public string? SubType { get; set; } - public decimal EstimatedHours { get; set; } - public decimal ActualHours { get; set; } - public List? Notes { get; set; } - public DateTime? ClosedDate { get; set; } - public string? AssignedTo { get; set; } -} diff --git a/Bezalu.ProjectReporting.API/Functions/ProjectCompletionReportFunction.cs b/Bezalu.ProjectReporting.API/Functions/ProjectCompletionReportFunction.cs index 2c4573c..60a93a1 100644 --- a/Bezalu.ProjectReporting.API/Functions/ProjectCompletionReportFunction.cs +++ b/Bezalu.ProjectReporting.API/Functions/ProjectCompletionReportFunction.cs @@ -1,9 +1,12 @@ -using Bezalu.ProjectReporting.API.DTOs; using Bezalu.ProjectReporting.API.Services; +using Bezalu.ProjectReporting.Shared.DTOs; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Azure.Functions.Worker; using Microsoft.Extensions.Logging; +using QuestPDF; +using QuestPDF.Fluent; +using QuestPDF.Infrastructure; namespace Bezalu.ProjectReporting.API.Functions; @@ -13,7 +16,7 @@ public class ProjectCompletionReportFunction( { [Function("GenerateProjectCompletionReport")] public async Task Run( - [HttpTrigger(AuthorizationLevel.Function, "post", Route = "reports/project-completion")] + [HttpTrigger(AuthorizationLevel.Function, "post", Route = "reports/project-completion")] HttpRequest req, CancellationToken cancellationToken) { @@ -22,14 +25,12 @@ public async Task Run( try { var request = await req.ReadFromJsonAsync(cancellationToken); - - if (request == null || request.ProjectId <= 0) - { + + if (request is not { ProjectId: > 0 }) return new BadRequestObjectResult(new { error = "Invalid project ID" }); - } var report = await reportingService.GenerateProjectCompletionReportAsync( - request.ProjectId, + request.ProjectId, cancellationToken); return new OkObjectResult(report); @@ -48,4 +49,196 @@ public async Task Run( }; } } -} + + // Changed to POST and accepts full report body to avoid regeneration + [Function("GenerateProjectCompletionReportPdf")] + public async Task RunPdf( + [HttpTrigger(AuthorizationLevel.Function, "post", Route = "reports/project-completion/pdf")] + HttpRequest req, + CancellationToken cancellationToken) + { + logger.LogInformation("Processing project completion report PDF request (direct report body)"); + + try + { + var report = await req.ReadFromJsonAsync(cancellationToken); + if (report is not { ProjectId: > 0 }) + return new BadRequestObjectResult(new { error = "Invalid report payload" }); + + Settings.License = LicenseType.Community; + + var document = Document.Create(container => + { + container.Page(page => + { + page.Margin(30); + page.Header().Row(row => + { + row.RelativeItem().Column(stack => + { + stack.Item().Text(report.ProjectName ?? "Project").FontSize(18).SemiBold(); + stack.Item().Text($"Generated: {report.GeneratedAt:yyyy-MM-dd HH:mm} UTC").FontSize(9); + }); + row.ConstantItem(80).AlignRight().Column(stack => + { + stack.Item().Text($"ID: {report.ProjectId}").FontSize(10); + stack.Item().Text(report.Summary?.Status ?? string.Empty).FontSize(10); + }); + }); + + page.Content().Column(stack => + { + if (report.Summary != null) + stack.Item().PaddingBottom(10).Element(SummarySection(report)); + if (report.Timeline != null) + stack.Item().PaddingBottom(10).Element(TimelineSection(report)); + if (report.Budget != null) + stack.Item().PaddingBottom(10).Element(BudgetSection(report)); + if (!string.IsNullOrWhiteSpace(report.AiGeneratedSummary)) + stack.Item().PaddingBottom(10).Element(AISummarySection(report)); + if (report.Phases?.Any() == true) + stack.Item().PaddingBottom(10).Element(PhasesSection(report)); + if (report.Tickets?.Any() == true) + stack.Item().PaddingBottom(10).Element(TicketsSection(report)); + }); + + page.Footer().AlignCenter().Text(text => + { + text.Span("Project Completion Report - Page ").FontSize(9); + text.CurrentPageNumber().FontSize(9); + text.Span(" / ").FontSize(9); + text.TotalPages().FontSize(9); + }); + }); + }); + + var pdfBytes = document.GeneratePdf(); + return new FileContentResult(pdfBytes, "application/pdf") + { + FileDownloadName = $"project-{report.ProjectId}-completion-report.pdf" + }; + } + catch (Exception ex) + { + logger.LogError(ex, "Error generating project completion report PDF"); + return new ObjectResult(new { error = "An error occurred while generating the PDF" }) + { + StatusCode = StatusCodes.Status500InternalServerError + }; + } + } + + private static Action SummarySection(ProjectCompletionReportResponse report) + { + return c => + { + var s = report.Summary; + c.Column(col => + { + col.Item().Text("Project Summary").FontSize(14).Bold(); + col.Item().Text($"Manager: {s?.Manager ?? string.Empty}").FontSize(10); + col.Item().Text($"Company: {s?.Company ?? string.Empty}").FontSize(10); + col.Item().Text($"Status: {s?.Status ?? string.Empty}").FontSize(10); + if (s is { PlannedStart: not null, PlannedEnd: not null }) + col.Item().Text($"Planned: {s.PlannedStart:yyyy-MM-dd} > {s.PlannedEnd:yyyy-MM-dd}").FontSize(10); + if (s is { ActualStart: not null, ActualEnd: not null }) + col.Item().Text($"Actual: {s.ActualStart:yyyy-MM-dd} > {s.ActualEnd:yyyy-MM-dd}").FontSize(10); + }); + }; + } + + private static Action TimelineSection(ProjectCompletionReportResponse report) + { + return c => + { + var t = report.Timeline; + c.Column(col => + { + col.Item().Text("Timeline Analysis").FontSize(14).Bold(); + col.Item().Text($"Planned Days: {t?.PlannedDays}").FontSize(10); + col.Item().Text($"Actual Days: {t?.TotalDays}").FontSize(10); + col.Item().Text($"Variance: {t?.VarianceDays}").FontSize(10); + col.Item().Text($"Schedule Adherence: {t?.ScheduleAdherence}").FontSize(10); + col.Item().Text($"Schedule Performance: {t?.SchedulePerformance}").FontSize(10); + }); + }; + } + + private static Action BudgetSection(ProjectCompletionReportResponse report) + { + return c => + { + var b = report.Budget; + c.Column(col => + { + col.Item().Text("Budget Analysis").FontSize(14).Bold(); + col.Item().Text($"Estimated Hours: {b?.EstimatedHours}").FontSize(10); + col.Item().Text($"Actual Hours: {b?.ActualHours}").FontSize(10); + col.Item().Text($"Variance Hours: {b?.VarianceHours}").FontSize(10); + col.Item().Text($"Budget Adherence: {b?.BudgetAdherence}").FontSize(10); + col.Item().Text($"Cost Performance: {b?.CostPerformance}").FontSize(10); + }); + }; + } + + private static Action AISummarySection(ProjectCompletionReportResponse report) + { + return c => + { + c.Column(col => + { + col.Item().Text("AI Generated Summary").FontSize(14).Bold(); + col.Item().Text(report.AiGeneratedSummary ?? string.Empty).FontSize(10); + }); + }; + } + + private static Action PhasesSection(ProjectCompletionReportResponse report) + { + return c => + { + c.Column(col => + { + col.Item().Text("Phases").FontSize(14).Bold(); + foreach (var phase in report.Phases ?? []) + col.Item().BorderBottom(1).PaddingVertical(4).Column(inner => + { + inner.Item().Text($"{phase.PhaseName} ({phase.Status})").SemiBold(); + if (phase is { ActualStart: not null, ActualEnd: not null }) + inner.Item().Text($"Actual: {phase.ActualStart:yyyy-MM-dd} > {phase.ActualEnd:yyyy-MM-dd}") + .FontSize(9); + inner.Item().Text($"Hours est/actual: {phase.EstimatedHours}/{phase.ActualHours}").FontSize(9); + }); + }); + }; + } + + private static Action TicketsSection(ProjectCompletionReportResponse report) + { + return c => + { + c.Column(col => + { + col.Item().Text("Tickets").FontSize(14).Bold(); + foreach (var ticket in report.Tickets ?? []) + col.Item().BorderBottom(1).PaddingVertical(4).Column(inner => + { + inner.Item().Text($"#{ticket.TicketNumber} {ticket.Summary} ({ticket.Status})").SemiBold(); + inner.Item().Text($"Type: {ticket.Type}/{ticket.SubType}").FontSize(9); + inner.Item().Text($"Hours est/actual: {ticket.EstimatedHours}/{ticket.ActualHours}") + .FontSize(9); + if (ticket.ClosedDate != null) + inner.Item().Text($"Closed: {ticket.ClosedDate:yyyy-MM-dd}").FontSize(9); + if (!string.IsNullOrWhiteSpace(ticket.AssignedTo)) + inner.Item().Text($"Assigned: {ticket.AssignedTo}").FontSize(9); + if (ticket.Notes?.Any() != true) return; + inner.Item().Text("Notes:").FontSize(9); + foreach (var n in ticket.Notes.Take(10)) + inner.Item().Text($" - {n}").FontSize(9); + if (ticket.Notes.Count > 10) + inner.Item().Text($" - ... ({ticket.Notes.Count - 10} more notes truncated)").FontSize(9); + }); + }); + }; + } +} \ No newline at end of file diff --git a/Bezalu.ProjectReporting.API/README.md b/Bezalu.ProjectReporting.API/README.md index d1221ce..326e01a 100644 --- a/Bezalu.ProjectReporting.API/README.md +++ b/Bezalu.ProjectReporting.API/README.md @@ -1,202 +1,97 @@ -# Project Reporting API +# Project Reporting Application -This API provides project completion reporting functionality for ConnectWise Manage projects, with AI-powered analysis using Azure OpenAI. +Full solution providing interactive project completion reporting for ConnectWise Manage with AI summary and PDF export. -## Features +## Components -- **Project Completion Reports**: Generate comprehensive reports analyzing project completion, including: - - Timeline analysis (planned vs actual dates) - - Budget analysis (planned vs actual hours) - - Phase-by-phase breakdown - - Ticket summaries with notes - - AI-generated executive summary and insights +- Web Front-End (Blazor WebAssembly) served as static assets (Azure Static Web Apps). +- Serverless API (Azure Functions isolated worker) for data aggregation and AI summary. +- Shared DTO library for consistent contracts. +- PDF generation via QuestPDF using already fetched report payload (no regeneration). -## API Endpoints +## End-to-End Flow +1. User enters Project ID and triggers `POST /api/reports/project-completion`. +2. API aggregates ConnectWise data, builds `ProjectCompletionReportResponse`, calls Azure OpenAI for AI summary, returns JSON. +3. Front-end displays report (timeline, budget, phases, tickets, AI summary rendered as Markdown). +4. User clicks Download PDF; front-end posts the existing JSON report body to `POST /api/reports/project-completion/pdf` (avoids second data + AI call). API returns PDF bytes. +5. Front-end uses JS interop helper (`saveFile`) to download the PDF with filename `project-{id}-completion-report.pdf`. -### Generate Project Completion Report +## Primary API Endpoints -**Endpoint**: `POST /api/reports/project-completion` - -**Request Body**: -```json -{ - "projectId": 12345 -} +### POST /api/reports/project-completion +Request body: ``` - -**Response**: -```json -{ - "projectId": 12345, - "projectName": "Example Project", - "summary": { - "status": "Completed", - "actualStart": "2024-01-01T00:00:00Z", - "actualEnd": "2024-03-31T00:00:00Z", - "plannedStart": "2024-01-01T00:00:00Z", - "plannedEnd": "2024-03-15T00:00:00Z", - "manager": "John Doe", - "company": "Acme Corp" - }, - "timeline": { - "totalDays": 90, - "plannedDays": 74, - "varianceDays": 16, - "scheduleAdherence": "Behind Schedule", - "schedulePerformance": "121.6%" - }, - "budget": { - "estimatedHours": 500.0, - "actualHours": 550.0, - "varianceHours": 50.0, - "estimatedCost": 0, - "actualCost": 0, - "varianceCost": 0, - "budgetAdherence": "Slightly Over", - "costPerformance": "110.0%" - }, - "phases": [...], - "tickets": [...], - "aiGeneratedSummary": "...", - "generatedAt": "2024-10-29T04:00:00Z" -} +{ "projectId":12345 } ``` - -## Configuration - -The API requires the following configuration settings: - -### ConnectWise Configuration - -- `ConnectWise:BaseUrl`: The base URL for your ConnectWise API (default: `https://na.myconnectwise.net/v4_6_release/apis/3.0`) -- `ConnectWise:CompanyId`: Your ConnectWise company identifier -- `ConnectWise:PublicKey`: Your ConnectWise API public key -- `ConnectWise:PrivateKey`: Your ConnectWise API private key -- `ConnectWise:ClientId`: Your ConnectWise client ID (application identifier) - -### Azure OpenAI Configuration - -- `AzureOpenAI:Endpoint`: Your Azure OpenAI resource endpoint (e.g., `https://your-resource.openai.azure.com/`) -- `AzureOpenAI:ApiKey`: Your Azure OpenAI API key -- `AzureOpenAI:DeploymentName`: The deployment name for your GPT model (default: `gpt-4`) - -### Local Development - -For local development, update the `local.settings.json` file with your credentials: - -```json -{ - "IsEncrypted": false, - "Values": { - "AzureWebJobsStorage": "UseDevelopmentStorage=true", - "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated", - "ConnectWise:BaseUrl": "https://na.myconnectwise.net/v4_6_release/apis/3.0", - "ConnectWise:CompanyId": "your-company-id", - "ConnectWise:PublicKey": "your-public-key", - "ConnectWise:PrivateKey": "your-private-key", - "ConnectWise:ClientId": "your-client-id", - "AzureOpenAI:Endpoint": "https://your-resource-name.openai.azure.com/", - "AzureOpenAI:ApiKey": "your-api-key", - "AzureOpenAI:DeploymentName": "gpt-4" - } -} +Response: `ProjectCompletionReportResponse` (see docs/contract.md). + +### POST /api/reports/project-completion/pdf +Request body: full `ProjectCompletionReportResponse` previously returned by the first endpoint. +Response: `application/pdf` file. + +## Report Object Highlights +- Summary (status, dates, manager, company) +- TimelineAnalysis (planned vs actual days, variance, adherence, performance %) +- BudgetAnalysis (estimated vs actual hours, variance, adherence) +- Phases list (per phase hours + status) +- Tickets list (meta, hours, notes subset) +- AiGeneratedSummary (markdown rendered client-side with Markdig) + +## Operability Notes +- PDF endpoint expects complete report payload; front-end must retain JSON until download. +- AI call cost avoided for PDF generation due to reuse of existing summary. +- Truncation applied to notes for AI prompt and PDF to control size. + +## Local Development +Prerequisites: +- .NET SDK +- Azure Functions Core Tools +- Valid ConnectWise + Azure OpenAI credentials in `local.settings.json` (excluded from source control). + +Run API: ``` +func start +``` +Run front-end: +``` +dotnet run --project Bezalu.ProjectReporting.Web +``` +Front-end will proxy to API according to Static Web Apps configuration/emulator or manual CORS settings if needed. -### Azure Deployment - -When deploying to Azure Functions, configure these settings as Application Settings in the Azure Portal or via Azure CLI. - -## Architecture - -The API is built using: - -- **Azure Functions** (Isolated Worker Model) for serverless hosting -- **.NET 9.0** runtime -- **ConnectWise Manage API** for project data retrieval -- **Azure OpenAI** for AI-powered report generation - -### Key Components - -1. **ProjectCompletionReportFunction**: The main HTTP-triggered Azure Function endpoint -2. **IProjectReportingService**: Orchestrates data gathering and report generation -3. **IConnectWiseApiClient**: HTTP client wrapper for ConnectWise API calls -4. **IAzureOpenAIService**: Azure OpenAI integration for generating summaries -5. **DTOs**: Data Transfer Objects for request/response models -6. **Models**: ConnectWise entity models - -## Report Content - -The project completion report includes: - -1. **Project Summary**: Basic project information (status, dates, manager, company) -2. **Timeline Analysis**: - - Planned vs actual duration - - Schedule variance - - Schedule adherence assessment -3. **Budget Analysis**: - - Planned vs actual hours - - Hours variance - - Budget adherence assessment -4. **Phase Details**: For each project phase: - - Status and dates - - Estimated vs actual hours - - Associated notes -5. **Ticket Summaries**: For each ticket: - - Ticket number and summary - - Status, type, and subtype - - Estimated vs actual hours - - Associated notes - - Assignment information -6. **AI-Generated Summary**: Comprehensive analysis including: - - Overall project performance - - Time budget adherence - - Schedule adherence - - Quality of notes vs actual completion - - Key insights and recommendations - -## Usage for Frontend - -The JSON response is designed to be easily consumed by frontend applications (e.g., Blazor) for: - -- Displaying project metrics -- Generating PDF reports -- Creating data visualizations -- Presenting AI-generated insights - -The structured format allows frontend developers to: -- Bind directly to UI components -- Generate charts and graphs -- Create custom report layouts -- Export to various formats (PDF, Excel, etc.) - -## Error Handling - -The API returns appropriate HTTP status codes: - -- `200 OK`: Successful report generation -- `400 Bad Request`: Invalid project ID -- `404 Not Found`: Project not found -- `500 Internal Server Error`: Server error during processing - -## Security Considerations - -- Store API keys and credentials securely (use Azure Key Vault in production) -- The `local.settings.json` file is excluded from source control -- Use Function-level authorization (API keys) for production deployments -- Consider implementing additional authentication/authorization as needed - -## Development - -To run locally: +## Azure Deployment Overview +- Deploy Blazor WebAssembly output (Release) to Azure Static Web Apps. +- Deploy Azure Functions project to same Static Web Apps resource (api folder) or separate Functions App (configure SWA `api_location`). +- Set configuration (App Settings) for ConnectWise and Azure OpenAI keys; prefer Key Vault references in production. -1. Install Azure Functions Core Tools -2. Configure `local.settings.json` with valid credentials -3. Run `func start` or press F5 in Visual Studio +Required App Settings: +- `ConnectWise:BaseUrl`, `ConnectWise:CompanyId`, `ConnectWise:PublicKey`, `ConnectWise:PrivateKey`, `ConnectWise:ClientId` +- `AzureOpenAI:Endpoint`, `AzureOpenAI:DeploymentName` (credential via `DefaultAzureCredential` in code; ensure managed identity / RBAC permissions) -To test the endpoint: +## Performance & Size +- WASM project uses trimming + AOT for faster runtime once cached; consider disabling AOT in Debug for faster builds. +- AI prompt size limited by truncation strategies in service. -```bash -curl -X POST http://localhost:7071/api/reports/project-completion \ - -H "Content-Type: application/json" \ - -d '{"projectId": 12345}' -``` +## Error Handling +-400 invalid project id or invalid report payload for PDF endpoint. +-404 project not found. +-500 unexpected processing errors. + +## Security +- Function auth level currently `Function`; set keys or add front-end auth (e.g., Entra ID) before production. +- Do not send sensitive data inside report payload for PDF endpoint; only project analysis data. + +## Extensibility +- Add cached layer to reuse raw data for multiple exports. +- Extend PDF sections (charts) by computing aggregates client-side and passing them in extended DTO. +- Add Excel export by introducing another POST /api/reports/project-completion/excel endpoint using a spreadsheet library server-side. + +## Documentation +See `/docs` for deeper details: +- architecture.md (layer & data flow) +- contract.md (DTO shapes) +- pdf.md (PDF composition logic) +- deployment.md (Azure setup steps) +- frontend.md (UI behaviors) + +--- +This README targets the operational overview; for detailed structures consult docs directory. diff --git a/Bezalu.ProjectReporting.API/Services/AzureOpenAIService.cs b/Bezalu.ProjectReporting.API/Services/AzureOpenAIService.cs index f56b348..4f43dc2 100644 --- a/Bezalu.ProjectReporting.API/Services/AzureOpenAIService.cs +++ b/Bezalu.ProjectReporting.API/Services/AzureOpenAIService.cs @@ -1,5 +1,3 @@ -using System.Linq; -using Azure.AI.OpenAI; using Microsoft.Extensions.Logging; using OpenAI.Chat; diff --git a/Bezalu.ProjectReporting.API/Services/ConnectWiseApiClient.cs b/Bezalu.ProjectReporting.API/Services/ConnectWiseApiClient.cs index febab52..68ac7e8 100644 --- a/Bezalu.ProjectReporting.API/Services/ConnectWiseApiClient.cs +++ b/Bezalu.ProjectReporting.API/Services/ConnectWiseApiClient.cs @@ -2,8 +2,6 @@ using Microsoft.Extensions.Logging; using System.Net.Http.Json; using System.Text; -using System.Linq; -using System.Net.Http; namespace Bezalu.ProjectReporting.API.Services; @@ -25,12 +23,6 @@ public class ConnectWiseApiClient(HttpClient httpClient, IConfiguration configur private bool _configured; - private static string NormalizeBase(string url) - { - if (string.IsNullOrWhiteSpace(url)) return "https://api-na.myconnectwise.net/v4_6_release/apis/3.0/"; // fallback - return url.EndsWith('/') ? url : url + "/"; - } - private void EnsureConfigured() { if (_configured) return; diff --git a/Bezalu.ProjectReporting.API/Services/ProjectReportingService.cs b/Bezalu.ProjectReporting.API/Services/ProjectReportingService.cs index 60c6d7c..5dd7d9a 100644 --- a/Bezalu.ProjectReporting.API/Services/ProjectReportingService.cs +++ b/Bezalu.ProjectReporting.API/Services/ProjectReportingService.cs @@ -1,8 +1,7 @@ -using Bezalu.ProjectReporting.API.DTOs; +using Bezalu.ProjectReporting.Shared.DTOs; using Bezalu.ProjectReporting.API.Models; using Microsoft.Extensions.Logging; using System.Text; -using System.Text.Json; namespace Bezalu.ProjectReporting.API.Services; diff --git a/Bezalu.ProjectReporting.Shared/Bezalu.ProjectReporting.Shared.csproj b/Bezalu.ProjectReporting.Shared/Bezalu.ProjectReporting.Shared.csproj new file mode 100644 index 0000000..b760144 --- /dev/null +++ b/Bezalu.ProjectReporting.Shared/Bezalu.ProjectReporting.Shared.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/Bezalu.ProjectReporting.Shared/DTOs/ProjectCompletionReportModels.cs b/Bezalu.ProjectReporting.Shared/DTOs/ProjectCompletionReportModels.cs new file mode 100644 index 0000000..bb64194 --- /dev/null +++ b/Bezalu.ProjectReporting.Shared/DTOs/ProjectCompletionReportModels.cs @@ -0,0 +1,79 @@ +namespace Bezalu.ProjectReporting.Shared.DTOs; + +public class ProjectCompletionReportRequest +{ + public int ProjectId { get; set; } +} + +public class ProjectCompletionReportResponse +{ + public int ProjectId { get; set; } + public string? ProjectName { get; set; } + public ProjectSummary? Summary { get; set; } + public TimelineAnalysis? Timeline { get; set; } + public BudgetAnalysis? Budget { get; set; } + public List? Phases { get; set; } + public List? Tickets { get; set; } + public string? AiGeneratedSummary { get; set; } + public DateTime GeneratedAt { get; set; } +} + +public class ProjectSummary +{ + public string? Status { get; set; } + public DateTime? ActualStart { get; set; } + public DateTime? ActualEnd { get; set; } + public DateTime? PlannedStart { get; set; } + public DateTime? PlannedEnd { get; set; } + public string? Manager { get; set; } + public string? Company { get; set; } +} + +public class TimelineAnalysis +{ + public int TotalDays { get; set; } + public int PlannedDays { get; set; } + public int VarianceDays { get; set; } + public string? ScheduleAdherence { get; set; } + public string? SchedulePerformance { get; set; } +} + +public class BudgetAnalysis +{ + public decimal EstimatedHours { get; set; } + public decimal ActualHours { get; set; } + public decimal VarianceHours { get; set; } + public decimal EstimatedCost { get; set; } + public decimal ActualCost { get; set; } + public decimal VarianceCost { get; set; } + public string? BudgetAdherence { get; set; } + public string? CostPerformance { get; set; } +} + +public class PhaseDetail +{ + public int PhaseId { get; set; } + public string? PhaseName { get; set; } + public string? Status { get; set; } + public DateTime? ActualStart { get; set; } + public DateTime? ActualEnd { get; set; } + public decimal EstimatedHours { get; set; } + public decimal ActualHours { get; set; } + public List? Notes { get; set; } + public string? Summary { get; set; } +} + +public class TicketSummary +{ + public int TicketId { get; set; } + public string? TicketNumber { get; set; } + public string? Summary { get; set; } + public string? Status { get; set; } + public string? Type { get; set; } + public string? SubType { get; set; } + public decimal EstimatedHours { get; set; } + public decimal ActualHours { get; set; } + public List? Notes { get; set; } + public DateTime? ClosedDate { get; set; } + public string? AssignedTo { get; set; } +} diff --git a/Bezalu.ProjectReporting.Web/App.razor b/Bezalu.ProjectReporting.Web/App.razor index 8f39933..d2deba2 100644 --- a/Bezalu.ProjectReporting.Web/App.razor +++ b/Bezalu.ProjectReporting.Web/App.razor @@ -1,11 +1,5 @@ - + - - + - - - - - - + \ No newline at end of file diff --git a/Bezalu.ProjectReporting.Web/Bezalu.ProjectReporting.Web.csproj b/Bezalu.ProjectReporting.Web/Bezalu.ProjectReporting.Web.csproj index 43c6750..45401cb 100644 --- a/Bezalu.ProjectReporting.Web/Bezalu.ProjectReporting.Web.csproj +++ b/Bezalu.ProjectReporting.Web/Bezalu.ProjectReporting.Web.csproj @@ -1,21 +1,41 @@ - + - net9.0 + net10.0 enable enable - true service-worker-assets.js + + true + true + true + false + true + + + false + + + + + + + + + + - - - + + diff --git a/Bezalu.ProjectReporting.Web/DTOs/ProjectCompletionReportRequest.cs b/Bezalu.ProjectReporting.Web/DTOs/ProjectCompletionReportRequest.cs deleted file mode 100644 index cbeacab..0000000 --- a/Bezalu.ProjectReporting.Web/DTOs/ProjectCompletionReportRequest.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Bezalu.ProjectReporting.Web.DTOs; - -public class ProjectCompletionReportRequest -{ - public int ProjectId { get; set; } -} diff --git a/Bezalu.ProjectReporting.Web/DTOs/ProjectCompletionReportResponse.cs b/Bezalu.ProjectReporting.Web/DTOs/ProjectCompletionReportResponse.cs deleted file mode 100644 index 3b226fa..0000000 --- a/Bezalu.ProjectReporting.Web/DTOs/ProjectCompletionReportResponse.cs +++ /dev/null @@ -1,74 +0,0 @@ -namespace Bezalu.ProjectReporting.Web.DTOs; - -public class ProjectCompletionReportResponse -{ - public int ProjectId { get; set; } - public string? ProjectName { get; set; } - public ProjectSummary? Summary { get; set; } - public TimelineAnalysis? Timeline { get; set; } - public BudgetAnalysis? Budget { get; set; } - public List? Phases { get; set; } - public List? Tickets { get; set; } - public string? AiGeneratedSummary { get; set; } - public DateTime GeneratedAt { get; set; } -} - -public class ProjectSummary -{ - public string? Status { get; set; } - public DateTime? ActualStart { get; set; } - public DateTime? ActualEnd { get; set; } - public DateTime? PlannedStart { get; set; } - public DateTime? PlannedEnd { get; set; } - public string? Manager { get; set; } - public string? Company { get; set; } -} - -public class TimelineAnalysis -{ - public int TotalDays { get; set; } - public int PlannedDays { get; set; } - public int VarianceDays { get; set; } - public string? ScheduleAdherence { get; set; } - public string? SchedulePerformance { get; set; } -} - -public class BudgetAnalysis -{ - public decimal EstimatedHours { get; set; } - public decimal ActualHours { get; set; } - public decimal VarianceHours { get; set; } - public decimal EstimatedCost { get; set; } - public decimal ActualCost { get; set; } - public decimal VarianceCost { get; set; } - public string? BudgetAdherence { get; set; } - public string? CostPerformance { get; set; } -} - -public class PhaseDetail -{ - public int PhaseId { get; set; } - public string? PhaseName { get; set; } - public string? Status { get; set; } - public DateTime? ActualStart { get; set; } - public DateTime? ActualEnd { get; set; } - public decimal EstimatedHours { get; set; } - public decimal ActualHours { get; set; } - public List? Notes { get; set; } - public string? Summary { get; set; } -} - -public class TicketSummary -{ - public int TicketId { get; set; } - public string? TicketNumber { get; set; } - public string? Summary { get; set; } - public string? Status { get; set; } - public string? Type { get; set; } - public string? SubType { get; set; } - public decimal EstimatedHours { get; set; } - public decimal ActualHours { get; set; } - public List? Notes { get; set; } - public DateTime? ClosedDate { get; set; } - public string? AssignedTo { get; set; } -} diff --git a/Bezalu.ProjectReporting.Web/Layout/MainLayout.razor b/Bezalu.ProjectReporting.Web/Layout/MainLayout.razor index e1a9a75..cfc155a 100644 --- a/Bezalu.ProjectReporting.Web/Layout/MainLayout.razor +++ b/Bezalu.ProjectReporting.Web/Layout/MainLayout.razor @@ -1,3 +1,14 @@ @inherits LayoutComponentBase -@Body + + Home + +
+ @Body +
+ + + + + + diff --git a/Bezalu.ProjectReporting.Web/Pages/Home.razor b/Bezalu.ProjectReporting.Web/Pages/Home.razor index b547a73..d0a374d 100644 --- a/Bezalu.ProjectReporting.Web/Pages/Home.razor +++ b/Bezalu.ProjectReporting.Web/Pages/Home.razor @@ -1,188 +1,147 @@ @page "/" -@using Bezalu.ProjectReporting.Web.DTOs @inject HttpClient Http +@inject NavigationManager Nav +@inject IJSRuntime JS @using Markdig -Project Report +Project Reporting -

Project Completion Report

- -
-
-
- - -
-
- -
-
- @if (errorMessage != null) - { -
@errorMessage
- } - @if (loading) - { -
- Loading... -
- } -
- -@if (report != null) -{ -
-
-

@report.ProjectName (#@report.ProjectId)

- -
-
- Status: @report.Summary?.Status - Manager: @report.Summary?.Manager - Company: @report.Summary?.Company - Generated: @report.GeneratedAt.ToLocalTime() -
- - @if (!string.IsNullOrWhiteSpace(report.AiGeneratedSummary)) + +

Generate Project Completion Report

+ + + Generate Report + @(IsPdfLoading ? "Generating PDF..." : "Download PDF") + @if (IsLoading) { -
-

AI Summary

-
- @((MarkupString)markdownHtml) -
-
+ } - @if (report.Timeline != null) + @if (!string.IsNullOrEmpty(ErrorMessage)) { -
-

Timeline

-
-
Planned Days@report.Timeline.PlannedDays
-
Actual Days@report.Timeline.TotalDays
-
Variance@report.Timeline.VarianceDays (@report.Timeline.ScheduleAdherence)
-
Performance@report.Timeline.SchedulePerformance
-
-
+ @ErrorMessage } - @if (report.Budget != null) - { -
-

Budget

-
-
Estimated Hrs@report.Budget.EstimatedHours
-
Actual Hrs@report.Budget.ActualHours
-
Variance@report.Budget.VarianceHours (@report.Budget.BudgetAdherence)
-
Cost Perf.@report.Budget.CostPerformance
-
-
- } - @if (report.Phases?.Any() == true) - { -
-

Phases (@report.Phases.Count)

- - - - - - - - - - - @foreach (var p in report.Phases) - { - - - - - - - } - -
PhaseStatusEst HrsAct Hrs
@p.PhaseName@p.Status@p.EstimatedHours@p.ActualHours
-
- } - @if (report.Tickets?.Any() == true) +
+
+ +@if (Report is not null) +{ + +

@Report.ProjectName (#@Report.ProjectId)

+

+ Status: @Report.Summary?.Status +

+

Manager: @Report.Summary?.Manager | Company: @Report.Summary?.Company

+

Timeline: @Report.Timeline?.PlannedDays d planned / @Report.Timeline?.TotalDays d actual (Var: @Report.Timeline?.VarianceDays)

+

Budget: @Report.Budget?.EstimatedHours h est / @Report.Budget?.ActualHours h actual (Var: @Report.Budget?.VarianceHours)

+ + @if (!string.IsNullOrWhiteSpace(Report.AiGeneratedSummary)) { -
-

Tickets (@report.Tickets.Count)

- - - - - - - - - - - - @foreach (var t in report.Tickets) - { - - - - - - - - } - -
#SummaryStatusEst/Act HrsAssigned
@t.TicketNumber@t.Summary@t.Status@t.EstimatedHours / @t.ActualHours@t.AssignedTo
-
+ + + +
@(new MarkupString(AiSummaryHtml))
+
+
} -
+ + + + @if (Report.Phases is null || Report.Phases.Count == 0) + { +

No phases.

+ } + else + { + + + + + + + } +
+ + @if (Report.Tickets is null || Report.Tickets.Count == 0) + { +

No tickets.

+ } + else + { + + + + + + + + } +
+
+ } @code { - private int projectId; - private bool loading; - private string? errorMessage; - private ProjectCompletionReportResponse? report; - private string markdownHtml = string.Empty; + int ProjectId { get; set; } + ProjectCompletionReportResponse? Report; + bool IsLoading; + bool IsPdfLoading; + string? ErrorMessage; + string AiSummaryHtml => Report?.AiGeneratedSummary is null ? string.Empty : Markdown.ToHtml(Report.AiGeneratedSummary); - private async Task GenerateReport() + async Task GenerateReport() { - errorMessage = null; - report = null; - markdownHtml = string.Empty; - loading = true; + ErrorMessage = null; + IsLoading = true; + Report = null; + StateHasChanged(); try { - var req = new ProjectCompletionReportRequest { ProjectId = projectId }; - var response = await Http.PostAsJsonAsync("api/reports/project-completion", req); - if (response.IsSuccessStatusCode) + var req = new ProjectCompletionReportRequest { ProjectId = ProjectId }; + var httpResp = await Http.PostAsJsonAsync("api/reports/project-completion", req); + if (!httpResp.IsSuccessStatusCode) { - report = await response.Content.ReadFromJsonAsync(); - if (!string.IsNullOrWhiteSpace(report?.AiGeneratedSummary)) - { - var pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build(); - markdownHtml = Markdown.ToHtml(report.AiGeneratedSummary, pipeline); - } + ErrorMessage = $"Failed: {httpResp.StatusCode}"; } else { - errorMessage = $"Error: {response.StatusCode}"; + Report = await httpResp.Content.ReadFromJsonAsync(); } } catch (Exception ex) { - errorMessage = ex.Message; + ErrorMessage = ex.Message; } finally { - loading = false; + IsLoading = false; } } - private async Task ExportPdf() + async Task DownloadPdf() { - if (report == null) return; - await JSRuntime.InvokeVoidAsync("exportReportPdf", "report-content", $"ProjectReport-{report.ProjectId}.pdf"); - } - - private void ToggleWrap() { /* placeholder for future interaction */ } + if (Report is null) return; + ErrorMessage = null; + IsPdfLoading = true; + try + { + var httpResp = await Http.PostAsJsonAsync("api/reports/project-completion/pdf", Report); + if (!httpResp.IsSuccessStatusCode) + { + ErrorMessage = $"PDF failed: {httpResp.StatusCode}"; + return; + } - [Inject] private IJSRuntime JSRuntime { get; set; } = default!; -} + 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) + { + ErrorMessage = ex.Message; + } + finally + { + IsPdfLoading = false; + } + } +} \ No newline at end of file diff --git a/Bezalu.ProjectReporting.Web/Pages/NotFound.razor b/Bezalu.ProjectReporting.Web/Pages/NotFound.razor index 917ada1..655a6d3 100644 --- a/Bezalu.ProjectReporting.Web/Pages/NotFound.razor +++ b/Bezalu.ProjectReporting.Web/Pages/NotFound.razor @@ -1,5 +1,7 @@ @page "/not-found" @layout MainLayout -

Not Found

-

Sorry, the content you are looking for does not exist.

\ No newline at end of file +Not found + +

Sorry, there's nothing at this address.

+
\ No newline at end of file diff --git a/Bezalu.ProjectReporting.Web/Program.cs b/Bezalu.ProjectReporting.Web/Program.cs index 1a97f8c..4b9d5f9 100644 --- a/Bezalu.ProjectReporting.Web/Program.cs +++ b/Bezalu.ProjectReporting.Web/Program.cs @@ -1,11 +1,13 @@ using Bezalu.ProjectReporting.Web; using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using Microsoft.FluentUI.AspNetCore.Components; var builder = WebAssemblyHostBuilder.CreateDefault(args); builder.RootComponents.Add("#app"); builder.RootComponents.Add("head::after"); builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); +builder.Services.AddFluentUIComponents(); await builder.Build().RunAsync(); diff --git a/Bezalu.ProjectReporting.Web/Properties/launchSettings.json b/Bezalu.ProjectReporting.Web/Properties/launchSettings.json index 8755e51..d588125 100644 --- a/Bezalu.ProjectReporting.Web/Properties/launchSettings.json +++ b/Bezalu.ProjectReporting.Web/Properties/launchSettings.json @@ -7,7 +7,7 @@ "launchBrowser": true, "launchUrl": "http://localhost:4280", "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", - "applicationUrl": "http://localhost:5107", + "applicationUrl": "http://localhost:5221", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/Bezalu.ProjectReporting.Web/_Imports.razor b/Bezalu.ProjectReporting.Web/_Imports.razor index 1cd301f..d08f8a4 100644 --- a/Bezalu.ProjectReporting.Web/_Imports.razor +++ b/Bezalu.ProjectReporting.Web/_Imports.razor @@ -5,6 +5,10 @@ @using Microsoft.AspNetCore.Components.Web @using Microsoft.AspNetCore.Components.Web.Virtualization @using Microsoft.AspNetCore.Components.WebAssembly.Http +@using Microsoft.FluentUI.AspNetCore.Components +@using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons + @using Microsoft.JSInterop @using Bezalu.ProjectReporting.Web @using Bezalu.ProjectReporting.Web.Layout +@using Bezalu.ProjectReporting.Shared.DTOs diff --git a/Bezalu.ProjectReporting.Web/wwwroot/css/app.css b/Bezalu.ProjectReporting.Web/wwwroot/css/app.css index 67efb94..2d7b409 100644 --- a/Bezalu.ProjectReporting.Web/wwwroot/css/app.css +++ b/Bezalu.ProjectReporting.Web/wwwroot/css/app.css @@ -1,27 +1,73 @@ -.valid.modified:not([type=checkbox]) { - outline: 1px solid #26b050; +@import '_content/Microsoft.FluentUI.AspNetCore.Components/css/reboot.css'; + +body { + --body-font: "Segoe UI Variable", "Segoe UI", sans-serif; + font-family: var(--body-font); + font-size: var(--type-ramp-base-font-size); + line-height: var(--type-ramp-base-line-height); + margin: 0; +} + +.navmenu-icon { + display: none; +} + +.main { + min-height: calc(100dvh - 86px); + color: var(--neutral-foreground-rest); + align-items: stretch !important; +} + +.body-content { + align-self: stretch; + height: calc(100dvh - 86px) !important; + display: flex; } -.invalid { - outline: 1px solid red; +.content { + padding: 0.5rem 1.5rem; + align-self: stretch !important; + width: 100%; } -.validation-message { - color: red; +footer { + background: var(--neutral-layer-4); + color: var(--neutral-foreground-rest); + align-items: center; + padding: 10px 10px; +} + + footer a { + color: var(--neutral-foreground-rest); + text-decoration: none; + } + + footer a:focus { + outline: 1px dashed; + outline-offset: 3px; + } + + footer a:hover { + text-decoration: underline; + } + +.alert { + border: 1px dashed var(--accent-fill-rest); + padding: 5px; } + #blazor-error-ui { - color-scheme: light only; background: lightyellow; bottom: 0; box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); - box-sizing: border-box; display: none; left: 0; padding: 0.6rem 1.25rem 0.7rem 1.25rem; position: fixed; width: 100%; z-index: 1000; + margin: 20px 0; } #blazor-error-ui .dismiss { @@ -37,17 +83,16 @@ color: white; } - .blazor-error-boundary::after { - content: "An error has occurred." + .blazor-error-boundary::before { + content: "An error has occurred. " } .loading-progress { - position: absolute; + position: relative; display: block; width: 8rem; height: 8rem; - inset: 20vh 0 auto 0; - margin: 0 auto 0 auto; + margin: 20vh auto 1rem auto; } .loading-progress circle { @@ -79,101 +124,64 @@ code { color: #c02d76; } -.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder { - color: var(--bs-secondary-color); - text-align: end; -} - -.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder { - text-align: start; -} - -/* Report styles */ -.report-container { - background: #fff; - border: 1px solid #e3e6eb; - border-radius: .5rem; - padding: 1.25rem 1.5rem; - box-shadow: 0 .125rem .5rem rgba(0, 0, 0, .06); -} - -.report-container h2 { - font-weight: 600; -} - -.meta span { - display: inline-block; - margin-bottom: .25rem; -} +@media (max-width: 600px) { + .header-gutters { + margin: 0.5rem 3rem 0.5rem 1.5rem !important; + } -.stat { - background: #f8f9fa; - border: 1px solid #e3e6eb; - border-radius: .25rem; - padding: .5rem .6rem; - display: flex; - flex-direction: column; - gap: .15rem; - height: 100%; -} + [dir="rtl"] .header-gutters { + margin: 0.5rem 1.5rem 0.5rem 3rem !important; + } -.stat .label { - font-size: .65rem; - text-transform: uppercase; - letter-spacing: .05em; - color: #6c757d; -} + .main { + flex-direction: column !important; + row-gap: 0 !important; + } -.stat .value { - font-weight: 600; -} + nav.sitenav { + width: 100%; + height: 100%; + } -.markdown { - background: #fcfcfd; - border: 1px solid #e3e6eb; - border-radius: .25rem; - padding: .75rem .85rem; - font-size: .9rem; - line-height: 1.3rem; -} + #main-menu { + width: 100% !important; + } -.markdown h1, .markdown h2, .markdown h3, .markdown h4 { - margin-top: 1rem; - font-weight: 600; -} + #main-menu > div:first-child:is(.expander) { + display: none; + } -.markdown p { - margin: .5rem 0; -} + .navmenu { + width: 100%; + } -.markdown ul { - padding-left: 1.15rem; - margin: .5rem 0; -} + #navmenu-toggle { + appearance: none; + } -.markdown li { - margin: .25rem 0; -} + #navmenu-toggle ~ nav { + display: none; + } -.markdown strong { - font-weight: 600; -} + #navmenu-toggle:checked ~ nav { + display: block; + } -.badge { - font-size: .6rem; - letter-spacing: .05em; -} + .navmenu-icon { + cursor: pointer; + z-index: 10; + display: block; + position: absolute; + top: 15px; + left: unset; + right: 20px; + width: 20px; + height: 20px; + border: none; + } -.table-sm td, .table-sm th { - padding: .35rem .5rem; + [dir="rtl"] .navmenu-icon { + left: 20px; + right: unset; + } } - -@media (max-width: 768px) { - .report-container { - padding: 1rem; - } - - .stat { - padding: .4rem .5rem; - } -} \ No newline at end of file diff --git a/Bezalu.ProjectReporting.Web/wwwroot/favicon.ico b/Bezalu.ProjectReporting.Web/wwwroot/favicon.ico new file mode 100644 index 0000000..e189d8e Binary files /dev/null and b/Bezalu.ProjectReporting.Web/wwwroot/favicon.ico differ diff --git a/Bezalu.ProjectReporting.Web/wwwroot/index.html b/Bezalu.ProjectReporting.Web/wwwroot/index.html index a89c156..1211693 100644 --- a/Bezalu.ProjectReporting.Web/wwwroot/index.html +++ b/Bezalu.ProjectReporting.Web/wwwroot/index.html @@ -7,9 +7,8 @@ Bezalu.ProjectReporting.Web + - @@ -30,10 +29,9 @@ Reload 🗙 - - - + + diff --git a/Bezalu.ProjectReporting.Web/wwwroot/js/fileSave.js b/Bezalu.ProjectReporting.Web/wwwroot/js/fileSave.js new file mode 100644 index 0000000..d5d5f72 --- /dev/null +++ b/Bezalu.ProjectReporting.Web/wwwroot/js/fileSave.js @@ -0,0 +1,19 @@ +window.saveFile = (base64, fileName, mime) => { + try { + const binary = atob(base64); + const len = binary.length; + const bytes = new Uint8Array(len); + for (let i =0; i < len; i++) bytes[i] = binary.charCodeAt(i); + const blob = new Blob([bytes], { type: mime || 'application/octet-stream' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = fileName || 'download'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url),5000); + } catch (e) { + console.error('saveFile error', e); + } +}; \ No newline at end of file diff --git a/Bezalu.ProjectReporting.Web/wwwroot/js/reportExport.js b/Bezalu.ProjectReporting.Web/wwwroot/js/reportExport.js deleted file mode 100644 index e800682..0000000 --- a/Bezalu.ProjectReporting.Web/wwwroot/js/reportExport.js +++ /dev/null @@ -1,22 +0,0 @@ -window.exportReportPdf = async function(elementId, fileName){ - try { - const element = document.getElementById(elementId); - if(!element){ - console.error('Element not found for PDF export', elementId); - return; - } - // Basic print dialog as placeholder. For real PDFs integrate something like jsPDF + html2canvas. - const printContents = element.innerHTML; - const win = window.open('', '', 'height=800,width=800'); - win.document.write('' + fileName + ''); - win.document.write(''); - win.document.write(printContents); - win.document.write(''); - win.document.close(); - win.focus(); - win.print(); - win.close(); - } catch(e){ - console.error('PDF export failed', e); - } -}; diff --git a/Bezalu.ProjectReporting.Web/wwwroot/service-worker.published.js b/Bezalu.ProjectReporting.Web/wwwroot/service-worker.published.js index 51a0e5c..1f7f543 100644 --- a/Bezalu.ProjectReporting.Web/wwwroot/service-worker.published.js +++ b/Bezalu.ProjectReporting.Web/wwwroot/service-worker.published.js @@ -8,7 +8,7 @@ self.addEventListener('fetch', event => event.respondWith(onFetch(event))); const cacheNamePrefix = 'offline-cache-'; const cacheName = `${cacheNamePrefix}${self.assetsManifest.version}`; -const offlineAssetsInclude = [ /\.dll$/, /\.pdb$/, /\.wasm/, /\.html/, /\.js$/, /\.json$/, /\.css$/, /\.woff$/, /\.png$/, /\.jpe?g$/, /\.gif$/, /\.ico$/, /\.blat$/, /\.dat$/, /\.webmanifest$/ ]; +const offlineAssetsInclude = [ /\.dll$/, /\.pdb$/, /\.wasm/, /\.html/, /\.js$/, /\.json$/, /\.css$/, /\.woff$/, /\.png$/, /\.jpe?g$/, /\.gif$/, /\.ico$/, /\.blat$/, /\.dat$/ ]; const offlineAssetsExclude = [ /^service-worker\.js$/ ]; // Replace with your base path if you are hosting on a subfolder. Ensure there is a trailing '/'. diff --git a/Bezalu.ProjectReporting.slnx b/Bezalu.ProjectReporting.slnx index 7e5ba3d..3dff933 100644 --- a/Bezalu.ProjectReporting.slnx +++ b/Bezalu.ProjectReporting.slnx @@ -1,4 +1,5 @@ - + + diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..46658d1 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,33 @@ +# Architecture + +## Overview +The solution consists of three projects: +- Bezalu.ProjectReporting.Web (Blazor WebAssembly front end) +- Bezalu.ProjectReporting.API (Azure Functions isolated worker back end) +- Bezalu.ProjectReporting.Shared (DTO contracts shared by both) + +## Data Flow +1. User enters Project ID in Web UI. +2. Front end POSTs projectId to `/api/reports/project-completion`. +3. API fetches project, phases, tickets, notes from ConnectWise. +4. API builds `ProjectCompletionReportResponse` and invokes Azure OpenAI to produce `AiGeneratedSummary`. +5. JSON returned to client; client renders summary markdown using Markdig. +6. User initiates PDF download; front end POSTs the full report JSON to `/api/reports/project-completion/pdf`. +7. API composes PDF using QuestPDF with supplied data (skip re-fetch & AI). +8. Client receives PDF bytes and triggers browser download via JS interop. + +## Key Services +- `IConnectWiseApiClient`: wraps HTTP calls to ConnectWise endpoints. +- `IProjectReportingService`: orchestrates data retrieval, report building, AI prompt assembly. +- `IAzureOpenAIService`: abstraction over Azure OpenAI ChatClient for summary generation. + +## Design Choices +- DTO reuse avoids duplication between front end and API. +- POST for PDF avoids second expensive aggregation call. +- Markdown + Markdig chosen for flexibility in AI summary formatting. +- QuestPDF chosen for deterministic server-side PDF rendering. + +## Future Enhancements +- Caching of raw ConnectWise responses. +- Additional export formats (Excel, HTML full report). +- Authentication (OIDC) integration for user-level access. diff --git a/CONFIGURATION.md b/docs/configuration.md similarity index 100% rename from CONFIGURATION.md rename to docs/configuration.md diff --git a/docs/contract.md b/docs/contract.md new file mode 100644 index 0000000..0363a7c --- /dev/null +++ b/docs/contract.md @@ -0,0 +1,74 @@ +# Report Contract (DTOs) + +## ProjectCompletionReportRequest +```jsonc +{ + "projectId":12345 +} +``` + +## ProjectCompletionReportResponse +```jsonc +{ + "projectId":12345, + "projectName": "Example Project", + "summary": { + "status": "Completed", + "actualStart": "2024-01-01T00:00:00Z", + "actualEnd": "2024-03-31T00:00:00Z", + "plannedStart": "2024-01-01T00:00:00Z", + "plannedEnd": "2024-03-15T00:00:00Z", + "manager": "John Doe", + "company": "Acme Corp" + }, + "timeline": { + "totalDays":90, + "plannedDays":74, + "varianceDays":16, + "scheduleAdherence": "Behind Schedule", + "schedulePerformance": "121.6%" + }, + "budget": { + "estimatedHours":500.0, + "actualHours":550.0, + "varianceHours":50.0, + "estimatedCost":0, + "actualCost":0, + "varianceCost":0, + "budgetAdherence": "Slightly Over", + "costPerformance": "110.0%" + }, + "phases": [ + { + "phaseId":1, + "phaseName": "Planning", + "status": "Complete", + "actualStart": "2024-01-01T00:00:00Z", + "actualEnd": "2024-01-05T00:00:00Z", + "estimatedHours":40.0, + "actualHours":42.5, + "notes": ["Initial kickoff done"], + "summary": null + } + ], + "tickets": [ + { + "ticketId":100, + "ticketNumber": "123456", + "summary": "Implement feature X", + "status": "Closed", + "type": "Development", + "subType": "Enhancement", + "estimatedHours":16.0, + "actualHours":18.25, + "notes": ["Reviewed by QA", "Minor fixes"] + } + ], + "aiGeneratedSummary": "...markdown text...", + "generatedAt": "2024-10-29T04:00:00Z" +} +``` + +## Notes +- Ticket and phase notes truncated for AI prompt & PDF. +- `AiGeneratedSummary` is markdown; client renders safely via Markdig. diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..44c3fa8 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,31 @@ +# Deployment + +## Azure Static Web Apps + Azure Functions +- Front end (Blazor WASM) deployed to Static Web Apps. +- API (Azure Functions) either integrated (api folder) or separate Functions App. + +## Steps (Separate Functions App) +1. Create Azure Functions App (Isolated .NET runtime) and configure Application Settings for ConnectWise + Azure OpenAI. +2. Deploy API project via `func azure functionapp publish ` or CI. +3. Create Static Web App and set `api_location` to Functions App if integrated, else configure front end to call external API base URL. +4. Upload front-end build (`dotnet publish -c Release Bezalu.ProjectReporting.Web`). + +## Configuration Keys +- `ConnectWise:*` +- `AzureOpenAI:Endpoint` +- `AzureOpenAI:DeploymentName` + +## Authentication +- Add Entra ID or other auth on Static Web Apps; issue front-end access token; secure Functions with Easy Auth or custom. + +## Environment Segregation +- Use separate resource groups for dev/stage/prod. +- Use managed identity for Azure OpenAI credentials instead of API key. + +## Logging & Monitoring +- Application Insights configured in API project. +- Add custom telemetry events for PDF generation latency. + +## CDN / Performance +- Enable SWA global distribution. +- WASM trimming + AOT already enabled (consider testing cold starts). diff --git a/docs/frontend.md b/docs/frontend.md new file mode 100644 index 0000000..c1855ec --- /dev/null +++ b/docs/frontend.md @@ -0,0 +1,22 @@ +# Front-End (Blazor WebAssembly) + +## Key Behaviors +- User enters Project ID; triggers report fetch. +- Displays metrics in Fluent UI components (cards, tabs, data grids, accordion). +- AI summary rendered as markdown using Markdig. +- PDF download posts existing report JSON to API; no recomputation. + +## State Management +- Local component state only (no global store yet). +- `IsLoading` for initial report, `IsPdfLoading` for PDF call. + +## HTTP +- `HttpClient` base address from host environment; assumes reverse proxy or relative `/api` route. + +## File Download +- `saveFile` JS helper converts Base64 to Blob and triggers ``. + +## Extensibility +- Add charts (variance trends) via a chart library. +- Add caching in browser (localStorage) for last report. +- Add global error boundary for API failures. diff --git a/docs/pdf.md b/docs/pdf.md new file mode 100644 index 0000000..2157275 --- /dev/null +++ b/docs/pdf.md @@ -0,0 +1,29 @@ +# PDF Generation + +## Endpoint +`POST /api/reports/project-completion/pdf` +Body: full `ProjectCompletionReportResponse` JSON. + +## Rationale +- Avoids re-fetching ConnectWise data. +- Skips second AI summary generation (cost + latency). +- Ensures PDF matches on-screen data exactly. + +## Implementation +- QuestPDF used in Azure Functions isolated worker. +- License set to Community. +- Sections rendered: Header, Summary, Timeline, Budget, AI Summary, Phases, Tickets, Footer with page numbers. + +## Size Control +- Only top N (10) notes per ticket included. +- AI summary included as plain text (markdown not interpreted server-side). + +## Extensibility +- Add charts (hours variance) via `Canvas` or table sections. +- Add cover page: introduce first `Page` before current layout. +- Support custom branding: pass logo URL in request and embed image. + +## Client Download +- Blazor WebAssembly posts report JSON. +- Receives `application/pdf` bytes. +- JS interop `saveFile` creates Blob and triggers download.