Spaces:
Running
Running
User commited on
Commit ·
4671199
1
Parent(s): 92025cf
fix: reports
Browse files- .nuget-scratch/NuGet.Config +7 -0
- TaskTrackingSystem.Database/AppDbContextModels/AppDbContext.cs +4 -0
- TaskTrackingSystem.Database/AppDbContextModels/Issue.cs +8 -0
- TaskTrackingSystem.Shared/Models/Issue/CreateIssueDto.cs +11 -0
- TaskTrackingSystem.Shared/Models/Issue/IssueDto.cs +4 -0
- TaskTrackingSystem.Shared/Models/Issue/UpdateIssueDto.cs +11 -0
- TaskTrackingSystem.Shared/Models/Report/EmployeeProductivityReportDto.cs +8 -0
- TaskTrackingSystem.Shared/Models/Report/IssueReportSummaryDto.cs +13 -0
- TaskTrackingSystem.Shared/Models/Report/OverdueCriticalTaskDto.cs +5 -0
- TaskTrackingSystem.Shared/Models/Report/ProjectProgressReportDto.cs +7 -0
- TaskTrackingSystem.Shared/Models/Report/TaskReportDto.cs +5 -0
- TaskTrackingSystem.WebApi/Features/Issue/IssueService.cs +12 -0
- TaskTrackingSystem.WebApi/Features/Report/ReportController.cs +1 -1
- TaskTrackingSystem.WebApi/Features/Report/ReportService.cs +187 -11
- TaskTrackingSystem.WebApi/Program.cs +83 -0
- TaskTrackingSystem.WebApp/Components/Pages/Features/Issues/AddIssue.razor +42 -0
- TaskTrackingSystem.WebApp/Components/Pages/Features/Issues/Issues.razor +79 -11
- TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/EmployeeReport.razor +50 -47
- TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/IssueReport.razor +325 -0
- TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/OverdueTasksReport.razor +18 -23
- TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/ProjectProgressReport.razor +32 -26
- TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/TaskReport.razor +115 -5
- TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/TimeTrackingReport.razor +62 -15
.nuget-scratch/NuGet.Config
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?xml version="1.0" encoding="utf-8"?>
|
| 2 |
+
<configuration>
|
| 3 |
+
<packageSources>
|
| 4 |
+
<clear />
|
| 5 |
+
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
|
| 6 |
+
</packageSources>
|
| 7 |
+
</configuration>
|
TaskTrackingSystem.Database/AppDbContextModels/AppDbContext.cs
CHANGED
|
@@ -178,7 +178,11 @@ public partial class AppDbContext : DbContext
|
|
| 178 |
.HasDefaultValueSql("(getdate())")
|
| 179 |
.HasColumnType("datetime");
|
| 180 |
entity.Property(e => e.Description).HasMaxLength(1000);
|
|
|
|
|
|
|
| 181 |
entity.Property(e => e.DueDate).HasColumnType("datetime");
|
|
|
|
|
|
|
| 182 |
entity.Property(e => e.IsDeleted).HasDefaultValue(false);
|
| 183 |
entity.Property(e => e.PriorityId).HasDefaultValue(TaskPriority.Medium);
|
| 184 |
entity.Property(e => e.StartDate).HasColumnType("datetime");
|
|
|
|
| 178 |
.HasDefaultValueSql("(getdate())")
|
| 179 |
.HasColumnType("datetime");
|
| 180 |
entity.Property(e => e.Description).HasMaxLength(1000);
|
| 181 |
+
entity.Property(e => e.DelayReason).HasMaxLength(300);
|
| 182 |
+
entity.Property(e => e.BlockedBy).HasMaxLength(200);
|
| 183 |
entity.Property(e => e.DueDate).HasColumnType("datetime");
|
| 184 |
+
entity.Property(e => e.EscalationLevel).HasDefaultValue(0);
|
| 185 |
+
entity.Property(e => e.IsBlocked).HasDefaultValue(false);
|
| 186 |
entity.Property(e => e.IsDeleted).HasDefaultValue(false);
|
| 187 |
entity.Property(e => e.PriorityId).HasDefaultValue(TaskPriority.Medium);
|
| 188 |
entity.Property(e => e.StartDate).HasColumnType("datetime");
|
TaskTrackingSystem.Database/AppDbContextModels/Issue.cs
CHANGED
|
@@ -20,6 +20,14 @@ public partial class Issue
|
|
| 20 |
|
| 21 |
public decimal? ActualHours { get; set; }
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
public DateTime StartDate { get; set; }
|
| 24 |
|
| 25 |
public DateTime DueDate { get; set; }
|
|
|
|
| 20 |
|
| 21 |
public decimal? ActualHours { get; set; }
|
| 22 |
|
| 23 |
+
public string? DelayReason { get; set; }
|
| 24 |
+
|
| 25 |
+
public bool IsBlocked { get; set; }
|
| 26 |
+
|
| 27 |
+
public string? BlockedBy { get; set; }
|
| 28 |
+
|
| 29 |
+
public int EscalationLevel { get; set; }
|
| 30 |
+
|
| 31 |
public DateTime StartDate { get; set; }
|
| 32 |
|
| 33 |
public DateTime DueDate { get; set; }
|
TaskTrackingSystem.Shared/Models/Issue/CreateIssueDto.cs
CHANGED
|
@@ -27,6 +27,17 @@ namespace TaskTrackingSystem.Shared.Models.Issue
|
|
| 27 |
[Range(typeof(decimal), "0", "100000")]
|
| 28 |
public decimal? ActualHours { get; set; }
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
[Required]
|
| 31 |
public DateTime StartDate { get; set; }
|
| 32 |
|
|
|
|
| 27 |
[Range(typeof(decimal), "0", "100000")]
|
| 28 |
public decimal? ActualHours { get; set; }
|
| 29 |
|
| 30 |
+
[MaxLength(300)]
|
| 31 |
+
public string? DelayReason { get; set; }
|
| 32 |
+
|
| 33 |
+
public bool IsBlocked { get; set; }
|
| 34 |
+
|
| 35 |
+
[MaxLength(200)]
|
| 36 |
+
public string? BlockedBy { get; set; }
|
| 37 |
+
|
| 38 |
+
[Range(0, 3)]
|
| 39 |
+
public int EscalationLevel { get; set; }
|
| 40 |
+
|
| 41 |
[Required]
|
| 42 |
public DateTime StartDate { get; set; }
|
| 43 |
|
TaskTrackingSystem.Shared/Models/Issue/IssueDto.cs
CHANGED
|
@@ -16,6 +16,10 @@ namespace TaskTrackingSystem.Shared.Models.Issue
|
|
| 16 |
public string? AssignedToName { get; set; }
|
| 17 |
public decimal? EstimatedHours { get; set; }
|
| 18 |
public decimal? ActualHours { get; set; }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
public DateTime StartDate { get; set; }
|
| 20 |
public DateTime DueDate { get; set; }
|
| 21 |
public AppTaskStatus StatusId { get; set; }
|
|
|
|
| 16 |
public string? AssignedToName { get; set; }
|
| 17 |
public decimal? EstimatedHours { get; set; }
|
| 18 |
public decimal? ActualHours { get; set; }
|
| 19 |
+
public string? DelayReason { get; set; }
|
| 20 |
+
public bool IsBlocked { get; set; }
|
| 21 |
+
public string? BlockedBy { get; set; }
|
| 22 |
+
public int EscalationLevel { get; set; }
|
| 23 |
public DateTime StartDate { get; set; }
|
| 24 |
public DateTime DueDate { get; set; }
|
| 25 |
public AppTaskStatus StatusId { get; set; }
|
TaskTrackingSystem.Shared/Models/Issue/UpdateIssueDto.cs
CHANGED
|
@@ -23,6 +23,17 @@ namespace TaskTrackingSystem.Shared.Models.Issue
|
|
| 23 |
[Range(typeof(decimal), "0", "100000")]
|
| 24 |
public decimal? ActualHours { get; set; }
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
[Required]
|
| 27 |
public DateTime StartDate { get; set; }
|
| 28 |
|
|
|
|
| 23 |
[Range(typeof(decimal), "0", "100000")]
|
| 24 |
public decimal? ActualHours { get; set; }
|
| 25 |
|
| 26 |
+
[MaxLength(300)]
|
| 27 |
+
public string? DelayReason { get; set; }
|
| 28 |
+
|
| 29 |
+
public bool IsBlocked { get; set; }
|
| 30 |
+
|
| 31 |
+
[MaxLength(200)]
|
| 32 |
+
public string? BlockedBy { get; set; }
|
| 33 |
+
|
| 34 |
+
[Range(0, 3)]
|
| 35 |
+
public int EscalationLevel { get; set; }
|
| 36 |
+
|
| 37 |
[Required]
|
| 38 |
public DateTime StartDate { get; set; }
|
| 39 |
|
TaskTrackingSystem.Shared/Models/Report/EmployeeProductivityReportDto.cs
CHANGED
|
@@ -7,6 +7,14 @@ public class EmployeeProductivityReportDto
|
|
| 7 |
public string Username { get; set; } = string.Empty;
|
| 8 |
public int AssignedCount { get; set; }
|
| 9 |
public int CompletedCount { get; set; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
public double Efficiency { get; set; }
|
| 11 |
public double OnTimeDeliveryRate { get; set; }
|
| 12 |
}
|
|
|
|
| 7 |
public string Username { get; set; } = string.Empty;
|
| 8 |
public int AssignedCount { get; set; }
|
| 9 |
public int CompletedCount { get; set; }
|
| 10 |
+
public int AssignedIssues { get; set; }
|
| 11 |
+
public int OpenIssues { get; set; }
|
| 12 |
+
public int OverdueIssues { get; set; }
|
| 13 |
+
public decimal ActualHours { get; set; }
|
| 14 |
+
public string WorkloadLevel { get; set; } = "Balanced";
|
| 15 |
+
public double AverageCompletionTimeDays { get; set; }
|
| 16 |
+
public string Trend { get; set; } = "Stable";
|
| 17 |
+
public double CompletionRate { get; set; }
|
| 18 |
public double Efficiency { get; set; }
|
| 19 |
public double OnTimeDeliveryRate { get; set; }
|
| 20 |
}
|
TaskTrackingSystem.Shared/Models/Report/IssueReportSummaryDto.cs
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
namespace TaskTrackingSystem.Shared.Models.Report;
|
| 2 |
+
|
| 3 |
+
public class IssueReportSummaryDto
|
| 4 |
+
{
|
| 5 |
+
public int TotalIssues { get; set; }
|
| 6 |
+
public int OpenIssues { get; set; }
|
| 7 |
+
public int OverdueIssues { get; set; }
|
| 8 |
+
public int BlockedIssues { get; set; }
|
| 9 |
+
public decimal EstimatedHours { get; set; }
|
| 10 |
+
public decimal ActualHours { get; set; }
|
| 11 |
+
public decimal VarianceHours { get; set; }
|
| 12 |
+
public double UtilizationPercent { get; set; }
|
| 13 |
+
}
|
TaskTrackingSystem.Shared/Models/Report/OverdueCriticalTaskDto.cs
CHANGED
|
@@ -15,5 +15,10 @@ namespace TaskTrackingSystem.Shared.Models.Report
|
|
| 15 |
public DateTime DueDate { get; set; }
|
| 16 |
public int DaysOverdue { get; set; }
|
| 17 |
public DateTime CreatedAt { get; set; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
}
|
| 19 |
}
|
|
|
|
| 15 |
public DateTime DueDate { get; set; }
|
| 16 |
public int DaysOverdue { get; set; }
|
| 17 |
public DateTime CreatedAt { get; set; }
|
| 18 |
+
public int OverdueIssues { get; set; }
|
| 19 |
+
public string DelayReason { get; set; } = string.Empty;
|
| 20 |
+
public string BlockedBy { get; set; } = string.Empty;
|
| 21 |
+
public int EscalationLevel { get; set; }
|
| 22 |
+
public string RecordType { get; set; } = "Task";
|
| 23 |
}
|
| 24 |
}
|
TaskTrackingSystem.Shared/Models/Report/ProjectProgressReportDto.cs
CHANGED
|
@@ -8,7 +8,14 @@ public class ProjectProgressReportDto
|
|
| 8 |
public DateTime EndDate { get; set; }
|
| 9 |
public int TotalTasks { get; set; }
|
| 10 |
public int CompletedTasks { get; set; }
|
|
|
|
|
|
|
|
|
|
| 11 |
public double Progress { get; set; }
|
| 12 |
public bool IsAhead { get; set; }
|
| 13 |
public bool IsAtRisk { get; set; }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
}
|
|
|
|
| 8 |
public DateTime EndDate { get; set; }
|
| 9 |
public int TotalTasks { get; set; }
|
| 10 |
public int CompletedTasks { get; set; }
|
| 11 |
+
public int OpenIssues { get; set; }
|
| 12 |
+
public int OverdueIssues { get; set; }
|
| 13 |
+
public int BlockedIssues { get; set; }
|
| 14 |
public double Progress { get; set; }
|
| 15 |
public bool IsAhead { get; set; }
|
| 16 |
public bool IsAtRisk { get; set; }
|
| 17 |
+
public string ProjectHealth { get; set; } = "Healthy";
|
| 18 |
+
public DateTime LastActivity { get; set; }
|
| 19 |
+
public DateTime? NextMilestone { get; set; }
|
| 20 |
+
public decimal ActualHours { get; set; }
|
| 21 |
}
|
TaskTrackingSystem.Shared/Models/Report/TaskReportDto.cs
CHANGED
|
@@ -20,5 +20,10 @@ namespace TaskTrackingSystem.Shared.Models.Report
|
|
| 20 |
public string? AssignedByUser { get; set; }
|
| 21 |
public DateTime DueDate { get; set; }
|
| 22 |
public DateTime CreatedAt { get; set; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
}
|
| 24 |
}
|
|
|
|
| 20 |
public string? AssignedByUser { get; set; }
|
| 21 |
public DateTime DueDate { get; set; }
|
| 22 |
public DateTime CreatedAt { get; set; }
|
| 23 |
+
public DateTime LastActivity { get; set; }
|
| 24 |
+
public int OpenIssues { get; set; }
|
| 25 |
+
public int OverdueIssues { get; set; }
|
| 26 |
+
public string IssueSummary { get; set; } = string.Empty;
|
| 27 |
+
public string TaskHealth { get; set; } = "Healthy";
|
| 28 |
}
|
| 29 |
}
|
TaskTrackingSystem.WebApi/Features/Issue/IssueService.cs
CHANGED
|
@@ -157,6 +157,10 @@ namespace TaskTrackingSystem.WebApi.Features.Issue
|
|
| 157 |
AssignedTo = dto.AssignedTo,
|
| 158 |
EstimatedHours = dto.EstimatedHours,
|
| 159 |
ActualHours = dto.ActualHours,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
StartDate = dto.StartDate,
|
| 161 |
DueDate = dto.DueDate,
|
| 162 |
StatusId = dto.StatusId == 0 ? AppTaskStatus.Todo : dto.StatusId,
|
|
@@ -206,6 +210,10 @@ namespace TaskTrackingSystem.WebApi.Features.Issue
|
|
| 206 |
issue.AssignedTo = dto.AssignedTo;
|
| 207 |
issue.EstimatedHours = dto.EstimatedHours;
|
| 208 |
issue.ActualHours = dto.ActualHours;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
issue.StartDate = dto.StartDate;
|
| 210 |
issue.DueDate = dto.DueDate;
|
| 211 |
issue.StatusId = dto.StatusId;
|
|
@@ -307,6 +315,10 @@ namespace TaskTrackingSystem.WebApi.Features.Issue
|
|
| 307 |
: null,
|
| 308 |
EstimatedHours = i.EstimatedHours,
|
| 309 |
ActualHours = i.ActualHours,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
StartDate = i.StartDate,
|
| 311 |
DueDate = i.DueDate,
|
| 312 |
StatusId = i.StatusId,
|
|
|
|
| 157 |
AssignedTo = dto.AssignedTo,
|
| 158 |
EstimatedHours = dto.EstimatedHours,
|
| 159 |
ActualHours = dto.ActualHours,
|
| 160 |
+
DelayReason = dto.DelayReason,
|
| 161 |
+
IsBlocked = dto.IsBlocked,
|
| 162 |
+
BlockedBy = dto.BlockedBy,
|
| 163 |
+
EscalationLevel = dto.EscalationLevel,
|
| 164 |
StartDate = dto.StartDate,
|
| 165 |
DueDate = dto.DueDate,
|
| 166 |
StatusId = dto.StatusId == 0 ? AppTaskStatus.Todo : dto.StatusId,
|
|
|
|
| 210 |
issue.AssignedTo = dto.AssignedTo;
|
| 211 |
issue.EstimatedHours = dto.EstimatedHours;
|
| 212 |
issue.ActualHours = dto.ActualHours;
|
| 213 |
+
issue.DelayReason = dto.DelayReason;
|
| 214 |
+
issue.IsBlocked = dto.IsBlocked;
|
| 215 |
+
issue.BlockedBy = dto.BlockedBy;
|
| 216 |
+
issue.EscalationLevel = dto.EscalationLevel;
|
| 217 |
issue.StartDate = dto.StartDate;
|
| 218 |
issue.DueDate = dto.DueDate;
|
| 219 |
issue.StatusId = dto.StatusId;
|
|
|
|
| 315 |
: null,
|
| 316 |
EstimatedHours = i.EstimatedHours,
|
| 317 |
ActualHours = i.ActualHours,
|
| 318 |
+
DelayReason = i.DelayReason,
|
| 319 |
+
IsBlocked = i.IsBlocked,
|
| 320 |
+
BlockedBy = i.BlockedBy,
|
| 321 |
+
EscalationLevel = i.EscalationLevel,
|
| 322 |
StartDate = i.StartDate,
|
| 323 |
DueDate = i.DueDate,
|
| 324 |
StatusId = i.StatusId,
|
TaskTrackingSystem.WebApi/Features/Report/ReportController.cs
CHANGED
|
@@ -29,7 +29,7 @@ namespace TaskTrackingSystem.WebApi.Features.Report
|
|
| 29 |
[ProducesResponseType(typeof(List<IssueDto>), StatusCodes.Status200OK)]
|
| 30 |
public async Task<ActionResult<Result<List<IssueDto>>>> GetIssuesReport()
|
| 31 |
{
|
| 32 |
-
var result = await _reportService.GetIssuesReportAsync();
|
| 33 |
return StatusCode(result.StatusCode, result);
|
| 34 |
}
|
| 35 |
|
|
|
|
| 29 |
[ProducesResponseType(typeof(List<IssueDto>), StatusCodes.Status200OK)]
|
| 30 |
public async Task<ActionResult<Result<List<IssueDto>>>> GetIssuesReport()
|
| 31 |
{
|
| 32 |
+
var result = await _reportService.GetIssuesReportAsync(User.GetRoleId(), User.GetUserId());
|
| 33 |
return StatusCode(result.StatusCode, result);
|
| 34 |
}
|
| 35 |
|
TaskTrackingSystem.WebApi/Features/Report/ReportService.cs
CHANGED
|
@@ -25,14 +25,16 @@ namespace TaskTrackingSystem.WebApi.Features.Report
|
|
| 25 |
_db = db;
|
| 26 |
}
|
| 27 |
|
| 28 |
-
public async Task<Result<List<IssueDto>>> GetIssuesReportAsync()
|
| 29 |
{
|
| 30 |
-
var
|
| 31 |
-
|
|
|
|
| 32 |
.Include(i => i.Task)
|
| 33 |
.ThenInclude(t => t.Project)
|
| 34 |
.Include(i => i.AssignedToNavigation)
|
| 35 |
-
.OrderBy(i => i.
|
|
|
|
| 36 |
.Select(i => new IssueDto
|
| 37 |
{
|
| 38 |
Id = i.Id,
|
|
@@ -48,6 +50,10 @@ namespace TaskTrackingSystem.WebApi.Features.Report
|
|
| 48 |
: null,
|
| 49 |
EstimatedHours = i.EstimatedHours,
|
| 50 |
ActualHours = i.ActualHours,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
StartDate = i.StartDate,
|
| 52 |
DueDate = i.DueDate,
|
| 53 |
StatusId = i.StatusId,
|
|
@@ -75,6 +81,29 @@ namespace TaskTrackingSystem.WebApi.Features.Report
|
|
| 75 |
return _db.Users.Where(u => !u.IsDeleted);
|
| 76 |
}
|
| 77 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
private static PagedResult<TDestination> MapPagedResult<TSource, TDestination>(
|
| 79 |
PagedResult<TSource> source,
|
| 80 |
Func<TSource, TDestination> map)
|
|
@@ -187,10 +216,96 @@ namespace TaskTrackingSystem.WebApi.Features.Report
|
|
| 187 |
AssignedToUser = t.AssignedToNavigation != null ? $"{t.AssignedToNavigation.FirstName} {t.AssignedToNavigation.LastName}" : null,
|
| 188 |
AssignedByUser = t.AssignedByNavigation != null ? $"{t.AssignedByNavigation.FirstName} {t.AssignedByNavigation.LastName}" : null,
|
| 189 |
DueDate = t.DueDate,
|
| 190 |
-
CreatedAt = t.CreatedAt ?? DateTime.UtcNow
|
|
|
|
| 191 |
};
|
| 192 |
}
|
| 193 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
// ——— Legacy endpoints (kept for backward compatibility) ———
|
| 195 |
|
| 196 |
public async Task<Result<IEnumerable<TaskReportDto>>> GetTasksReportAsync(
|
|
@@ -205,6 +320,7 @@ namespace TaskTrackingSystem.WebApi.Features.Report
|
|
| 205 |
.ThenByDescending(t => t.CreatedAt ?? DateTime.UtcNow)
|
| 206 |
.ToListAsync();
|
| 207 |
var list = tasks.Select(MapTaskReport).ToList();
|
|
|
|
| 208 |
return Result<IEnumerable<TaskReportDto>>.Success(list);
|
| 209 |
}
|
| 210 |
|
|
@@ -228,7 +344,9 @@ namespace TaskTrackingSystem.WebApi.Features.Report
|
|
| 228 |
.ThenByDescending(t => t.CreatedAt ?? DateTime.UtcNow)
|
| 229 |
.ToPagedResultAsync(page, pageSize);
|
| 230 |
|
| 231 |
-
|
|
|
|
|
|
|
| 232 |
}
|
| 233 |
|
| 234 |
public async Task<Result<IEnumerable<UserProductivityDto>>> GetUserProductivityReportAsync(long roleId, long currentUserId)
|
|
@@ -517,6 +635,7 @@ namespace TaskTrackingSystem.WebApi.Features.Report
|
|
| 517 |
DaysOverdue = t.DueDate < now ? (int)(now - t.DueDate).TotalDays : 0,
|
| 518 |
CreatedAt = t.CreatedAt ?? DateTime.UtcNow
|
| 519 |
}).ToList();
|
|
|
|
| 520 |
|
| 521 |
return Result<IEnumerable<OverdueCriticalTaskDto>>.Success(list);
|
| 522 |
}
|
|
@@ -598,6 +717,7 @@ namespace TaskTrackingSystem.WebApi.Features.Report
|
|
| 598 |
DaysOverdue = t.DueDate < now ? (int)(now - t.DueDate).TotalDays : 0,
|
| 599 |
CreatedAt = t.CreatedAt ?? DateTime.UtcNow
|
| 600 |
});
|
|
|
|
| 601 |
|
| 602 |
return mapped;
|
| 603 |
}
|
|
@@ -659,12 +779,21 @@ namespace TaskTrackingSystem.WebApi.Features.Report
|
|
| 659 |
}
|
| 660 |
|
| 661 |
var tasks = await BuildAccessibleTaskQuery(isAdmin, isManager, currentUserId).ToListAsync();
|
|
|
|
|
|
|
|
|
|
| 662 |
|
| 663 |
if (startDate.HasValue)
|
|
|
|
| 664 |
tasks = tasks.Where(t => t.CreatedAt >= startDate.Value.Date).ToList();
|
|
|
|
|
|
|
| 665 |
|
| 666 |
if (endDate.HasValue)
|
|
|
|
| 667 |
tasks = tasks.Where(t => t.CreatedAt < endDate.Value.Date.AddDays(1)).ToList();
|
|
|
|
|
|
|
| 668 |
|
| 669 |
if (!string.IsNullOrWhiteSpace(status))
|
| 670 |
{
|
|
@@ -672,11 +801,15 @@ namespace TaskTrackingSystem.WebApi.Features.Report
|
|
| 672 |
if (sl == "overdue")
|
| 673 |
{
|
| 674 |
tasks = tasks.Where(t => t.StatusId != AppTaskStatus.Done && t.DueDate < DateTime.Today).ToList();
|
|
|
|
| 675 |
}
|
| 676 |
else if (sl != "0")
|
| 677 |
{
|
| 678 |
if (Enum.TryParse<AppTaskStatus>(status, true, out var parsedStatus))
|
|
|
|
| 679 |
tasks = tasks.Where(t => t.StatusId == parsedStatus).ToList();
|
|
|
|
|
|
|
| 680 |
}
|
| 681 |
}
|
| 682 |
|
|
@@ -684,14 +817,25 @@ namespace TaskTrackingSystem.WebApi.Features.Report
|
|
| 684 |
foreach (var user in users)
|
| 685 |
{
|
| 686 |
var userTasks = tasks.Where(t => t.AssignedTo == user.Id).ToList();
|
|
|
|
| 687 |
int total = userTasks.Count;
|
| 688 |
var completedTasks = userTasks.Where(t => t.StatusId == AppTaskStatus.Done).ToList();
|
| 689 |
int done = completedTasks.Count;
|
|
|
|
| 690 |
|
| 691 |
int onTimeCount = completedTasks.Count(t =>
|
| 692 |
(t.UpdatedAt ?? t.CreatedAt) <= t.DueDate);
|
| 693 |
|
| 694 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 695 |
|
| 696 |
list.Add(new EmployeeProductivityReportDto
|
| 697 |
{
|
|
@@ -700,7 +844,15 @@ namespace TaskTrackingSystem.WebApi.Features.Report
|
|
| 700 |
FullName = $"{user.FirstName} {user.LastName}".Trim(),
|
| 701 |
AssignedCount = total,
|
| 702 |
CompletedCount = done,
|
| 703 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 704 |
OnTimeDeliveryRate = onTimeDeliveryRate
|
| 705 |
});
|
| 706 |
}
|
|
@@ -711,7 +863,7 @@ namespace TaskTrackingSystem.WebApi.Features.Report
|
|
| 711 |
list = list.Where(u => u.FullName.ToLower().Contains(s) || u.Username.ToLower().Contains(s)).ToList();
|
| 712 |
}
|
| 713 |
|
| 714 |
-
return Result<IEnumerable<EmployeeProductivityReportDto>>.Success(list.OrderByDescending(u => u.
|
| 715 |
}
|
| 716 |
|
| 717 |
public async Task<PagedResult<EmployeeProductivityReportDto>> GetPagedEmployeeProductivityReportAsync(
|
|
@@ -732,7 +884,7 @@ namespace TaskTrackingSystem.WebApi.Features.Report
|
|
| 732 |
return new PagedResult<EmployeeProductivityReportDto>();
|
| 733 |
}
|
| 734 |
|
| 735 |
-
var ordered = full.Value.OrderByDescending(u => u.
|
| 736 |
var normalizedPageSize = PaginationExtensions.NormalizePageSize(pageSize);
|
| 737 |
var totalCount = ordered.Count;
|
| 738 |
var totalPages = totalCount == 0 ? 0 : (int)Math.Ceiling(totalCount / (double)normalizedPageSize);
|
|
@@ -781,6 +933,9 @@ namespace TaskTrackingSystem.WebApi.Features.Report
|
|
| 781 |
}
|
| 782 |
|
| 783 |
var tasks = await BuildAccessibleTaskQuery(isAdmin, isManager, currentUserId).ToListAsync();
|
|
|
|
|
|
|
|
|
|
| 784 |
var today = DateTime.Today;
|
| 785 |
|
| 786 |
if (startDate.HasValue)
|
|
@@ -793,16 +948,19 @@ namespace TaskTrackingSystem.WebApi.Features.Report
|
|
| 793 |
foreach (var project in projects)
|
| 794 |
{
|
| 795 |
var projectTasks = tasks.Where(t => t.ProjectId == project.Id).ToList();
|
|
|
|
| 796 |
|
| 797 |
if (!string.IsNullOrWhiteSpace(status))
|
| 798 |
{
|
| 799 |
if (status.Equals("overdue", StringComparison.OrdinalIgnoreCase))
|
| 800 |
{
|
| 801 |
projectTasks = projectTasks.Where(t => t.DueDate.Date < today && t.StatusId != AppTaskStatus.Done).ToList();
|
|
|
|
| 802 |
}
|
| 803 |
else if (status != "0" && Enum.TryParse<AppTaskStatus>(status, true, out var parsedStatus))
|
| 804 |
{
|
| 805 |
projectTasks = projectTasks.Where(t => t.StatusId == parsedStatus).ToList();
|
|
|
|
| 806 |
}
|
| 807 |
}
|
| 808 |
|
|
@@ -821,8 +979,19 @@ namespace TaskTrackingSystem.WebApi.Features.Report
|
|
| 821 |
}
|
| 822 |
|
| 823 |
bool isAhead = progress > elapsedPct && progress > 0 && progress < 100;
|
|
|
|
|
|
|
|
|
|
| 824 |
bool isAtRisk = (projectTasks.Any(t => t.StatusId != AppTaskStatus.Done && t.DueDate.Date < today)) ||
|
|
|
|
|
|
|
| 825 |
(project.EndDate.Date < today && progress < 100);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 826 |
|
| 827 |
list.Add(new ProjectProgressReportDto
|
| 828 |
{
|
|
@@ -832,9 +1001,16 @@ namespace TaskTrackingSystem.WebApi.Features.Report
|
|
| 832 |
EndDate = project.EndDate,
|
| 833 |
TotalTasks = totalTasks,
|
| 834 |
CompletedTasks = completedTasks,
|
|
|
|
|
|
|
|
|
|
| 835 |
Progress = progress,
|
| 836 |
IsAhead = isAhead,
|
| 837 |
-
IsAtRisk = isAtRisk
|
|
|
|
|
|
|
|
|
|
|
|
|
| 838 |
});
|
| 839 |
}
|
| 840 |
|
|
|
|
| 25 |
_db = db;
|
| 26 |
}
|
| 27 |
|
| 28 |
+
public async Task<Result<List<IssueDto>>> GetIssuesReportAsync(long roleId, long currentUserId)
|
| 29 |
{
|
| 30 |
+
var isAdmin = await DataScopeAuthorization.IsAdminScopeAsync(_db, roleId);
|
| 31 |
+
var isManager = await DataScopeAuthorization.IsManagerScopeAsync(_db, roleId);
|
| 32 |
+
var list = await BuildAccessibleIssueQuery(isAdmin, isManager, currentUserId)
|
| 33 |
.Include(i => i.Task)
|
| 34 |
.ThenInclude(t => t.Project)
|
| 35 |
.Include(i => i.AssignedToNavigation)
|
| 36 |
+
.OrderBy(i => i.DueDate)
|
| 37 |
+
.ThenBy(i => i.Title)
|
| 38 |
.Select(i => new IssueDto
|
| 39 |
{
|
| 40 |
Id = i.Id,
|
|
|
|
| 50 |
: null,
|
| 51 |
EstimatedHours = i.EstimatedHours,
|
| 52 |
ActualHours = i.ActualHours,
|
| 53 |
+
DelayReason = i.DelayReason,
|
| 54 |
+
IsBlocked = i.IsBlocked,
|
| 55 |
+
BlockedBy = i.BlockedBy,
|
| 56 |
+
EscalationLevel = i.EscalationLevel,
|
| 57 |
StartDate = i.StartDate,
|
| 58 |
DueDate = i.DueDate,
|
| 59 |
StatusId = i.StatusId,
|
|
|
|
| 81 |
return _db.Users.Where(u => !u.IsDeleted);
|
| 82 |
}
|
| 83 |
|
| 84 |
+
private IQueryable<TaskTrackingSystem.Database.AppDbContextModels.Issue> BuildAccessibleIssueQuery(bool isAdmin, bool isManager, long currentUserId)
|
| 85 |
+
{
|
| 86 |
+
var query = _db.Issues
|
| 87 |
+
.Where(i => i.IsDeleted != true && i.Task.IsDeleted != true && i.Task.Project.IsDeleted != true);
|
| 88 |
+
|
| 89 |
+
if (isAdmin)
|
| 90 |
+
{
|
| 91 |
+
return query;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
if (isManager)
|
| 95 |
+
{
|
| 96 |
+
return query.Where(i =>
|
| 97 |
+
i.AssignedTo == currentUserId ||
|
| 98 |
+
i.CreatedBy == currentUserId ||
|
| 99 |
+
i.Task.Project.ProjectMembers.Any(pm => pm.UserId == currentUserId));
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
return query.Where(i =>
|
| 103 |
+
i.AssignedTo == currentUserId ||
|
| 104 |
+
i.CreatedBy == currentUserId);
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
private static PagedResult<TDestination> MapPagedResult<TSource, TDestination>(
|
| 108 |
PagedResult<TSource> source,
|
| 109 |
Func<TSource, TDestination> map)
|
|
|
|
| 216 |
AssignedToUser = t.AssignedToNavigation != null ? $"{t.AssignedToNavigation.FirstName} {t.AssignedToNavigation.LastName}" : null,
|
| 217 |
AssignedByUser = t.AssignedByNavigation != null ? $"{t.AssignedByNavigation.FirstName} {t.AssignedByNavigation.LastName}" : null,
|
| 218 |
DueDate = t.DueDate,
|
| 219 |
+
CreatedAt = t.CreatedAt ?? DateTime.UtcNow,
|
| 220 |
+
LastActivity = t.UpdatedAt ?? t.CreatedAt ?? DateTime.UtcNow
|
| 221 |
};
|
| 222 |
}
|
| 223 |
|
| 224 |
+
private async System.Threading.Tasks.Task EnrichTaskReportsAsync(List<TaskReportDto> reports)
|
| 225 |
+
{
|
| 226 |
+
if (reports.Count == 0)
|
| 227 |
+
{
|
| 228 |
+
return;
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
var today = DateTime.Today;
|
| 232 |
+
var taskIds = reports.Select(r => r.TaskId).ToList();
|
| 233 |
+
var issues = await _db.Issues
|
| 234 |
+
.Where(i => i.IsDeleted != true && taskIds.Contains(i.TaskId))
|
| 235 |
+
.Select(i => new
|
| 236 |
+
{
|
| 237 |
+
i.TaskId,
|
| 238 |
+
i.Title,
|
| 239 |
+
i.StatusId,
|
| 240 |
+
i.PriorityId,
|
| 241 |
+
i.DueDate,
|
| 242 |
+
i.UpdatedAt,
|
| 243 |
+
i.CreatedAt,
|
| 244 |
+
i.IsBlocked
|
| 245 |
+
})
|
| 246 |
+
.ToListAsync();
|
| 247 |
+
|
| 248 |
+
foreach (var report in reports)
|
| 249 |
+
{
|
| 250 |
+
var taskIssues = issues.Where(i => i.TaskId == report.TaskId).ToList();
|
| 251 |
+
report.OpenIssues = taskIssues.Count(i => i.StatusId != AppTaskStatus.Done);
|
| 252 |
+
report.OverdueIssues = taskIssues.Count(i => i.StatusId != AppTaskStatus.Done && i.DueDate.Date < today);
|
| 253 |
+
report.IssueSummary = taskIssues.Count == 0
|
| 254 |
+
? "No issues"
|
| 255 |
+
: $"{report.OpenIssues} open, {report.OverdueIssues} overdue, {taskIssues.Count(i => i.IsBlocked)} blocked";
|
| 256 |
+
report.LastActivity = taskIssues
|
| 257 |
+
.Select(i => i.UpdatedAt ?? i.CreatedAt ?? report.LastActivity)
|
| 258 |
+
.DefaultIfEmpty(report.LastActivity)
|
| 259 |
+
.Max();
|
| 260 |
+
|
| 261 |
+
report.TaskHealth = report.StatusId == AppTaskStatus.Done
|
| 262 |
+
? "Healthy"
|
| 263 |
+
: report.OverdueIssues > 0 || report.DueDate.Date < today || taskIssues.Any(i => i.IsBlocked)
|
| 264 |
+
? "Critical"
|
| 265 |
+
: report.PriorityId == TaskPriority.High || report.DueDate.Date <= today.AddDays(3)
|
| 266 |
+
? "At Risk"
|
| 267 |
+
: "Healthy";
|
| 268 |
+
}
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
private async System.Threading.Tasks.Task EnrichOverdueTaskReportsAsync(List<OverdueCriticalTaskDto> reports)
|
| 272 |
+
{
|
| 273 |
+
if (reports.Count == 0)
|
| 274 |
+
{
|
| 275 |
+
return;
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
var today = DateTime.Today;
|
| 279 |
+
var taskIds = reports.Select(r => r.TaskId).ToList();
|
| 280 |
+
var issues = await _db.Issues
|
| 281 |
+
.Where(i => i.IsDeleted != true && taskIds.Contains(i.TaskId))
|
| 282 |
+
.Select(i => new
|
| 283 |
+
{
|
| 284 |
+
i.TaskId,
|
| 285 |
+
i.StatusId,
|
| 286 |
+
i.DueDate,
|
| 287 |
+
i.DelayReason,
|
| 288 |
+
i.IsBlocked,
|
| 289 |
+
i.BlockedBy,
|
| 290 |
+
i.EscalationLevel
|
| 291 |
+
})
|
| 292 |
+
.ToListAsync();
|
| 293 |
+
|
| 294 |
+
foreach (var report in reports)
|
| 295 |
+
{
|
| 296 |
+
var taskIssues = issues.Where(i => i.TaskId == report.TaskId).ToList();
|
| 297 |
+
report.OverdueIssues = taskIssues.Count(i => i.StatusId != AppTaskStatus.Done && i.DueDate.Date < today);
|
| 298 |
+
var riskIssue = taskIssues
|
| 299 |
+
.OrderByDescending(i => i.IsBlocked)
|
| 300 |
+
.ThenByDescending(i => i.EscalationLevel)
|
| 301 |
+
.ThenBy(i => i.DueDate)
|
| 302 |
+
.FirstOrDefault();
|
| 303 |
+
report.DelayReason = riskIssue?.DelayReason ?? (report.DaysOverdue > 0 ? "Past due date" : "High priority due soon");
|
| 304 |
+
report.BlockedBy = riskIssue?.BlockedBy ?? string.Empty;
|
| 305 |
+
report.EscalationLevel = riskIssue?.EscalationLevel ?? 0;
|
| 306 |
+
}
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
// ——— Legacy endpoints (kept for backward compatibility) ———
|
| 310 |
|
| 311 |
public async Task<Result<IEnumerable<TaskReportDto>>> GetTasksReportAsync(
|
|
|
|
| 320 |
.ThenByDescending(t => t.CreatedAt ?? DateTime.UtcNow)
|
| 321 |
.ToListAsync();
|
| 322 |
var list = tasks.Select(MapTaskReport).ToList();
|
| 323 |
+
await EnrichTaskReportsAsync(list);
|
| 324 |
return Result<IEnumerable<TaskReportDto>>.Success(list);
|
| 325 |
}
|
| 326 |
|
|
|
|
| 344 |
.ThenByDescending(t => t.CreatedAt ?? DateTime.UtcNow)
|
| 345 |
.ToPagedResultAsync(page, pageSize);
|
| 346 |
|
| 347 |
+
var mapped = MapPagedResult(paged, MapTaskReport);
|
| 348 |
+
await EnrichTaskReportsAsync(mapped.Items);
|
| 349 |
+
return mapped;
|
| 350 |
}
|
| 351 |
|
| 352 |
public async Task<Result<IEnumerable<UserProductivityDto>>> GetUserProductivityReportAsync(long roleId, long currentUserId)
|
|
|
|
| 635 |
DaysOverdue = t.DueDate < now ? (int)(now - t.DueDate).TotalDays : 0,
|
| 636 |
CreatedAt = t.CreatedAt ?? DateTime.UtcNow
|
| 637 |
}).ToList();
|
| 638 |
+
await EnrichOverdueTaskReportsAsync(list);
|
| 639 |
|
| 640 |
return Result<IEnumerable<OverdueCriticalTaskDto>>.Success(list);
|
| 641 |
}
|
|
|
|
| 717 |
DaysOverdue = t.DueDate < now ? (int)(now - t.DueDate).TotalDays : 0,
|
| 718 |
CreatedAt = t.CreatedAt ?? DateTime.UtcNow
|
| 719 |
});
|
| 720 |
+
await EnrichOverdueTaskReportsAsync(mapped.Items);
|
| 721 |
|
| 722 |
return mapped;
|
| 723 |
}
|
|
|
|
| 779 |
}
|
| 780 |
|
| 781 |
var tasks = await BuildAccessibleTaskQuery(isAdmin, isManager, currentUserId).ToListAsync();
|
| 782 |
+
var issues = await BuildAccessibleIssueQuery(isAdmin, isManager, currentUserId)
|
| 783 |
+
.Include(i => i.Task)
|
| 784 |
+
.ToListAsync();
|
| 785 |
|
| 786 |
if (startDate.HasValue)
|
| 787 |
+
{
|
| 788 |
tasks = tasks.Where(t => t.CreatedAt >= startDate.Value.Date).ToList();
|
| 789 |
+
issues = issues.Where(i => (i.CreatedAt ?? DateTime.UtcNow) >= startDate.Value.Date).ToList();
|
| 790 |
+
}
|
| 791 |
|
| 792 |
if (endDate.HasValue)
|
| 793 |
+
{
|
| 794 |
tasks = tasks.Where(t => t.CreatedAt < endDate.Value.Date.AddDays(1)).ToList();
|
| 795 |
+
issues = issues.Where(i => (i.CreatedAt ?? DateTime.UtcNow) < endDate.Value.Date.AddDays(1)).ToList();
|
| 796 |
+
}
|
| 797 |
|
| 798 |
if (!string.IsNullOrWhiteSpace(status))
|
| 799 |
{
|
|
|
|
| 801 |
if (sl == "overdue")
|
| 802 |
{
|
| 803 |
tasks = tasks.Where(t => t.StatusId != AppTaskStatus.Done && t.DueDate < DateTime.Today).ToList();
|
| 804 |
+
issues = issues.Where(i => i.StatusId != AppTaskStatus.Done && i.DueDate.Date < DateTime.Today).ToList();
|
| 805 |
}
|
| 806 |
else if (sl != "0")
|
| 807 |
{
|
| 808 |
if (Enum.TryParse<AppTaskStatus>(status, true, out var parsedStatus))
|
| 809 |
+
{
|
| 810 |
tasks = tasks.Where(t => t.StatusId == parsedStatus).ToList();
|
| 811 |
+
issues = issues.Where(i => i.StatusId == parsedStatus).ToList();
|
| 812 |
+
}
|
| 813 |
}
|
| 814 |
}
|
| 815 |
|
|
|
|
| 817 |
foreach (var user in users)
|
| 818 |
{
|
| 819 |
var userTasks = tasks.Where(t => t.AssignedTo == user.Id).ToList();
|
| 820 |
+
var userIssues = issues.Where(i => i.AssignedTo == user.Id).ToList();
|
| 821 |
int total = userTasks.Count;
|
| 822 |
var completedTasks = userTasks.Where(t => t.StatusId == AppTaskStatus.Done).ToList();
|
| 823 |
int done = completedTasks.Count;
|
| 824 |
+
var completedIssues = userIssues.Where(i => i.StatusId == AppTaskStatus.Done).ToList();
|
| 825 |
|
| 826 |
int onTimeCount = completedTasks.Count(t =>
|
| 827 |
(t.UpdatedAt ?? t.CreatedAt) <= t.DueDate);
|
| 828 |
|
| 829 |
+
var onTimeIssueCount = completedIssues.Count(i => (i.UpdatedAt ?? i.CreatedAt ?? DateTime.UtcNow) <= i.DueDate);
|
| 830 |
+
double onTimeDeliveryRate = completedIssues.Count > 0
|
| 831 |
+
? Math.Round(((double)onTimeIssueCount / completedIssues.Count) * 100, 2)
|
| 832 |
+
: done > 0 ? Math.Round(((double)onTimeCount / done) * 100, 2) : 0;
|
| 833 |
+
var openIssues = userIssues.Count(i => i.StatusId != AppTaskStatus.Done);
|
| 834 |
+
var overdueIssues = userIssues.Count(i => i.StatusId != AppTaskStatus.Done && i.DueDate.Date < DateTime.Today);
|
| 835 |
+
var completionRate = userIssues.Count > 0 ? Math.Round(((double)completedIssues.Count / userIssues.Count) * 100, 2) : 0;
|
| 836 |
+
var avgCompletionDays = completedIssues.Count > 0
|
| 837 |
+
? Math.Round(completedIssues.Average(i => Math.Max(0, ((i.UpdatedAt ?? i.CreatedAt ?? DateTime.UtcNow).Date - i.StartDate.Date).TotalDays)), 1)
|
| 838 |
+
: 0;
|
| 839 |
|
| 840 |
list.Add(new EmployeeProductivityReportDto
|
| 841 |
{
|
|
|
|
| 844 |
FullName = $"{user.FirstName} {user.LastName}".Trim(),
|
| 845 |
AssignedCount = total,
|
| 846 |
CompletedCount = done,
|
| 847 |
+
AssignedIssues = userIssues.Count,
|
| 848 |
+
OpenIssues = openIssues,
|
| 849 |
+
OverdueIssues = overdueIssues,
|
| 850 |
+
ActualHours = userIssues.Sum(i => i.ActualHours ?? 0),
|
| 851 |
+
WorkloadLevel = openIssues >= 10 || overdueIssues >= 3 ? "High" : openIssues <= 2 ? "Light" : "Balanced",
|
| 852 |
+
AverageCompletionTimeDays = avgCompletionDays,
|
| 853 |
+
Trend = overdueIssues > 0 ? "Declining" : completionRate >= 80 ? "Improving" : "Stable",
|
| 854 |
+
CompletionRate = completionRate,
|
| 855 |
+
Efficiency = completionRate,
|
| 856 |
OnTimeDeliveryRate = onTimeDeliveryRate
|
| 857 |
});
|
| 858 |
}
|
|
|
|
| 863 |
list = list.Where(u => u.FullName.ToLower().Contains(s) || u.Username.ToLower().Contains(s)).ToList();
|
| 864 |
}
|
| 865 |
|
| 866 |
+
return Result<IEnumerable<EmployeeProductivityReportDto>>.Success(list.OrderByDescending(u => u.OpenIssues).ThenByDescending(u => u.ActualHours));
|
| 867 |
}
|
| 868 |
|
| 869 |
public async Task<PagedResult<EmployeeProductivityReportDto>> GetPagedEmployeeProductivityReportAsync(
|
|
|
|
| 884 |
return new PagedResult<EmployeeProductivityReportDto>();
|
| 885 |
}
|
| 886 |
|
| 887 |
+
var ordered = full.Value.OrderByDescending(u => u.OpenIssues).ThenByDescending(u => u.ActualHours).ToList();
|
| 888 |
var normalizedPageSize = PaginationExtensions.NormalizePageSize(pageSize);
|
| 889 |
var totalCount = ordered.Count;
|
| 890 |
var totalPages = totalCount == 0 ? 0 : (int)Math.Ceiling(totalCount / (double)normalizedPageSize);
|
|
|
|
| 933 |
}
|
| 934 |
|
| 935 |
var tasks = await BuildAccessibleTaskQuery(isAdmin, isManager, currentUserId).ToListAsync();
|
| 936 |
+
var issues = await BuildAccessibleIssueQuery(isAdmin, isManager, currentUserId)
|
| 937 |
+
.Include(i => i.Task)
|
| 938 |
+
.ToListAsync();
|
| 939 |
var today = DateTime.Today;
|
| 940 |
|
| 941 |
if (startDate.HasValue)
|
|
|
|
| 948 |
foreach (var project in projects)
|
| 949 |
{
|
| 950 |
var projectTasks = tasks.Where(t => t.ProjectId == project.Id).ToList();
|
| 951 |
+
var projectIssues = issues.Where(i => i.Task.ProjectId == project.Id).ToList();
|
| 952 |
|
| 953 |
if (!string.IsNullOrWhiteSpace(status))
|
| 954 |
{
|
| 955 |
if (status.Equals("overdue", StringComparison.OrdinalIgnoreCase))
|
| 956 |
{
|
| 957 |
projectTasks = projectTasks.Where(t => t.DueDate.Date < today && t.StatusId != AppTaskStatus.Done).ToList();
|
| 958 |
+
projectIssues = projectIssues.Where(i => i.DueDate.Date < today && i.StatusId != AppTaskStatus.Done).ToList();
|
| 959 |
}
|
| 960 |
else if (status != "0" && Enum.TryParse<AppTaskStatus>(status, true, out var parsedStatus))
|
| 961 |
{
|
| 962 |
projectTasks = projectTasks.Where(t => t.StatusId == parsedStatus).ToList();
|
| 963 |
+
projectIssues = projectIssues.Where(i => i.StatusId == parsedStatus).ToList();
|
| 964 |
}
|
| 965 |
}
|
| 966 |
|
|
|
|
| 979 |
}
|
| 980 |
|
| 981 |
bool isAhead = progress > elapsedPct && progress > 0 && progress < 100;
|
| 982 |
+
var openIssues = projectIssues.Count(i => i.StatusId != AppTaskStatus.Done);
|
| 983 |
+
var overdueIssues = projectIssues.Count(i => i.StatusId != AppTaskStatus.Done && i.DueDate.Date < today);
|
| 984 |
+
var blockedIssues = projectIssues.Count(i => i.IsBlocked);
|
| 985 |
bool isAtRisk = (projectTasks.Any(t => t.StatusId != AppTaskStatus.Done && t.DueDate.Date < today)) ||
|
| 986 |
+
overdueIssues > 0 ||
|
| 987 |
+
blockedIssues > 0 ||
|
| 988 |
(project.EndDate.Date < today && progress < 100);
|
| 989 |
+
var health = isAtRisk && (overdueIssues > 0 || blockedIssues > 0) ? "Critical" : isAtRisk ? "At Risk" : "Healthy";
|
| 990 |
+
var lastActivity = projectIssues
|
| 991 |
+
.Select(i => i.UpdatedAt ?? i.CreatedAt ?? project.CreatedAt ?? DateTime.UtcNow)
|
| 992 |
+
.Concat(projectTasks.Select(t => t.UpdatedAt ?? t.CreatedAt ?? DateTime.UtcNow))
|
| 993 |
+
.DefaultIfEmpty(project.CreatedAt ?? DateTime.UtcNow)
|
| 994 |
+
.Max();
|
| 995 |
|
| 996 |
list.Add(new ProjectProgressReportDto
|
| 997 |
{
|
|
|
|
| 1001 |
EndDate = project.EndDate,
|
| 1002 |
TotalTasks = totalTasks,
|
| 1003 |
CompletedTasks = completedTasks,
|
| 1004 |
+
OpenIssues = openIssues,
|
| 1005 |
+
OverdueIssues = overdueIssues,
|
| 1006 |
+
BlockedIssues = blockedIssues,
|
| 1007 |
Progress = progress,
|
| 1008 |
IsAhead = isAhead,
|
| 1009 |
+
IsAtRisk = isAtRisk,
|
| 1010 |
+
ProjectHealth = health,
|
| 1011 |
+
LastActivity = lastActivity,
|
| 1012 |
+
NextMilestone = project.EndDate.Date >= today ? project.EndDate : null,
|
| 1013 |
+
ActualHours = projectIssues.Sum(i => i.ActualHours ?? 0)
|
| 1014 |
});
|
| 1015 |
}
|
| 1016 |
|
TaskTrackingSystem.WebApi/Program.cs
CHANGED
|
@@ -134,6 +134,8 @@ static async System.Threading.Tasks.Task EnsureSeedDataAsync(WebApplication app)
|
|
| 134 |
using var scope = app.Services.CreateScope();
|
| 135 |
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
| 136 |
|
|
|
|
|
|
|
| 137 |
var dashboardMenu = await db.Menus.FirstOrDefaultAsync(m => m.MenuCode == "DASHBOARD");
|
| 138 |
if (dashboardMenu == null)
|
| 139 |
{
|
|
@@ -316,6 +318,8 @@ static async System.Threading.Tasks.Task EnsureSeedDataAsync(WebApplication app)
|
|
| 316 |
|
| 317 |
await db.SaveChangesAsync();
|
| 318 |
|
|
|
|
|
|
|
| 319 |
// Soft-delete existing tasks that belong to deleted projects
|
| 320 |
var orphanedTasks = await db.Tasks
|
| 321 |
.Where(t => t.IsDeleted != true && t.Project.IsDeleted == true)
|
|
@@ -332,3 +336,82 @@ static async System.Threading.Tasks.Task EnsureSeedDataAsync(WebApplication app)
|
|
| 332 |
|
| 333 |
await db.SaveChangesAsync();
|
| 334 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
using var scope = app.Services.CreateScope();
|
| 135 |
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
| 136 |
|
| 137 |
+
await EnsureReportUpgradeSchemaAsync(db);
|
| 138 |
+
|
| 139 |
var dashboardMenu = await db.Menus.FirstOrDefaultAsync(m => m.MenuCode == "DASHBOARD");
|
| 140 |
if (dashboardMenu == null)
|
| 141 |
{
|
|
|
|
| 318 |
|
| 319 |
await db.SaveChangesAsync();
|
| 320 |
|
| 321 |
+
await EnsureIssueReportMenuAsync(db);
|
| 322 |
+
|
| 323 |
// Soft-delete existing tasks that belong to deleted projects
|
| 324 |
var orphanedTasks = await db.Tasks
|
| 325 |
.Where(t => t.IsDeleted != true && t.Project.IsDeleted == true)
|
|
|
|
| 336 |
|
| 337 |
await db.SaveChangesAsync();
|
| 338 |
}
|
| 339 |
+
|
| 340 |
+
static async System.Threading.Tasks.Task EnsureReportUpgradeSchemaAsync(AppDbContext db)
|
| 341 |
+
{
|
| 342 |
+
await db.Database.ExecuteSqlRawAsync(@"
|
| 343 |
+
IF COL_LENGTH('dbo.Issues', 'DelayReason') IS NULL
|
| 344 |
+
ALTER TABLE [dbo].[Issues] ADD [DelayReason] NVARCHAR(300) NULL;
|
| 345 |
+
|
| 346 |
+
IF COL_LENGTH('dbo.Issues', 'IsBlocked') IS NULL
|
| 347 |
+
ALTER TABLE [dbo].[Issues] ADD [IsBlocked] BIT NOT NULL CONSTRAINT [DF_Issues_IsBlocked] DEFAULT ((0));
|
| 348 |
+
|
| 349 |
+
IF COL_LENGTH('dbo.Issues', 'BlockedBy') IS NULL
|
| 350 |
+
ALTER TABLE [dbo].[Issues] ADD [BlockedBy] NVARCHAR(200) NULL;
|
| 351 |
+
|
| 352 |
+
IF COL_LENGTH('dbo.Issues', 'EscalationLevel') IS NULL
|
| 353 |
+
ALTER TABLE [dbo].[Issues] ADD [EscalationLevel] INT NOT NULL CONSTRAINT [DF_Issues_EscalationLevel] DEFAULT ((0));
|
| 354 |
+
");
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
static async System.Threading.Tasks.Task EnsureIssueReportMenuAsync(AppDbContext db)
|
| 358 |
+
{
|
| 359 |
+
var reportsMenu = await db.Menus.FirstOrDefaultAsync(m => m.MenuCode == "REPORTS" && !m.IsDeleted);
|
| 360 |
+
var issueReportMenu = await db.Menus.FirstOrDefaultAsync(m => m.MenuCode == "REPORTS_ISSUES");
|
| 361 |
+
|
| 362 |
+
if (issueReportMenu == null)
|
| 363 |
+
{
|
| 364 |
+
issueReportMenu = new Menu
|
| 365 |
+
{
|
| 366 |
+
MenuCode = "REPORTS_ISSUES",
|
| 367 |
+
ParentMenuId = reportsMenu?.MenuId,
|
| 368 |
+
MenuName = "Issue Report",
|
| 369 |
+
MenuUrl = "/reports/issues",
|
| 370 |
+
Icon = "file-pen-line",
|
| 371 |
+
Visible = true,
|
| 372 |
+
OrderNo = 15,
|
| 373 |
+
IsDeleted = false,
|
| 374 |
+
CreatedAt = DateTime.UtcNow
|
| 375 |
+
};
|
| 376 |
+
db.Menus.Add(issueReportMenu);
|
| 377 |
+
await db.SaveChangesAsync();
|
| 378 |
+
}
|
| 379 |
+
else
|
| 380 |
+
{
|
| 381 |
+
issueReportMenu.ParentMenuId = reportsMenu?.MenuId;
|
| 382 |
+
issueReportMenu.MenuName = "Issue Report";
|
| 383 |
+
issueReportMenu.MenuUrl = "/reports/issues";
|
| 384 |
+
issueReportMenu.Icon = "file-pen-line";
|
| 385 |
+
issueReportMenu.Visible = true;
|
| 386 |
+
issueReportMenu.OrderNo = 15;
|
| 387 |
+
issueReportMenu.IsDeleted = false;
|
| 388 |
+
issueReportMenu.UpdatedAt = DateTime.UtcNow;
|
| 389 |
+
await db.SaveChangesAsync();
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
var reportRoles = await db.Roles
|
| 393 |
+
.Where(role => !role.IsDeleted && (role.Name == "Admin" || role.Name == "Manager" || role.Name == "Employee"))
|
| 394 |
+
.ToListAsync();
|
| 395 |
+
|
| 396 |
+
foreach (var role in reportRoles)
|
| 397 |
+
{
|
| 398 |
+
var roleMenu = await db.RoleMenus.FirstOrDefaultAsync(rm => rm.RoleId == role.Id && rm.MenuId == issueReportMenu.MenuId);
|
| 399 |
+
if (roleMenu == null)
|
| 400 |
+
{
|
| 401 |
+
db.RoleMenus.Add(new RoleMenu
|
| 402 |
+
{
|
| 403 |
+
RoleId = role.Id,
|
| 404 |
+
MenuId = issueReportMenu.MenuId,
|
| 405 |
+
IsDeleted = false,
|
| 406 |
+
CreatedAt = DateTime.UtcNow
|
| 407 |
+
});
|
| 408 |
+
}
|
| 409 |
+
else
|
| 410 |
+
{
|
| 411 |
+
roleMenu.IsDeleted = false;
|
| 412 |
+
roleMenu.UpdatedAt = DateTime.UtcNow;
|
| 413 |
+
}
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
await db.SaveChangesAsync();
|
| 417 |
+
}
|
TaskTrackingSystem.WebApp/Components/Pages/Features/Issues/AddIssue.razor
CHANGED
|
@@ -111,6 +111,36 @@
|
|
| 111 |
</select>
|
| 112 |
</div>
|
| 113 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
<div>
|
| 115 |
<label class="block text-sm font-medium text-slate-700 mb-1.5">Project</label>
|
| 116 |
<input value="@SelectedProjectLabel" readonly
|
|
@@ -251,6 +281,10 @@
|
|
| 251 |
private DateTime formDueDate = DateTime.Today;
|
| 252 |
private AppTaskStatus formStatusId = AppTaskStatus.Todo;
|
| 253 |
private TaskPriority formPriorityId = TaskPriority.Medium;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
private string assigneeSearchInput = "";
|
| 255 |
private bool isAssigneeDropdownOpen;
|
| 256 |
private bool isLoadingProjectMembers;
|
|
@@ -471,6 +505,10 @@
|
|
| 471 |
AssignedTo = formAssignedTo,
|
| 472 |
EstimatedHours = formEstimatedHours,
|
| 473 |
ActualHours = formActualHours,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 474 |
StartDate = formStartDate,
|
| 475 |
DueDate = formDueDate,
|
| 476 |
StatusId = formStatusId,
|
|
@@ -512,6 +550,10 @@
|
|
| 512 |
formDueDate = DateTime.Today;
|
| 513 |
formStatusId = AppTaskStatus.Todo;
|
| 514 |
formPriorityId = TaskPriority.Medium;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 515 |
taskErrorMessage = "";
|
| 516 |
titleErrorMessage = "";
|
| 517 |
isTaskError = false;
|
|
|
|
| 111 |
</select>
|
| 112 |
</div>
|
| 113 |
|
| 114 |
+
<div>
|
| 115 |
+
<label class="block text-sm font-medium text-slate-700 mb-1.5">Blocked Status</label>
|
| 116 |
+
<label class="flex h-10 items-center gap-2 rounded-lg border border-slate-200 px-3 text-sm text-slate-700">
|
| 117 |
+
<input type="checkbox" @bind="formIsBlocked" class="h-4 w-4 rounded border-slate-300 text-violet-600 focus:ring-violet-500" />
|
| 118 |
+
<span>Blocked</span>
|
| 119 |
+
</label>
|
| 120 |
+
</div>
|
| 121 |
+
|
| 122 |
+
<div>
|
| 123 |
+
<label class="block text-sm font-medium text-slate-700 mb-1.5">Escalation Level</label>
|
| 124 |
+
<select @bind="formEscalationLevel" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all">
|
| 125 |
+
<option value="0">None</option>
|
| 126 |
+
<option value="1">Team Lead</option>
|
| 127 |
+
<option value="2">PM</option>
|
| 128 |
+
<option value="3">Manager</option>
|
| 129 |
+
</select>
|
| 130 |
+
</div>
|
| 131 |
+
|
| 132 |
+
<div>
|
| 133 |
+
<label class="block text-sm font-medium text-slate-700 mb-1.5">Blocked By</label>
|
| 134 |
+
<input @bind="formBlockedBy" type="text" placeholder="Dependency, person, or team"
|
| 135 |
+
class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" />
|
| 136 |
+
</div>
|
| 137 |
+
|
| 138 |
+
<div>
|
| 139 |
+
<label class="block text-sm font-medium text-slate-700 mb-1.5">Delay Reason</label>
|
| 140 |
+
<input @bind="formDelayReason" type="text" placeholder="Why is this issue delayed?"
|
| 141 |
+
class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" />
|
| 142 |
+
</div>
|
| 143 |
+
|
| 144 |
<div>
|
| 145 |
<label class="block text-sm font-medium text-slate-700 mb-1.5">Project</label>
|
| 146 |
<input value="@SelectedProjectLabel" readonly
|
|
|
|
| 281 |
private DateTime formDueDate = DateTime.Today;
|
| 282 |
private AppTaskStatus formStatusId = AppTaskStatus.Todo;
|
| 283 |
private TaskPriority formPriorityId = TaskPriority.Medium;
|
| 284 |
+
private string? formDelayReason;
|
| 285 |
+
private bool formIsBlocked;
|
| 286 |
+
private string? formBlockedBy;
|
| 287 |
+
private int formEscalationLevel;
|
| 288 |
private string assigneeSearchInput = "";
|
| 289 |
private bool isAssigneeDropdownOpen;
|
| 290 |
private bool isLoadingProjectMembers;
|
|
|
|
| 505 |
AssignedTo = formAssignedTo,
|
| 506 |
EstimatedHours = formEstimatedHours,
|
| 507 |
ActualHours = formActualHours,
|
| 508 |
+
DelayReason = formDelayReason,
|
| 509 |
+
IsBlocked = formIsBlocked,
|
| 510 |
+
BlockedBy = formBlockedBy,
|
| 511 |
+
EscalationLevel = formEscalationLevel,
|
| 512 |
StartDate = formStartDate,
|
| 513 |
DueDate = formDueDate,
|
| 514 |
StatusId = formStatusId,
|
|
|
|
| 550 |
formDueDate = DateTime.Today;
|
| 551 |
formStatusId = AppTaskStatus.Todo;
|
| 552 |
formPriorityId = TaskPriority.Medium;
|
| 553 |
+
formDelayReason = null;
|
| 554 |
+
formIsBlocked = false;
|
| 555 |
+
formBlockedBy = null;
|
| 556 |
+
formEscalationLevel = 0;
|
| 557 |
taskErrorMessage = "";
|
| 558 |
titleErrorMessage = "";
|
| 559 |
isTaskError = false;
|
TaskTrackingSystem.WebApp/Components/Pages/Features/Issues/Issues.razor
CHANGED
|
@@ -108,8 +108,9 @@
|
|
| 108 |
<col class="w-[11%]" />
|
| 109 |
<col class="w-[11%]" />
|
| 110 |
<col class="w-[12%]" />
|
| 111 |
-
<col class="w-[
|
| 112 |
-
<col class="w-[
|
|
|
|
| 113 |
<col class="w-[10%]" />
|
| 114 |
</colgroup>
|
| 115 |
<thead>
|
|
@@ -121,6 +122,7 @@
|
|
| 121 |
<th class="px-5 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Priority</th>
|
| 122 |
<th class="px-5 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Dates</th>
|
| 123 |
<th class="px-5 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Assignee</th>
|
|
|
|
| 124 |
<th class="px-5 py-3 text-center text-xs font-semibold text-slate-500 uppercase tracking-wider">Actions</th>
|
| 125 |
</tr>
|
| 126 |
</thead>
|
|
@@ -160,6 +162,20 @@
|
|
| 160 |
<td class="px-5 py-4 text-sm text-slate-600 break-words">
|
| 161 |
@(!string.IsNullOrWhiteSpace(issue.AssignedToName) ? issue.AssignedToName : "Unassigned")
|
| 162 |
</td>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
<td class="px-5 py-4 text-center">
|
| 164 |
<div class="flex items-center justify-center gap-1">
|
| 165 |
@if (canEditIssue)
|
|
@@ -181,7 +197,7 @@
|
|
| 181 |
}
|
| 182 |
else
|
| 183 |
{
|
| 184 |
-
<EmptyState ColSpan="
|
| 185 |
}
|
| 186 |
</tbody>
|
| 187 |
</table>
|
|
@@ -199,9 +215,9 @@
|
|
| 199 |
|
| 200 |
@if (showModal)
|
| 201 |
{
|
| 202 |
-
<div class="fixed inset-0 z-50 flex items-
|
| 203 |
-
<div class="
|
| 204 |
-
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-100">
|
| 205 |
<div>
|
| 206 |
<h3 class="text-lg font-semibold text-slate-900">Edit Issue</h3>
|
| 207 |
<p class="text-xs text-slate-500 mt-0.5">Issue #@editingId</p>
|
|
@@ -211,18 +227,50 @@
|
|
| 211 |
</button>
|
| 212 |
</div>
|
| 213 |
|
| 214 |
-
<div class="p-6 space-y-4">
|
| 215 |
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 216 |
-
<div class="rounded-xl border border-slate-200 bg-slate-50 p-
|
| 217 |
<p class="text-xs font-semibold uppercase tracking-wider text-slate-400">Task</p>
|
| 218 |
<p class="mt-1 text-sm font-semibold text-slate-900">@SelectedTaskLabel</p>
|
| 219 |
</div>
|
| 220 |
-
<div class="rounded-xl border border-slate-200 bg-slate-50 p-
|
| 221 |
<p class="text-xs font-semibold uppercase tracking-wider text-slate-400">Project</p>
|
| 222 |
<p class="mt-1 text-sm font-semibold text-slate-900">@SelectedProjectLabel</p>
|
| 223 |
</div>
|
| 224 |
</div>
|
| 225 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
<div>
|
| 227 |
<label class="block text-sm font-medium text-slate-700 mb-1.5">Title <span class="text-red-500">*</span></label>
|
| 228 |
<input @bind="formTitle" type="text" placeholder="Issue title"
|
|
@@ -231,7 +279,7 @@
|
|
| 231 |
|
| 232 |
<div>
|
| 233 |
<label class="block text-sm font-medium text-slate-700 mb-1.5">Description</label>
|
| 234 |
-
<textarea @bind="formDescription" rows="
|
| 235 |
class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all"></textarea>
|
| 236 |
</div>
|
| 237 |
|
|
@@ -293,7 +341,7 @@
|
|
| 293 |
}
|
| 294 |
</div>
|
| 295 |
|
| 296 |
-
<div class="flex items-center justify-end gap-3
|
| 297 |
<button @onclick="CloseModal" class="rounded-lg px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-100 transition-colors">Cancel</button>
|
| 298 |
<button @onclick="RequestSaveConfirmation" disabled="@isSaving" class="rounded-lg bg-violet-600 px-4 py-2 text-sm font-medium text-white hover:bg-violet-700 disabled:opacity-50 transition-colors">
|
| 299 |
@(isSaving ? "Saving..." : "Update")
|
|
@@ -369,6 +417,10 @@
|
|
| 369 |
private DateTime formDueDate = DateTime.Today;
|
| 370 |
private AppTaskStatus formStatusId = AppTaskStatus.Todo;
|
| 371 |
private TaskPriority formPriorityId = TaskPriority.Medium;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 372 |
|
| 373 |
private string SelectedTaskLabel => tasks.FirstOrDefault(t => t.Id == formTaskId)?.Title ?? $"Task #{formTaskId}";
|
| 374 |
private string SelectedProjectLabel => tasks.FirstOrDefault(t => t.Id == formTaskId) is { } task
|
|
@@ -539,6 +591,10 @@
|
|
| 539 |
formDueDate = issue.DueDate;
|
| 540 |
formStatusId = issue.StatusId;
|
| 541 |
formPriorityId = issue.PriorityId;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 542 |
errorMessage = null;
|
| 543 |
showModal = true;
|
| 544 |
|
|
@@ -612,6 +668,10 @@
|
|
| 612 |
Description = formDescription,
|
| 613 |
AssignedTo = formAssignedTo,
|
| 614 |
ActualHours = formActualHours,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 615 |
StartDate = formStartDate,
|
| 616 |
DueDate = formDueDate,
|
| 617 |
StatusId = formStatusId,
|
|
@@ -735,4 +795,12 @@
|
|
| 735 |
TaskPriority.High => "High",
|
| 736 |
_ => "Unknown"
|
| 737 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 738 |
}
|
|
|
|
| 108 |
<col class="w-[11%]" />
|
| 109 |
<col class="w-[11%]" />
|
| 110 |
<col class="w-[12%]" />
|
| 111 |
+
<col class="w-[12%]" />
|
| 112 |
+
<col class="w-[12%]" />
|
| 113 |
+
<col class="w-[8%]" />
|
| 114 |
<col class="w-[10%]" />
|
| 115 |
</colgroup>
|
| 116 |
<thead>
|
|
|
|
| 122 |
<th class="px-5 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Priority</th>
|
| 123 |
<th class="px-5 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Dates</th>
|
| 124 |
<th class="px-5 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Assignee</th>
|
| 125 |
+
<th class="px-5 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Risk</th>
|
| 126 |
<th class="px-5 py-3 text-center text-xs font-semibold text-slate-500 uppercase tracking-wider">Actions</th>
|
| 127 |
</tr>
|
| 128 |
</thead>
|
|
|
|
| 162 |
<td class="px-5 py-4 text-sm text-slate-600 break-words">
|
| 163 |
@(!string.IsNullOrWhiteSpace(issue.AssignedToName) ? issue.AssignedToName : "Unassigned")
|
| 164 |
</td>
|
| 165 |
+
<td class="px-5 py-4">
|
| 166 |
+
@if (issue.IsBlocked)
|
| 167 |
+
{
|
| 168 |
+
<span class="inline-flex items-center rounded-full bg-rose-50 px-2 py-0.5 text-xs font-semibold text-rose-700">Blocked</span>
|
| 169 |
+
}
|
| 170 |
+
else if (issue.EscalationLevel > 0)
|
| 171 |
+
{
|
| 172 |
+
<span class="inline-flex items-center rounded-full bg-amber-50 px-2 py-0.5 text-xs font-semibold text-amber-700">@GetEscalationLabel(issue.EscalationLevel)</span>
|
| 173 |
+
}
|
| 174 |
+
else
|
| 175 |
+
{
|
| 176 |
+
<span class="text-xs text-slate-400">Clear</span>
|
| 177 |
+
}
|
| 178 |
+
</td>
|
| 179 |
<td class="px-5 py-4 text-center">
|
| 180 |
<div class="flex items-center justify-center gap-1">
|
| 181 |
@if (canEditIssue)
|
|
|
|
| 197 |
}
|
| 198 |
else
|
| 199 |
{
|
| 200 |
+
<EmptyState ColSpan="9" Icon="file-pen-line" Title="No issues yet" Subtitle="Create your first issue to start tracking daily work." />
|
| 201 |
}
|
| 202 |
</tbody>
|
| 203 |
</table>
|
|
|
|
| 215 |
|
| 216 |
@if (showModal)
|
| 217 |
{
|
| 218 |
+
<div class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 p-4 backdrop-blur-sm sm:p-6">
|
| 219 |
+
<div class="flex max-h-[calc(100vh-2rem)] w-full max-w-4xl flex-col overflow-hidden rounded-2xl bg-white shadow-xl sm:max-h-[calc(100vh-3rem)]">
|
| 220 |
+
<div class="shrink-0 flex items-center justify-between px-6 py-4 border-b border-slate-100">
|
| 221 |
<div>
|
| 222 |
<h3 class="text-lg font-semibold text-slate-900">Edit Issue</h3>
|
| 223 |
<p class="text-xs text-slate-500 mt-0.5">Issue #@editingId</p>
|
|
|
|
| 227 |
</button>
|
| 228 |
</div>
|
| 229 |
|
| 230 |
+
<div class="min-h-0 flex-1 overflow-y-auto p-6 space-y-4">
|
| 231 |
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 232 |
+
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
|
| 233 |
<p class="text-xs font-semibold uppercase tracking-wider text-slate-400">Task</p>
|
| 234 |
<p class="mt-1 text-sm font-semibold text-slate-900">@SelectedTaskLabel</p>
|
| 235 |
</div>
|
| 236 |
+
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
|
| 237 |
<p class="text-xs font-semibold uppercase tracking-wider text-slate-400">Project</p>
|
| 238 |
<p class="mt-1 text-sm font-semibold text-slate-900">@SelectedProjectLabel</p>
|
| 239 |
</div>
|
| 240 |
</div>
|
| 241 |
|
| 242 |
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 243 |
+
<div>
|
| 244 |
+
<label class="block text-sm font-medium text-slate-700 mb-1.5">Blocked Status</label>
|
| 245 |
+
<label class="flex h-10 items-center gap-2 rounded-lg border border-slate-200 px-3 text-sm text-slate-700">
|
| 246 |
+
<input type="checkbox" @bind="formIsBlocked" class="h-4 w-4 rounded border-slate-300 text-violet-600 focus:ring-violet-500" />
|
| 247 |
+
<span>Blocked</span>
|
| 248 |
+
</label>
|
| 249 |
+
</div>
|
| 250 |
+
<div>
|
| 251 |
+
<label class="block text-sm font-medium text-slate-700 mb-1.5">Escalation Level</label>
|
| 252 |
+
<select @bind="formEscalationLevel" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all">
|
| 253 |
+
<option value="0">None</option>
|
| 254 |
+
<option value="1">Team Lead</option>
|
| 255 |
+
<option value="2">PM</option>
|
| 256 |
+
<option value="3">Manager</option>
|
| 257 |
+
</select>
|
| 258 |
+
</div>
|
| 259 |
+
</div>
|
| 260 |
+
|
| 261 |
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 262 |
+
<div>
|
| 263 |
+
<label class="block text-sm font-medium text-slate-700 mb-1.5">Blocked By</label>
|
| 264 |
+
<input @bind="formBlockedBy" type="text" placeholder="Dependency, person, or team"
|
| 265 |
+
class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" />
|
| 266 |
+
</div>
|
| 267 |
+
<div>
|
| 268 |
+
<label class="block text-sm font-medium text-slate-700 mb-1.5">Delay Reason</label>
|
| 269 |
+
<input @bind="formDelayReason" type="text" placeholder="Why is this issue delayed?"
|
| 270 |
+
class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" />
|
| 271 |
+
</div>
|
| 272 |
+
</div>
|
| 273 |
+
|
| 274 |
<div>
|
| 275 |
<label class="block text-sm font-medium text-slate-700 mb-1.5">Title <span class="text-red-500">*</span></label>
|
| 276 |
<input @bind="formTitle" type="text" placeholder="Issue title"
|
|
|
|
| 279 |
|
| 280 |
<div>
|
| 281 |
<label class="block text-sm font-medium text-slate-700 mb-1.5">Description</label>
|
| 282 |
+
<textarea @bind="formDescription" rows="3" placeholder="Describe the work..."
|
| 283 |
class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all"></textarea>
|
| 284 |
</div>
|
| 285 |
|
|
|
|
| 341 |
}
|
| 342 |
</div>
|
| 343 |
|
| 344 |
+
<div class="shrink-0 flex items-center justify-end gap-3 border-t border-slate-100 bg-white px-6 py-4 shadow-[0_-12px_24px_rgba(15,23,42,0.06)]">
|
| 345 |
<button @onclick="CloseModal" class="rounded-lg px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-100 transition-colors">Cancel</button>
|
| 346 |
<button @onclick="RequestSaveConfirmation" disabled="@isSaving" class="rounded-lg bg-violet-600 px-4 py-2 text-sm font-medium text-white hover:bg-violet-700 disabled:opacity-50 transition-colors">
|
| 347 |
@(isSaving ? "Saving..." : "Update")
|
|
|
|
| 417 |
private DateTime formDueDate = DateTime.Today;
|
| 418 |
private AppTaskStatus formStatusId = AppTaskStatus.Todo;
|
| 419 |
private TaskPriority formPriorityId = TaskPriority.Medium;
|
| 420 |
+
private string? formDelayReason;
|
| 421 |
+
private bool formIsBlocked;
|
| 422 |
+
private string? formBlockedBy;
|
| 423 |
+
private int formEscalationLevel;
|
| 424 |
|
| 425 |
private string SelectedTaskLabel => tasks.FirstOrDefault(t => t.Id == formTaskId)?.Title ?? $"Task #{formTaskId}";
|
| 426 |
private string SelectedProjectLabel => tasks.FirstOrDefault(t => t.Id == formTaskId) is { } task
|
|
|
|
| 591 |
formDueDate = issue.DueDate;
|
| 592 |
formStatusId = issue.StatusId;
|
| 593 |
formPriorityId = issue.PriorityId;
|
| 594 |
+
formDelayReason = issue.DelayReason;
|
| 595 |
+
formIsBlocked = issue.IsBlocked;
|
| 596 |
+
formBlockedBy = issue.BlockedBy;
|
| 597 |
+
formEscalationLevel = issue.EscalationLevel;
|
| 598 |
errorMessage = null;
|
| 599 |
showModal = true;
|
| 600 |
|
|
|
|
| 668 |
Description = formDescription,
|
| 669 |
AssignedTo = formAssignedTo,
|
| 670 |
ActualHours = formActualHours,
|
| 671 |
+
DelayReason = formDelayReason,
|
| 672 |
+
IsBlocked = formIsBlocked,
|
| 673 |
+
BlockedBy = formBlockedBy,
|
| 674 |
+
EscalationLevel = formEscalationLevel,
|
| 675 |
StartDate = formStartDate,
|
| 676 |
DueDate = formDueDate,
|
| 677 |
StatusId = formStatusId,
|
|
|
|
| 795 |
TaskPriority.High => "High",
|
| 796 |
_ => "Unknown"
|
| 797 |
};
|
| 798 |
+
|
| 799 |
+
private static string GetEscalationLabel(int level) => level switch
|
| 800 |
+
{
|
| 801 |
+
1 => "Team Lead",
|
| 802 |
+
2 => "PM",
|
| 803 |
+
3 => "Manager",
|
| 804 |
+
_ => "None"
|
| 805 |
+
};
|
| 806 |
}
|
TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/EmployeeReport.razor
CHANGED
|
@@ -10,14 +10,14 @@
|
|
| 10 |
@inject ApiClientService ApiClient
|
| 11 |
@inject IJSRuntime JS
|
| 12 |
|
| 13 |
-
<PageTitle>Employee
|
| 14 |
|
| 15 |
<div class="space-y-6">
|
| 16 |
<!-- Page Header -->
|
| 17 |
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
| 18 |
<div>
|
| 19 |
-
<h2 class="text-3xl font-bold tracking-tight text-slate-900">Employee
|
| 20 |
-
<p class="text-slate-500 mt-1">Monitor
|
| 21 |
</div>
|
| 22 |
<div class="flex items-center space-x-3">
|
| 23 |
<div class="inline-flex rounded-lg border border-slate-200 bg-white p-0.5 shadow-sm">
|
|
@@ -113,7 +113,7 @@
|
|
| 113 |
{
|
| 114 |
<!-- Chart View: SVG Horizontal Bar Chart showing Efficiency -->
|
| 115 |
<div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
| 116 |
-
<h3 class="font-semibold text-slate-900 mb-4 font-sans text-md">
|
| 117 |
@if (!allEmployees.Any())
|
| 118 |
{
|
| 119 |
<p class="text-slate-400 text-center py-8">No data available.</p>
|
|
@@ -126,10 +126,10 @@
|
|
| 126 |
<div>
|
| 127 |
<div class="flex justify-between text-xs font-semibold text-slate-700 mb-1.5">
|
| 128 |
<span>@emp.FullName (@emp.Username)</span>
|
| 129 |
-
<span>@emp.
|
| 130 |
</div>
|
| 131 |
<div class="h-4 rounded-full bg-slate-100 overflow-hidden flex">
|
| 132 |
-
<div class="h-full bg-
|
| 133 |
</div>
|
| 134 |
</div>
|
| 135 |
}
|
|
@@ -146,10 +146,11 @@
|
|
| 146 |
<tr class="border-b border-slate-100 bg-slate-50/60">
|
| 147 |
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">No.</th>
|
| 148 |
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Employee</th>
|
| 149 |
-
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
| 150 |
-
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
| 151 |
-
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
| 152 |
-
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
|
|
|
| 153 |
</tr>
|
| 154 |
</thead>
|
| 155 |
<tbody class="divide-y divide-slate-100">
|
|
@@ -171,34 +172,18 @@
|
|
| 171 |
</div>
|
| 172 |
</div>
|
| 173 |
</td>
|
| 174 |
-
<td class="px-6 py-4 text-sm font-medium text-slate-900">@emp.
|
| 175 |
-
<td class="px-6 py-4 text-sm font-medium text-
|
| 176 |
-
<td class="px-6 py-4">
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
<div class="h-full rounded-full bg-emerald-500 transition-all" style="width: @(Math.Min(emp.Efficiency, 100))%"></div>
|
| 180 |
-
</div>
|
| 181 |
-
<span class="text-sm font-semibold text-slate-700">@emp.Efficiency.ToString("F0")%</span>
|
| 182 |
-
</div>
|
| 183 |
-
</td>
|
| 184 |
-
<td class="px-6 py-4">
|
| 185 |
-
<div class="flex items-center space-x-3">
|
| 186 |
-
<div class="flex-1 h-2.5 rounded-full bg-slate-100 overflow-hidden max-w-[120px]">
|
| 187 |
-
<div class="h-full rounded-full @(emp.OnTimeDeliveryRate >= 80 ? "bg-emerald-500" : emp.OnTimeDeliveryRate >= 50 ? "bg-amber-400" : "bg-rose-500") transition-all"
|
| 188 |
-
style="width: @(Math.Min(emp.OnTimeDeliveryRate, 100))%"></div>
|
| 189 |
-
</div>
|
| 190 |
-
<span class="text-sm font-semibold @(emp.OnTimeDeliveryRate >= 80 ? "text-emerald-600" : emp.OnTimeDeliveryRate >= 50 ? "text-amber-500" : "text-rose-600")">
|
| 191 |
-
@emp.OnTimeDeliveryRate.ToString("F0")%
|
| 192 |
-
</span>
|
| 193 |
-
</div>
|
| 194 |
-
</td>
|
| 195 |
</tr>
|
| 196 |
}
|
| 197 |
}
|
| 198 |
else
|
| 199 |
{
|
| 200 |
<tr>
|
| 201 |
-
<td colspan="
|
| 202 |
<p class="text-sm text-slate-400">No employee data found matching criteria.</p>
|
| 203 |
</td>
|
| 204 |
</tr>
|
|
@@ -365,16 +350,18 @@
|
|
| 365 |
{
|
| 366 |
emp.FullName,
|
| 367 |
emp.Username,
|
| 368 |
-
emp.
|
| 369 |
-
emp.
|
| 370 |
-
|
| 371 |
-
|
|
|
|
|
|
|
| 372 |
});
|
| 373 |
|
| 374 |
var workbookBytes = SpreadsheetReportBuilder.BuildTableWorkbook(
|
| 375 |
-
"Employee
|
| 376 |
-
"Employee
|
| 377 |
-
new[] { "Employee", "Username", "
|
| 378 |
rows,
|
| 379 |
new[]
|
| 380 |
{
|
|
@@ -386,7 +373,7 @@
|
|
| 386 |
|
| 387 |
await JS.InvokeVoidAsync(
|
| 388 |
"downloadFileFromBase64",
|
| 389 |
-
$"
|
| 390 |
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
| 391 |
Convert.ToBase64String(workbookBytes));
|
| 392 |
}
|
|
@@ -405,22 +392,38 @@
|
|
| 405 |
{
|
| 406 |
emp.FullName,
|
| 407 |
emp.Username,
|
| 408 |
-
emp.
|
| 409 |
-
emp.
|
| 410 |
-
|
| 411 |
-
|
|
|
|
|
|
|
| 412 |
});
|
| 413 |
|
| 414 |
var pdfBytes = SimplePdfReportBuilder.BuildTableReport(
|
| 415 |
-
"Employee
|
| 416 |
summaryLines,
|
| 417 |
-
new[] { "Employee", "Username", "
|
| 418 |
rows);
|
| 419 |
|
| 420 |
await JS.InvokeVoidAsync(
|
| 421 |
"downloadFileFromBase64",
|
| 422 |
-
$"
|
| 423 |
"application/pdf",
|
| 424 |
Convert.ToBase64String(pdfBytes));
|
| 425 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 426 |
}
|
|
|
|
| 10 |
@inject ApiClientService ApiClient
|
| 11 |
@inject IJSRuntime JS
|
| 12 |
|
| 13 |
+
<PageTitle>Employee Workload & Performance</PageTitle>
|
| 14 |
|
| 15 |
<div class="space-y-6">
|
| 16 |
<!-- Page Header -->
|
| 17 |
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
| 18 |
<div>
|
| 19 |
+
<h2 class="text-3xl font-bold tracking-tight text-slate-900">Employee Workload & Performance</h2>
|
| 20 |
+
<p class="text-slate-500 mt-1">Monitor issue workload, execution health, and actual effort.</p>
|
| 21 |
</div>
|
| 22 |
<div class="flex items-center space-x-3">
|
| 23 |
<div class="inline-flex rounded-lg border border-slate-200 bg-white p-0.5 shadow-sm">
|
|
|
|
| 113 |
{
|
| 114 |
<!-- Chart View: SVG Horizontal Bar Chart showing Efficiency -->
|
| 115 |
<div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
| 116 |
+
<h3 class="font-semibold text-slate-900 mb-4 font-sans text-md">Workload by Employee</h3>
|
| 117 |
@if (!allEmployees.Any())
|
| 118 |
{
|
| 119 |
<p class="text-slate-400 text-center py-8">No data available.</p>
|
|
|
|
| 126 |
<div>
|
| 127 |
<div class="flex justify-between text-xs font-semibold text-slate-700 mb-1.5">
|
| 128 |
<span>@emp.FullName (@emp.Username)</span>
|
| 129 |
+
<span>@emp.OpenIssues open, @emp.OverdueIssues overdue, @emp.ActualHours.ToString("N1") hrs</span>
|
| 130 |
</div>
|
| 131 |
<div class="h-4 rounded-full bg-slate-100 overflow-hidden flex">
|
| 132 |
+
<div class="h-full bg-violet-600 rounded-full transition-all" style="width: @(Math.Min(emp.OpenIssues * 10, 100))%"></div>
|
| 133 |
</div>
|
| 134 |
</div>
|
| 135 |
}
|
|
|
|
| 146 |
<tr class="border-b border-slate-100 bg-slate-50/60">
|
| 147 |
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">No.</th>
|
| 148 |
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Employee</th>
|
| 149 |
+
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Assigned Issues</th>
|
| 150 |
+
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Open / Overdue</th>
|
| 151 |
+
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Actual Hours</th>
|
| 152 |
+
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Workload</th>
|
| 153 |
+
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Trend</th>
|
| 154 |
</tr>
|
| 155 |
</thead>
|
| 156 |
<tbody class="divide-y divide-slate-100">
|
|
|
|
| 172 |
</div>
|
| 173 |
</div>
|
| 174 |
</td>
|
| 175 |
+
<td class="px-6 py-4 text-sm font-medium text-slate-900">@emp.AssignedIssues</td>
|
| 176 |
+
<td class="px-6 py-4 text-sm font-medium"><span class="text-blue-600">@emp.OpenIssues</span> / <span class="text-rose-600">@emp.OverdueIssues</span></td>
|
| 177 |
+
<td class="px-6 py-4 text-sm font-semibold text-slate-800">@emp.ActualHours.ToString("N1") hrs</td>
|
| 178 |
+
<td class="px-6 py-4"><span class="inline-flex rounded-full px-2.5 py-0.5 text-xs font-semibold @GetWorkloadBadge(emp.WorkloadLevel)">@emp.WorkloadLevel</span></td>
|
| 179 |
+
<td class="px-6 py-4"><span class="inline-flex rounded-full px-2.5 py-0.5 text-xs font-semibold @GetTrendBadge(emp.Trend)">@emp.Trend</span></td>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
</tr>
|
| 181 |
}
|
| 182 |
}
|
| 183 |
else
|
| 184 |
{
|
| 185 |
<tr>
|
| 186 |
+
<td colspan="7" class="px-6 py-12 text-center">
|
| 187 |
<p class="text-sm text-slate-400">No employee data found matching criteria.</p>
|
| 188 |
</td>
|
| 189 |
</tr>
|
|
|
|
| 350 |
{
|
| 351 |
emp.FullName,
|
| 352 |
emp.Username,
|
| 353 |
+
emp.AssignedIssues,
|
| 354 |
+
emp.OpenIssues,
|
| 355 |
+
emp.OverdueIssues,
|
| 356 |
+
emp.ActualHours,
|
| 357 |
+
emp.WorkloadLevel,
|
| 358 |
+
emp.Trend
|
| 359 |
});
|
| 360 |
|
| 361 |
var workbookBytes = SpreadsheetReportBuilder.BuildTableWorkbook(
|
| 362 |
+
"Employee Workload",
|
| 363 |
+
"Employee Workload & Performance Report",
|
| 364 |
+
new[] { "Employee", "Username", "Assigned Issues", "Open Issues", "Overdue Issues", "Actual Hours", "Workload", "Trend" },
|
| 365 |
rows,
|
| 366 |
new[]
|
| 367 |
{
|
|
|
|
| 373 |
|
| 374 |
await JS.InvokeVoidAsync(
|
| 375 |
"downloadFileFromBase64",
|
| 376 |
+
$"EmployeeWorkloadPerformance_{DateTime.Today:yyyyMMdd}.xlsx",
|
| 377 |
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
| 378 |
Convert.ToBase64String(workbookBytes));
|
| 379 |
}
|
|
|
|
| 392 |
{
|
| 393 |
emp.FullName,
|
| 394 |
emp.Username,
|
| 395 |
+
emp.AssignedIssues.ToString(),
|
| 396 |
+
emp.OpenIssues.ToString(),
|
| 397 |
+
emp.OverdueIssues.ToString(),
|
| 398 |
+
emp.ActualHours.ToString("N1"),
|
| 399 |
+
emp.WorkloadLevel,
|
| 400 |
+
emp.Trend
|
| 401 |
});
|
| 402 |
|
| 403 |
var pdfBytes = SimplePdfReportBuilder.BuildTableReport(
|
| 404 |
+
"Employee Workload & Performance Report",
|
| 405 |
summaryLines,
|
| 406 |
+
new[] { "Employee", "Username", "Assigned Issues", "Open", "Overdue", "Hours", "Workload", "Trend" },
|
| 407 |
rows);
|
| 408 |
|
| 409 |
await JS.InvokeVoidAsync(
|
| 410 |
"downloadFileFromBase64",
|
| 411 |
+
$"EmployeeWorkloadPerformance_{DateTime.Today:yyyyMMdd}.pdf",
|
| 412 |
"application/pdf",
|
| 413 |
Convert.ToBase64String(pdfBytes));
|
| 414 |
}
|
| 415 |
+
|
| 416 |
+
private static string GetWorkloadBadge(string workload) => workload switch
|
| 417 |
+
{
|
| 418 |
+
"High" => "bg-rose-50 text-rose-700",
|
| 419 |
+
"Light" => "bg-slate-100 text-slate-600",
|
| 420 |
+
_ => "bg-emerald-50 text-emerald-700"
|
| 421 |
+
};
|
| 422 |
+
|
| 423 |
+
private static string GetTrendBadge(string trend) => trend switch
|
| 424 |
+
{
|
| 425 |
+
"Improving" => "bg-emerald-50 text-emerald-700",
|
| 426 |
+
"Declining" => "bg-rose-50 text-rose-700",
|
| 427 |
+
_ => "bg-blue-50 text-blue-700"
|
| 428 |
+
};
|
| 429 |
}
|
TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/IssueReport.razor
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@page "/reports/issues"
|
| 2 |
+
@using Microsoft.AspNetCore.Authorization
|
| 3 |
+
@using TaskTrackingSystem.Shared
|
| 4 |
+
@using TaskTrackingSystem.Shared.Enums
|
| 5 |
+
@using TaskTrackingSystem.Shared.Models.Issue
|
| 6 |
+
@using TaskTrackingSystem.Shared.Models.Project
|
| 7 |
+
@rendermode @(new InteractiveServerRenderMode(prerender: false))
|
| 8 |
+
@attribute [Authorize]
|
| 9 |
+
@inject ApiClientService ApiClient
|
| 10 |
+
@inject IJSRuntime JS
|
| 11 |
+
|
| 12 |
+
<PageTitle>Issue Report</PageTitle>
|
| 13 |
+
|
| 14 |
+
<div class="space-y-6">
|
| 15 |
+
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
| 16 |
+
<div>
|
| 17 |
+
<h2 class="text-3xl font-bold tracking-tight text-slate-900">Issue Report</h2>
|
| 18 |
+
<p class="mt-1 text-slate-500">Track daily execution, backlog health, and issue-level effort.</p>
|
| 19 |
+
</div>
|
| 20 |
+
<div class="flex flex-wrap items-center gap-3">
|
| 21 |
+
<div class="inline-flex rounded-lg border border-slate-200 bg-white p-0.5 shadow-sm">
|
| 22 |
+
<button @onclick="() => SetView(false)" class="inline-flex items-center rounded-md px-3 py-1.5 text-xs font-semibold @(!isChartView ? "bg-violet-600 text-white" : "text-slate-600 hover:text-slate-900")">
|
| 23 |
+
<i data-lucide="list" class="mr-1 h-3.5 w-3.5"></i> List View
|
| 24 |
+
</button>
|
| 25 |
+
<button @onclick="() => SetView(true)" class="inline-flex items-center rounded-md px-3 py-1.5 text-xs font-semibold @(isChartView ? "bg-violet-600 text-white" : "text-slate-600 hover:text-slate-900")">
|
| 26 |
+
<i data-lucide="bar-chart-3" class="mr-1 h-3.5 w-3.5"></i> Chart View
|
| 27 |
+
</button>
|
| 28 |
+
</div>
|
| 29 |
+
<button @onclick="DownloadExcel" class="inline-flex items-center rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">
|
| 30 |
+
<i data-lucide="download" class="mr-2 h-4 w-4"></i> Export Excel
|
| 31 |
+
</button>
|
| 32 |
+
<button @onclick="DownloadPdf" class="inline-flex items-center rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">
|
| 33 |
+
<i data-lucide="file-text" class="mr-2 h-4 w-4"></i> Export PDF
|
| 34 |
+
</button>
|
| 35 |
+
</div>
|
| 36 |
+
</div>
|
| 37 |
+
|
| 38 |
+
<div class="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
|
| 39 |
+
<div class="flex flex-wrap items-end gap-3">
|
| 40 |
+
<div class="min-w-[220px] flex-1">
|
| 41 |
+
<label class="mb-1.5 block text-xs font-medium text-slate-500">Search</label>
|
| 42 |
+
<input @bind="searchInput" type="text" placeholder="Search issue, task, project, assignee..."
|
| 43 |
+
class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600" />
|
| 44 |
+
</div>
|
| 45 |
+
<div>
|
| 46 |
+
<label class="mb-1.5 block text-xs font-medium text-slate-500">Project</label>
|
| 47 |
+
<select @bind="filterProjectId" class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600">
|
| 48 |
+
<option value="0">All Projects</option>
|
| 49 |
+
@foreach (var project in projects)
|
| 50 |
+
{
|
| 51 |
+
<option value="@project.Id">@project.Name</option>
|
| 52 |
+
}
|
| 53 |
+
</select>
|
| 54 |
+
</div>
|
| 55 |
+
<div>
|
| 56 |
+
<label class="mb-1.5 block text-xs font-medium text-slate-500">Status</label>
|
| 57 |
+
<select @bind="filterStatus" class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600">
|
| 58 |
+
<option value="0">All Statuses</option>
|
| 59 |
+
<option value="1">To Do</option>
|
| 60 |
+
<option value="2">In Progress</option>
|
| 61 |
+
<option value="3">Done</option>
|
| 62 |
+
<option value="overdue">Overdue</option>
|
| 63 |
+
<option value="blocked">Blocked</option>
|
| 64 |
+
</select>
|
| 65 |
+
</div>
|
| 66 |
+
<button @onclick="ApplyFilters" class="inline-flex items-center rounded-lg bg-violet-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-violet-700">
|
| 67 |
+
<i data-lucide="search" class="mr-2 h-4 w-4"></i> Search
|
| 68 |
+
</button>
|
| 69 |
+
<button @onclick="ResetFilters" class="inline-flex items-center rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">
|
| 70 |
+
<i data-lucide="rotate-ccw" class="mr-2 h-4 w-4"></i> Reset
|
| 71 |
+
</button>
|
| 72 |
+
</div>
|
| 73 |
+
</div>
|
| 74 |
+
|
| 75 |
+
@if (isLoading)
|
| 76 |
+
{
|
| 77 |
+
<LoadingSpinner Message="Loading issue report..." />
|
| 78 |
+
}
|
| 79 |
+
else
|
| 80 |
+
{
|
| 81 |
+
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
| 82 |
+
@MetricCard("Total Issues", FilteredIssues.Count().ToString(), "file-pen-line", "violet")
|
| 83 |
+
@MetricCard("Open Issues", OpenIssues.ToString(), "list-todo", "blue")
|
| 84 |
+
@MetricCard("Overdue Issues", OverdueIssues.ToString(), "alarm-clock", "rose")
|
| 85 |
+
@MetricCard("Blocked Issues", BlockedIssues.ToString(), "ban", "amber")
|
| 86 |
+
</div>
|
| 87 |
+
|
| 88 |
+
<div class="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
|
| 89 |
+
<h3 class="text-sm font-semibold text-slate-900">Key Insights</h3>
|
| 90 |
+
<div class="mt-4 grid grid-cols-1 gap-3 md:grid-cols-3">
|
| 91 |
+
@foreach (var insight in Insights)
|
| 92 |
+
{
|
| 93 |
+
<div class="rounded-lg border border-slate-100 bg-slate-50 px-3 py-2 text-sm text-slate-700">@insight</div>
|
| 94 |
+
}
|
| 95 |
+
</div>
|
| 96 |
+
</div>
|
| 97 |
+
|
| 98 |
+
<div class="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
|
| 99 |
+
@if (isChartView)
|
| 100 |
+
{
|
| 101 |
+
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
|
| 102 |
+
@BarChart("Issues by Status", StatusChart)
|
| 103 |
+
@BarChart("Issues by Priority", PriorityChart)
|
| 104 |
+
@BarChart("Issues by Employee", EmployeeChart)
|
| 105 |
+
@BarChart("Issues by Project", ProjectChart)
|
| 106 |
+
</div>
|
| 107 |
+
}
|
| 108 |
+
else
|
| 109 |
+
{
|
| 110 |
+
<div class="overflow-x-auto">
|
| 111 |
+
<table class="w-full">
|
| 112 |
+
<thead>
|
| 113 |
+
<tr class="border-b border-slate-100 bg-slate-50/60">
|
| 114 |
+
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500">Issue</th>
|
| 115 |
+
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500">Task / Project</th>
|
| 116 |
+
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500">Assignee</th>
|
| 117 |
+
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500">Status</th>
|
| 118 |
+
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500">Hours</th>
|
| 119 |
+
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500">Due</th>
|
| 120 |
+
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500">Risk</th>
|
| 121 |
+
</tr>
|
| 122 |
+
</thead>
|
| 123 |
+
<tbody class="divide-y divide-slate-100">
|
| 124 |
+
@foreach (var issue in PagedIssues)
|
| 125 |
+
{
|
| 126 |
+
<tr class="align-top hover:bg-slate-50/50">
|
| 127 |
+
<td class="px-4 py-3">
|
| 128 |
+
<button @onclick="() => ToggleExpanded(issue.Id)" class="text-left text-sm font-semibold text-slate-900 hover:text-violet-600">@issue.Title</button>
|
| 129 |
+
@if (expandedIssues.Contains(issue.Id))
|
| 130 |
+
{
|
| 131 |
+
<div class="mt-2 rounded-lg bg-slate-50 p-3 text-xs text-slate-600">
|
| 132 |
+
<div>@(string.IsNullOrWhiteSpace(issue.Description) ? "No description" : issue.Description)</div>
|
| 133 |
+
<div class="mt-1">Delay: @(string.IsNullOrWhiteSpace(issue.DelayReason) ? "None recorded" : issue.DelayReason)</div>
|
| 134 |
+
<div>Blocked by: @(string.IsNullOrWhiteSpace(issue.BlockedBy) ? "None" : issue.BlockedBy)</div>
|
| 135 |
+
</div>
|
| 136 |
+
}
|
| 137 |
+
</td>
|
| 138 |
+
<td class="px-4 py-3 text-sm text-slate-600"><div class="font-medium text-slate-800">@issue.TaskTitle</div><div class="text-xs text-slate-400">@issue.ProjectName</div></td>
|
| 139 |
+
<td class="px-4 py-3 text-sm text-slate-600">@(issue.AssignedToName ?? "Unassigned")</td>
|
| 140 |
+
<td class="px-4 py-3"><span class="rounded-full px-2 py-0.5 text-xs font-medium @GetStatusBadge(issue.StatusId)">@GetStatusLabel(issue.StatusId)</span></td>
|
| 141 |
+
<td class="px-4 py-3 text-sm text-slate-700">@((issue.EstimatedHours ?? 0).ToString("N1")) / @((issue.ActualHours ?? 0).ToString("N1"))</td>
|
| 142 |
+
<td class="px-4 py-3 text-sm @(IsOverdue(issue) ? "font-semibold text-rose-600" : "text-slate-600")">@DisplayFormats.Date(issue.DueDate)</td>
|
| 143 |
+
<td class="px-4 py-3">@RiskBadge(issue)</td>
|
| 144 |
+
</tr>
|
| 145 |
+
}
|
| 146 |
+
@if (!PagedIssues.Any())
|
| 147 |
+
{
|
| 148 |
+
<EmptyState ColSpan="7" Icon="file-pen-line" Title="No issues found" Subtitle="No issues matched the current report filters." />
|
| 149 |
+
}
|
| 150 |
+
</tbody>
|
| 151 |
+
</table>
|
| 152 |
+
</div>
|
| 153 |
+
<Pagination CurrentPage="currentPage" TotalPages="TotalPages" PageSize="pageSize" TotalCount="FilteredIssues.Count()" OnPageChanged="HandlePageChanged" OnPageSizeChanged="HandlePageSizeChanged" />
|
| 154 |
+
}
|
| 155 |
+
</div>
|
| 156 |
+
}
|
| 157 |
+
</div>
|
| 158 |
+
|
| 159 |
+
@code {
|
| 160 |
+
private bool isLoading = true;
|
| 161 |
+
private bool isChartView;
|
| 162 |
+
private List<IssueDto> issues = new();
|
| 163 |
+
private List<ProjectDto> projects = new();
|
| 164 |
+
private HashSet<long> expandedIssues = new();
|
| 165 |
+
private string searchInput = string.Empty;
|
| 166 |
+
private string appliedSearch = string.Empty;
|
| 167 |
+
private long filterProjectId;
|
| 168 |
+
private long appliedProjectId;
|
| 169 |
+
private string filterStatus = "0";
|
| 170 |
+
private string appliedStatus = "0";
|
| 171 |
+
private int currentPage = 1;
|
| 172 |
+
private int pageSize = 10;
|
| 173 |
+
private const string StorageKey = "tts.report.issues.filters";
|
| 174 |
+
|
| 175 |
+
private IEnumerable<IssueDto> FilteredIssues => issues.Where(MatchesFilters).OrderBy(i => i.DueDate).ThenBy(i => i.Title);
|
| 176 |
+
private IEnumerable<IssueDto> PagedIssues => FilteredIssues.Skip((currentPage - 1) * pageSize).Take(pageSize);
|
| 177 |
+
private int TotalPages => (int)Math.Ceiling((double)FilteredIssues.Count() / pageSize);
|
| 178 |
+
private int OpenIssues => FilteredIssues.Count(i => i.StatusId != AppTaskStatus.Done);
|
| 179 |
+
private int OverdueIssues => FilteredIssues.Count(IsOverdue);
|
| 180 |
+
private int BlockedIssues => FilteredIssues.Count(i => i.IsBlocked);
|
| 181 |
+
|
| 182 |
+
protected override async Task OnInitializedAsync()
|
| 183 |
+
{
|
| 184 |
+
var client = ApiClient.CreateClient();
|
| 185 |
+
try
|
| 186 |
+
{
|
| 187 |
+
var issuesTask = client.GetFromJsonAsync<Result<List<IssueDto>>>("Report/issues", Serialization.CaseInsensitive);
|
| 188 |
+
var projectsTask = client.GetFromJsonAsync<List<ProjectDto>>("Project", Serialization.CaseInsensitive);
|
| 189 |
+
await Task.WhenAll(issuesTask, projectsTask);
|
| 190 |
+
issues = issuesTask.Result?.Value ?? new();
|
| 191 |
+
projects = projectsTask.Result ?? new();
|
| 192 |
+
}
|
| 193 |
+
finally
|
| 194 |
+
{
|
| 195 |
+
isLoading = false;
|
| 196 |
+
}
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
protected override async Task OnAfterRenderAsync(bool firstRender)
|
| 200 |
+
{
|
| 201 |
+
await JS.InvokeVoidAsync("initIcons");
|
| 202 |
+
if (firstRender)
|
| 203 |
+
{
|
| 204 |
+
await LoadSavedFilters();
|
| 205 |
+
}
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
private bool MatchesFilters(IssueDto issue)
|
| 209 |
+
{
|
| 210 |
+
if (appliedProjectId > 0 && issue.ProjectId != appliedProjectId) return false;
|
| 211 |
+
if (appliedStatus == "overdue" && !IsOverdue(issue)) return false;
|
| 212 |
+
if (appliedStatus == "blocked" && !issue.IsBlocked) return false;
|
| 213 |
+
if (int.TryParse(appliedStatus, out var statusValue) && statusValue > 0 && (int)issue.StatusId != statusValue) return false;
|
| 214 |
+
if (!string.IsNullOrWhiteSpace(appliedSearch))
|
| 215 |
+
{
|
| 216 |
+
var s = appliedSearch.Trim();
|
| 217 |
+
return issue.Title.Contains(s, StringComparison.OrdinalIgnoreCase)
|
| 218 |
+
|| issue.TaskTitle.Contains(s, StringComparison.OrdinalIgnoreCase)
|
| 219 |
+
|| issue.ProjectName.Contains(s, StringComparison.OrdinalIgnoreCase)
|
| 220 |
+
|| (issue.AssignedToName?.Contains(s, StringComparison.OrdinalIgnoreCase) == true);
|
| 221 |
+
}
|
| 222 |
+
return true;
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
private async Task ApplyFilters()
|
| 226 |
+
{
|
| 227 |
+
appliedSearch = searchInput;
|
| 228 |
+
appliedProjectId = filterProjectId;
|
| 229 |
+
appliedStatus = filterStatus;
|
| 230 |
+
currentPage = 1;
|
| 231 |
+
await SaveFilters();
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
private async Task ResetFilters()
|
| 235 |
+
{
|
| 236 |
+
searchInput = appliedSearch = string.Empty;
|
| 237 |
+
filterProjectId = appliedProjectId = 0;
|
| 238 |
+
filterStatus = appliedStatus = "0";
|
| 239 |
+
currentPage = 1;
|
| 240 |
+
await SaveFilters();
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
private void SetView(bool chartView) => isChartView = chartView;
|
| 244 |
+
private void ToggleExpanded(long id) { if (!expandedIssues.Remove(id)) expandedIssues.Add(id); }
|
| 245 |
+
private Task HandlePageChanged(int page) { currentPage = page; return Task.CompletedTask; }
|
| 246 |
+
private Task HandlePageSizeChanged(int size) { pageSize = size; currentPage = 1; return Task.CompletedTask; }
|
| 247 |
+
|
| 248 |
+
private async Task SaveFilters()
|
| 249 |
+
{
|
| 250 |
+
await JS.InvokeVoidAsync("localStorage.setItem", StorageKey, System.Text.Json.JsonSerializer.Serialize(new SavedFilter(appliedSearch, appliedProjectId, appliedStatus)));
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
private async Task LoadSavedFilters()
|
| 254 |
+
{
|
| 255 |
+
var json = await JS.InvokeAsync<string?>("localStorage.getItem", StorageKey);
|
| 256 |
+
if (string.IsNullOrWhiteSpace(json)) return;
|
| 257 |
+
var saved = System.Text.Json.JsonSerializer.Deserialize<SavedFilter>(json);
|
| 258 |
+
if (saved == null) return;
|
| 259 |
+
searchInput = appliedSearch = saved.Search;
|
| 260 |
+
filterProjectId = appliedProjectId = saved.ProjectId;
|
| 261 |
+
filterStatus = appliedStatus = saved.Status;
|
| 262 |
+
StateHasChanged();
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
private static bool IsOverdue(IssueDto issue) => issue.StatusId != AppTaskStatus.Done && issue.DueDate.Date < DateTime.Today;
|
| 266 |
+
private static string GetStatusLabel(AppTaskStatus status) => status == AppTaskStatus.InProgress ? "In Progress" : status == AppTaskStatus.Done ? "Done" : "To Do";
|
| 267 |
+
private static string GetStatusBadge(AppTaskStatus status) => status == AppTaskStatus.Done ? "bg-emerald-50 text-emerald-700" : status == AppTaskStatus.InProgress ? "bg-blue-50 text-blue-700" : "bg-slate-100 text-slate-700";
|
| 268 |
+
private static string GetPriorityLabel(TaskPriority priority) => priority == TaskPriority.High ? "High" : priority == TaskPriority.Medium ? "Medium" : "Low";
|
| 269 |
+
private static string EscalationLabel(int level) => level switch { 1 => "Team Lead", 2 => "PM", 3 => "Manager", _ => "None" };
|
| 270 |
+
|
| 271 |
+
private RenderFragment RiskBadge(IssueDto issue) => builder =>
|
| 272 |
+
{
|
| 273 |
+
var text = issue.IsBlocked ? "Blocked" : issue.EscalationLevel > 0 ? EscalationLabel(issue.EscalationLevel) : IsOverdue(issue) ? "Overdue" : "Clear";
|
| 274 |
+
var cls = issue.IsBlocked || IsOverdue(issue) ? "bg-rose-50 text-rose-700" : issue.EscalationLevel > 0 ? "bg-amber-50 text-amber-700" : "bg-emerald-50 text-emerald-700";
|
| 275 |
+
builder.OpenElement(0, "span");
|
| 276 |
+
builder.AddAttribute(1, "class", $"rounded-full px-2 py-0.5 text-xs font-semibold {cls}");
|
| 277 |
+
builder.AddContent(2, text);
|
| 278 |
+
builder.CloseElement();
|
| 279 |
+
};
|
| 280 |
+
|
| 281 |
+
private IReadOnlyList<(string Label, int Value)> StatusChart => new[] { ("To Do", FilteredIssues.Count(i => i.StatusId == AppTaskStatus.Todo)), ("In Progress", FilteredIssues.Count(i => i.StatusId == AppTaskStatus.InProgress)), ("Done", FilteredIssues.Count(i => i.StatusId == AppTaskStatus.Done)), ("Overdue", OverdueIssues) };
|
| 282 |
+
private IReadOnlyList<(string Label, int Value)> PriorityChart => Enum.GetValues<TaskPriority>().Select(p => (GetPriorityLabel(p), FilteredIssues.Count(i => i.PriorityId == p))).ToList();
|
| 283 |
+
private IReadOnlyList<(string Label, int Value)> EmployeeChart => FilteredIssues.GroupBy(i => i.AssignedToName ?? "Unassigned").OrderByDescending(g => g.Count()).Take(5).Select(g => (g.Key, g.Count())).ToList();
|
| 284 |
+
private IReadOnlyList<(string Label, int Value)> ProjectChart => FilteredIssues.GroupBy(i => i.ProjectName).OrderByDescending(g => g.Count()).Take(5).Select(g => (g.Key, g.Count())).ToList();
|
| 285 |
+
private IEnumerable<string> Insights => new[] { $"{OverdueIssues} overdue issues need attention.", $"{BlockedIssues} issues are blocked.", $"Actual hours are {FilteredIssues.Sum(i => i.ActualHours ?? 0):N1} against {FilteredIssues.Sum(i => i.EstimatedHours ?? 0):N1} estimated." };
|
| 286 |
+
|
| 287 |
+
private RenderFragment MetricCard(string label, string value, string icon, string color) => builder =>
|
| 288 |
+
{
|
| 289 |
+
builder.OpenElement(0, "div");
|
| 290 |
+
builder.AddAttribute(1, "class", $"rounded-xl border border-{color}-100 bg-{color}-50 p-5 shadow-sm");
|
| 291 |
+
builder.AddMarkupContent(2, $"<div class=\"flex items-center justify-between\"><div><p class=\"text-xs font-semibold uppercase tracking-wider text-{color}-600\">{label}</p><p class=\"mt-1 text-3xl font-bold text-{color}-700\">{value}</p></div><span class=\"flex h-11 w-11 items-center justify-center rounded-xl bg-white/70\"><i data-lucide=\"{icon}\" class=\"h-6 w-6 text-{color}-600\"></i></span></div>");
|
| 292 |
+
builder.CloseElement();
|
| 293 |
+
};
|
| 294 |
+
|
| 295 |
+
private RenderFragment BarChart(string title, IReadOnlyList<(string Label, int Value)> rows) => builder =>
|
| 296 |
+
{
|
| 297 |
+
var max = Math.Max(1, rows.Select(r => r.Value).DefaultIfEmpty(0).Max());
|
| 298 |
+
builder.OpenElement(0, "div");
|
| 299 |
+
builder.AddAttribute(1, "class", "rounded-xl border border-slate-100 bg-slate-50 p-4");
|
| 300 |
+
builder.AddMarkupContent(2, $"<h3 class=\"mb-4 text-sm font-semibold text-slate-900\">{title}</h3>");
|
| 301 |
+
var seq = 3;
|
| 302 |
+
foreach (var row in rows)
|
| 303 |
+
{
|
| 304 |
+
var width = row.Value * 100 / max;
|
| 305 |
+
builder.AddMarkupContent(seq++, $"<div class=\"mb-3\"><div class=\"mb-1 flex justify-between text-xs font-semibold text-slate-600\"><span>{row.Label}</span><span>{row.Value}</span></div><div class=\"h-2.5 rounded-full bg-white\"><div class=\"h-full rounded-full bg-violet-600\" style=\"width:{width}%\"></div></div></div>");
|
| 306 |
+
}
|
| 307 |
+
builder.CloseElement();
|
| 308 |
+
};
|
| 309 |
+
|
| 310 |
+
private async Task DownloadExcel()
|
| 311 |
+
{
|
| 312 |
+
var rows = FilteredIssues.Select(i => new object?[] { i.Title, i.TaskTitle, i.ProjectName, i.AssignedToName ?? "Unassigned", GetStatusLabel(i.StatusId), GetPriorityLabel(i.PriorityId), i.EstimatedHours ?? 0, i.ActualHours ?? 0, i.DueDate, i.IsBlocked ? "Blocked" : "Clear", EscalationLabel(i.EscalationLevel), i.DelayReason ?? "" });
|
| 313 |
+
var bytes = SpreadsheetReportBuilder.BuildTableWorkbook("Issue Report", "Issue Report", new[] { "Issue", "Task", "Project", "Assignee", "Status", "Priority", "Estimated", "Actual", "Due Date", "Blocked", "Escalation", "Delay Reason" }, rows, new[] { $"Generated: {DisplayFormats.Date(DateTime.Today)}" });
|
| 314 |
+
await JS.InvokeVoidAsync("downloadFileFromBase64", $"IssueReport_{DateTime.Today:yyyyMMdd}.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", Convert.ToBase64String(bytes));
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
private async Task DownloadPdf()
|
| 318 |
+
{
|
| 319 |
+
var rows = FilteredIssues.Select(i => new[] { i.Title, i.TaskTitle, i.ProjectName, i.AssignedToName ?? "Unassigned", GetStatusLabel(i.StatusId), DisplayFormats.Date(i.DueDate), i.IsBlocked ? "Blocked" : "Clear" });
|
| 320 |
+
var bytes = SimplePdfReportBuilder.BuildTableReport("Issue Report", new[] { $"Generated: {DisplayFormats.Date(DateTime.Today)}" }, new[] { "Issue", "Task", "Project", "Assignee", "Status", "Due Date", "Risk" }, rows);
|
| 321 |
+
await JS.InvokeVoidAsync("downloadFileFromBase64", $"IssueReport_{DateTime.Today:yyyyMMdd}.pdf", "application/pdf", Convert.ToBase64String(bytes));
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
private sealed record SavedFilter(string Search, long ProjectId, string Status);
|
| 325 |
+
}
|
TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/OverdueTasksReport.razor
CHANGED
|
@@ -51,7 +51,7 @@
|
|
| 51 |
<div class="rounded-xl border border-amber-100 bg-amber-50 p-5 shadow-sm">
|
| 52 |
<div class="flex items-center justify-between">
|
| 53 |
<div>
|
| 54 |
-
<p class="text-xs font-semibold uppercase tracking-wider text-amber-500">
|
| 55 |
<p class="mt-1 text-3xl font-bold text-amber-700">@criticalCount</p>
|
| 56 |
</div>
|
| 57 |
<span class="flex h-11 w-11 items-center justify-center rounded-xl bg-amber-100">
|
|
@@ -151,8 +151,8 @@
|
|
| 151 |
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Assignee</th>
|
| 152 |
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Due Date</th>
|
| 153 |
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Days Overdue</th>
|
| 154 |
-
|
| 155 |
-
|
| 156 |
</tr>
|
| 157 |
</thead>
|
| 158 |
<tbody class="divide-y divide-slate-100">
|
|
@@ -215,26 +215,13 @@
|
|
| 215 |
</span>
|
| 216 |
}
|
| 217 |
</td>
|
|
|
|
|
|
|
|
|
|
| 218 |
<td class="px-6 py-4">
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
"Medium" => "bg-amber-50 text-amber-700",
|
| 223 |
-
_ => "bg-slate-100 text-slate-600"
|
| 224 |
-
};
|
| 225 |
-
}
|
| 226 |
-
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium @priorityClass">@task.PriorityName</span>
|
| 227 |
-
</td>
|
| 228 |
-
<td class="px-6 py-4">
|
| 229 |
-
@{
|
| 230 |
-
var statusClass = task.StatusName switch {
|
| 231 |
-
"In Progress" => "bg-blue-50 text-blue-700",
|
| 232 |
-
"Done" => "bg-emerald-50 text-emerald-700",
|
| 233 |
-
_ => "bg-slate-100 text-slate-600"
|
| 234 |
-
};
|
| 235 |
-
}
|
| 236 |
-
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium @statusClass">@task.StatusName</span>
|
| 237 |
-
</td>
|
| 238 |
</tr>
|
| 239 |
}
|
| 240 |
}
|
|
@@ -320,7 +307,7 @@
|
|
| 320 |
var now = DateTime.UtcNow;
|
| 321 |
|
| 322 |
overdueCount = allOverdue.Count(t => t.DueDate < now);
|
| 323 |
-
criticalCount = allOverdue.Count(t => t.PriorityName == "High");
|
| 324 |
dueSoonCount = allOverdue.Count(t => t.DueDate >= now && t.DueDate <= now.AddDays(3));
|
| 325 |
avgDaysOverdue = overdueCount > 0
|
| 326 |
? (int)allOverdue.Where(t => t.DueDate < now).Average(t => t.DaysOverdue)
|
|
@@ -443,4 +430,12 @@
|
|
| 443 |
"application/pdf",
|
| 444 |
Convert.ToBase64String(pdfBytes));
|
| 445 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 446 |
}
|
|
|
|
| 51 |
<div class="rounded-xl border border-amber-100 bg-amber-50 p-5 shadow-sm">
|
| 52 |
<div class="flex items-center justify-between">
|
| 53 |
<div>
|
| 54 |
+
<p class="text-xs font-semibold uppercase tracking-wider text-amber-500">High Priority Overdue</p>
|
| 55 |
<p class="mt-1 text-3xl font-bold text-amber-700">@criticalCount</p>
|
| 56 |
</div>
|
| 57 |
<span class="flex h-11 w-11 items-center justify-center rounded-xl bg-amber-100">
|
|
|
|
| 151 |
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Assignee</th>
|
| 152 |
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Due Date</th>
|
| 153 |
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Days Overdue</th>
|
| 154 |
+
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Issues</th>
|
| 155 |
+
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Delay / Escalation</th>
|
| 156 |
</tr>
|
| 157 |
</thead>
|
| 158 |
<tbody class="divide-y divide-slate-100">
|
|
|
|
| 215 |
</span>
|
| 216 |
}
|
| 217 |
</td>
|
| 218 |
+
<td class="px-6 py-4 text-sm text-slate-700">
|
| 219 |
+
<span class="font-semibold text-rose-600">@task.OverdueIssues</span> overdue
|
| 220 |
+
</td>
|
| 221 |
<td class="px-6 py-4">
|
| 222 |
+
<div class="text-sm font-medium text-slate-800">@(string.IsNullOrWhiteSpace(task.DelayReason) ? "No reason recorded" : task.DelayReason)</div>
|
| 223 |
+
<div class="mt-1 text-xs text-slate-500">Blocked by: @(string.IsNullOrWhiteSpace(task.BlockedBy) ? "None" : task.BlockedBy) · Escalation: @GetEscalationLabel(task.EscalationLevel)</div>
|
| 224 |
+
</td>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
</tr>
|
| 226 |
}
|
| 227 |
}
|
|
|
|
| 307 |
var now = DateTime.UtcNow;
|
| 308 |
|
| 309 |
overdueCount = allOverdue.Count(t => t.DueDate < now);
|
| 310 |
+
criticalCount = allOverdue.Count(t => t.PriorityName == "High" && t.DaysOverdue > 0);
|
| 311 |
dueSoonCount = allOverdue.Count(t => t.DueDate >= now && t.DueDate <= now.AddDays(3));
|
| 312 |
avgDaysOverdue = overdueCount > 0
|
| 313 |
? (int)allOverdue.Where(t => t.DueDate < now).Average(t => t.DaysOverdue)
|
|
|
|
| 430 |
"application/pdf",
|
| 431 |
Convert.ToBase64String(pdfBytes));
|
| 432 |
}
|
| 433 |
+
|
| 434 |
+
private static string GetEscalationLabel(int level) => level switch
|
| 435 |
+
{
|
| 436 |
+
1 => "Team Lead",
|
| 437 |
+
2 => "PM",
|
| 438 |
+
3 => "Manager",
|
| 439 |
+
_ => "None"
|
| 440 |
+
};
|
| 441 |
}
|
TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/ProjectProgressReport.razor
CHANGED
|
@@ -19,7 +19,7 @@
|
|
| 19 |
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
| 20 |
<div>
|
| 21 |
<h2 class="text-3xl font-bold tracking-tight text-slate-900">Project Progress</h2>
|
| 22 |
-
<p class="text-slate-500 mt-1">View project
|
| 23 |
</div>
|
| 24 |
<div class="flex items-center space-x-3">
|
| 25 |
<div class="inline-flex rounded-lg border border-slate-200 bg-white p-0.5 shadow-sm">
|
|
@@ -154,7 +154,7 @@
|
|
| 154 |
{
|
| 155 |
<!-- Chart View: SVG Project Progress Bars -->
|
| 156 |
<div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
| 157 |
-
<h3 class="font-semibold text-slate-900 mb-4 font-sans text-md">Project
|
| 158 |
@if (!allProjects.Any())
|
| 159 |
{
|
| 160 |
<p class="text-slate-400 text-center py-8">No data available.</p>
|
|
@@ -167,10 +167,10 @@
|
|
| 167 |
<div>
|
| 168 |
<div class="flex justify-between text-xs font-semibold text-slate-700 mb-1.5">
|
| 169 |
<span>@proj.ProjectName</span>
|
| 170 |
-
<span>@proj.
|
| 171 |
</div>
|
| 172 |
<div class="h-4 rounded-full bg-slate-100 overflow-hidden flex">
|
| 173 |
-
<div class="h-full bg-emerald-500 rounded-full transition-all" style="width: @(Math.Min(proj.
|
| 174 |
</div>
|
| 175 |
</div>
|
| 176 |
}
|
|
@@ -189,9 +189,10 @@
|
|
| 189 |
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Project Name</th>
|
| 190 |
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Start Date</th>
|
| 191 |
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">End Date</th>
|
| 192 |
-
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
| 193 |
-
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
| 194 |
-
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
|
|
|
| 195 |
</tr>
|
| 196 |
</thead>
|
| 197 |
<tbody class="divide-y divide-slate-100">
|
|
@@ -205,23 +206,17 @@
|
|
| 205 |
<td class="px-6 py-4 text-sm font-semibold text-slate-900">@proj.ProjectName</td>
|
| 206 |
<td class="px-6 py-4 text-sm text-slate-600">@DisplayFormats.Date(proj.StartDate)</td>
|
| 207 |
<td class="px-6 py-4 text-sm text-slate-600">@DisplayFormats.Date(proj.EndDate)</td>
|
| 208 |
-
<td class="px-6 py-4 text-sm text-slate-900">@proj.
|
| 209 |
-
<td class="px-6 py-4 text-sm text-
|
| 210 |
-
<td class="px-6 py-4">
|
| 211 |
-
|
| 212 |
-
<div class="flex-1 h-2.5 rounded-full bg-slate-100 overflow-hidden max-w-[120px]">
|
| 213 |
-
<div class="h-full rounded-full bg-emerald-500 transition-all" style="width: @(Math.Min(proj.Progress, 100))%"></div>
|
| 214 |
-
</div>
|
| 215 |
-
<span class="text-sm font-semibold text-slate-700">@proj.Progress.ToString("F0")%</span>
|
| 216 |
-
</div>
|
| 217 |
-
</td>
|
| 218 |
</tr>
|
| 219 |
}
|
| 220 |
}
|
| 221 |
else
|
| 222 |
{
|
| 223 |
<tr>
|
| 224 |
-
<td colspan="
|
| 225 |
<p class="text-sm text-slate-400">No project data found matching criteria.</p>
|
| 226 |
</td>
|
| 227 |
</tr>
|
|
@@ -398,15 +393,17 @@
|
|
| 398 |
proj.ProjectName,
|
| 399 |
proj.StartDate,
|
| 400 |
proj.EndDate,
|
| 401 |
-
proj.
|
| 402 |
-
proj.
|
| 403 |
-
|
|
|
|
|
|
|
| 404 |
});
|
| 405 |
|
| 406 |
var workbookBytes = SpreadsheetReportBuilder.BuildTableWorkbook(
|
| 407 |
"Project Progress",
|
| 408 |
"Project Progress Report",
|
| 409 |
-
new[] { "Project", "Start Date", "End Date", "
|
| 410 |
rows,
|
| 411 |
new[]
|
| 412 |
{
|
|
@@ -438,15 +435,17 @@
|
|
| 438 |
proj.ProjectName,
|
| 439 |
DisplayFormats.Date(proj.StartDate),
|
| 440 |
DisplayFormats.Date(proj.EndDate),
|
| 441 |
-
proj.
|
| 442 |
-
proj.
|
| 443 |
-
|
|
|
|
|
|
|
| 444 |
});
|
| 445 |
|
| 446 |
var pdfBytes = SimplePdfReportBuilder.BuildTableReport(
|
| 447 |
"Project Progress Report",
|
| 448 |
summaryLines,
|
| 449 |
-
new[] { "Project", "Start Date", "End Date", "
|
| 450 |
rows);
|
| 451 |
|
| 452 |
await JS.InvokeVoidAsync(
|
|
@@ -455,4 +454,11 @@
|
|
| 455 |
"application/pdf",
|
| 456 |
Convert.ToBase64String(pdfBytes));
|
| 457 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 458 |
}
|
|
|
|
| 19 |
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
| 20 |
<div>
|
| 21 |
<h2 class="text-3xl font-bold tracking-tight text-slate-900">Project Progress</h2>
|
| 22 |
+
<p class="text-slate-500 mt-1">View project health using both planning tasks and daily execution issues.</p>
|
| 23 |
</div>
|
| 24 |
<div class="flex items-center space-x-3">
|
| 25 |
<div class="inline-flex rounded-lg border border-slate-200 bg-white p-0.5 shadow-sm">
|
|
|
|
| 154 |
{
|
| 155 |
<!-- Chart View: SVG Project Progress Bars -->
|
| 156 |
<div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
| 157 |
+
<h3 class="font-semibold text-slate-900 mb-4 font-sans text-md">Project Health Chart</h3>
|
| 158 |
@if (!allProjects.Any())
|
| 159 |
{
|
| 160 |
<p class="text-slate-400 text-center py-8">No data available.</p>
|
|
|
|
| 167 |
<div>
|
| 168 |
<div class="flex justify-between text-xs font-semibold text-slate-700 mb-1.5">
|
| 169 |
<span>@proj.ProjectName</span>
|
| 170 |
+
<span>@proj.OpenIssues open, @proj.OverdueIssues overdue, @proj.BlockedIssues blocked</span>
|
| 171 |
</div>
|
| 172 |
<div class="h-4 rounded-full bg-slate-100 overflow-hidden flex">
|
| 173 |
+
<div class="h-full @(proj.ProjectHealth == "Critical" ? "bg-rose-500" : proj.ProjectHealth == "At Risk" ? "bg-amber-500" : "bg-emerald-500") rounded-full transition-all" style="width: @(Math.Min(Math.Max(proj.OpenIssues + proj.OverdueIssues + proj.BlockedIssues, 1) * 10, 100))%"></div>
|
| 174 |
</div>
|
| 175 |
</div>
|
| 176 |
}
|
|
|
|
| 189 |
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Project Name</th>
|
| 190 |
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Start Date</th>
|
| 191 |
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">End Date</th>
|
| 192 |
+
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Open Issues</th>
|
| 193 |
+
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Blocked / Overdue</th>
|
| 194 |
+
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Health</th>
|
| 195 |
+
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Last Activity</th>
|
| 196 |
</tr>
|
| 197 |
</thead>
|
| 198 |
<tbody class="divide-y divide-slate-100">
|
|
|
|
| 206 |
<td class="px-6 py-4 text-sm font-semibold text-slate-900">@proj.ProjectName</td>
|
| 207 |
<td class="px-6 py-4 text-sm text-slate-600">@DisplayFormats.Date(proj.StartDate)</td>
|
| 208 |
<td class="px-6 py-4 text-sm text-slate-600">@DisplayFormats.Date(proj.EndDate)</td>
|
| 209 |
+
<td class="px-6 py-4 text-sm text-slate-900">@proj.OpenIssues</td>
|
| 210 |
+
<td class="px-6 py-4 text-sm font-medium"><span class="text-amber-600">@proj.BlockedIssues</span> / <span class="text-rose-600">@proj.OverdueIssues</span></td>
|
| 211 |
+
<td class="px-6 py-4"><span class="inline-flex rounded-full px-2.5 py-0.5 text-xs font-semibold @GetProjectHealthBadge(proj.ProjectHealth)">@proj.ProjectHealth</span></td>
|
| 212 |
+
<td class="px-6 py-4 text-sm text-slate-600">@DisplayFormats.Date(proj.LastActivity)</td>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
</tr>
|
| 214 |
}
|
| 215 |
}
|
| 216 |
else
|
| 217 |
{
|
| 218 |
<tr>
|
| 219 |
+
<td colspan="8" class="px-6 py-12 text-center">
|
| 220 |
<p class="text-sm text-slate-400">No project data found matching criteria.</p>
|
| 221 |
</td>
|
| 222 |
</tr>
|
|
|
|
| 393 |
proj.ProjectName,
|
| 394 |
proj.StartDate,
|
| 395 |
proj.EndDate,
|
| 396 |
+
proj.OpenIssues,
|
| 397 |
+
proj.OverdueIssues,
|
| 398 |
+
proj.BlockedIssues,
|
| 399 |
+
proj.ProjectHealth,
|
| 400 |
+
proj.ActualHours
|
| 401 |
});
|
| 402 |
|
| 403 |
var workbookBytes = SpreadsheetReportBuilder.BuildTableWorkbook(
|
| 404 |
"Project Progress",
|
| 405 |
"Project Progress Report",
|
| 406 |
+
new[] { "Project", "Start Date", "End Date", "Open Issues", "Overdue Issues", "Blocked Issues", "Health", "Actual Hours" },
|
| 407 |
rows,
|
| 408 |
new[]
|
| 409 |
{
|
|
|
|
| 435 |
proj.ProjectName,
|
| 436 |
DisplayFormats.Date(proj.StartDate),
|
| 437 |
DisplayFormats.Date(proj.EndDate),
|
| 438 |
+
proj.OpenIssues.ToString(),
|
| 439 |
+
proj.OverdueIssues.ToString(),
|
| 440 |
+
proj.BlockedIssues.ToString(),
|
| 441 |
+
proj.ProjectHealth,
|
| 442 |
+
proj.ActualHours.ToString("N1")
|
| 443 |
});
|
| 444 |
|
| 445 |
var pdfBytes = SimplePdfReportBuilder.BuildTableReport(
|
| 446 |
"Project Progress Report",
|
| 447 |
summaryLines,
|
| 448 |
+
new[] { "Project", "Start Date", "End Date", "Open", "Overdue", "Blocked", "Health", "Hours" },
|
| 449 |
rows);
|
| 450 |
|
| 451 |
await JS.InvokeVoidAsync(
|
|
|
|
| 454 |
"application/pdf",
|
| 455 |
Convert.ToBase64String(pdfBytes));
|
| 456 |
}
|
| 457 |
+
|
| 458 |
+
private static string GetProjectHealthBadge(string health) => health switch
|
| 459 |
+
{
|
| 460 |
+
"Critical" => "bg-rose-50 text-rose-700",
|
| 461 |
+
"At Risk" => "bg-amber-50 text-amber-700",
|
| 462 |
+
_ => "bg-emerald-50 text-emerald-700"
|
| 463 |
+
};
|
| 464 |
}
|
TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/TaskReport.razor
CHANGED
|
@@ -152,8 +152,37 @@
|
|
| 152 |
</div>
|
| 153 |
</div>
|
| 154 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
else
|
| 156 |
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
@if (isChartView)
|
| 158 |
{
|
| 159 |
<!-- Chart View -->
|
|
@@ -250,6 +279,16 @@
|
|
| 250 |
}
|
| 251 |
</div>
|
| 252 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
</div>
|
| 254 |
}
|
| 255 |
else
|
|
@@ -264,8 +303,8 @@
|
|
| 264 |
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Project</th>
|
| 265 |
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Status</th>
|
| 266 |
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Priority</th>
|
| 267 |
-
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
| 268 |
-
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
| 269 |
</tr>
|
| 270 |
</thead>
|
| 271 |
<tbody class="divide-y divide-slate-100">
|
|
@@ -276,7 +315,9 @@
|
|
| 276 |
var t = entry.t;
|
| 277 |
<tr class="hover:bg-slate-50/50 transition-colors">
|
| 278 |
<td class="px-6 py-3 text-sm font-semibold text-slate-500">@((currentPage - 1) * pageSize + entry.index + 1)</td>
|
| 279 |
-
<td class="px-6 py-3
|
|
|
|
|
|
|
| 280 |
<td class="px-6 py-3 text-sm text-slate-500">@t.ProjectName</td>
|
| 281 |
<td class="px-6 py-3">
|
| 282 |
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium @GetStatusBadge(t.StatusId)">@t.StatusName</span>
|
|
@@ -284,9 +325,24 @@
|
|
| 284 |
<td class="px-6 py-3">
|
| 285 |
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium @GetPriorityBadge(t.PriorityId)">@t.PriorityName</span>
|
| 286 |
</td>
|
| 287 |
-
<td class="px-6 py-3
|
| 288 |
-
|
|
|
|
|
|
|
| 289 |
</tr>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
}
|
| 291 |
}
|
| 292 |
else
|
|
@@ -327,8 +383,10 @@
|
|
| 327 |
private bool isLoading = true;
|
| 328 |
private bool isChartView = false;
|
| 329 |
private List<TaskReportDto> taskReports = new();
|
|
|
|
| 330 |
private PagedResult<TaskReportDto>? taskReportPage;
|
| 331 |
private List<ProjectDto> projects = new();
|
|
|
|
| 332 |
|
| 333 |
// Filters
|
| 334 |
private string filterPeriod = "all";
|
|
@@ -399,6 +457,13 @@
|
|
| 399 |
|
| 400 |
private IEnumerable<TaskReportDto> PagedList => taskReportPage?.Items ?? Enumerable.Empty<TaskReportDto>();
|
| 401 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 402 |
protected override async Task OnInitializedAsync()
|
| 403 |
{
|
| 404 |
var client = ApiClient.CreateClient();
|
|
@@ -426,6 +491,14 @@
|
|
| 426 |
isChartView = chartView;
|
| 427 |
}
|
| 428 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 429 |
private void ShowProjectSuggestions()
|
| 430 |
{
|
| 431 |
projectSearchQuery = projectSearchInput;
|
|
@@ -489,6 +562,7 @@
|
|
| 489 |
var client = ApiClient.CreateClient();
|
| 490 |
try
|
| 491 |
{
|
|
|
|
| 492 |
var query = BuildTaskReportQuery();
|
| 493 |
var fullTask = client.GetFromJsonAsync<Result<List<TaskReportDto>>>(query);
|
| 494 |
var pagedTask = client.GetFromJsonAsync<PagedResult<TaskReportDto>>(BuildTaskReportQuery(includePaging: true));
|
|
@@ -498,6 +572,10 @@
|
|
| 498 |
{
|
| 499 |
taskReports = fullTask.Result.Value;
|
| 500 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 501 |
|
| 502 |
if (pagedTask.Result != null)
|
| 503 |
{
|
|
@@ -509,6 +587,9 @@
|
|
| 509 |
catch (Exception ex)
|
| 510 |
{
|
| 511 |
Console.WriteLine($"Task report error: {ex.Message}");
|
|
|
|
|
|
|
|
|
|
| 512 |
}
|
| 513 |
finally
|
| 514 |
{
|
|
@@ -616,4 +697,33 @@
|
|
| 616 |
TaskPriority.High => "bg-red-100 text-red-700",
|
| 617 |
_ => "bg-slate-100 text-slate-600"
|
| 618 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 619 |
}
|
|
|
|
| 152 |
</div>
|
| 153 |
</div>
|
| 154 |
}
|
| 155 |
+
else if (!string.IsNullOrWhiteSpace(errorMessage))
|
| 156 |
+
{
|
| 157 |
+
<div class="rounded-xl border border-rose-200 bg-rose-50 p-5 text-sm text-rose-700 shadow-sm">
|
| 158 |
+
<div class="flex items-start gap-3">
|
| 159 |
+
<i data-lucide="alert-triangle" class="mt-0.5 h-5 w-5 shrink-0"></i>
|
| 160 |
+
<div>
|
| 161 |
+
<p class="font-semibold">Task report could not load.</p>
|
| 162 |
+
<p class="mt-1">@errorMessage</p>
|
| 163 |
+
</div>
|
| 164 |
+
</div>
|
| 165 |
+
</div>
|
| 166 |
+
}
|
| 167 |
else
|
| 168 |
{
|
| 169 |
+
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
|
| 170 |
+
@TaskMetricCard("Total", FilteredList.Count().ToString(), "file-text", "violet")
|
| 171 |
+
@TaskMetricCard("Completed", FilteredList.Count(t => t.StatusId == AppTaskStatus.Done).ToString(), "check-circle-2", "emerald")
|
| 172 |
+
@TaskMetricCard("In Progress", FilteredList.Count(t => t.StatusId == AppTaskStatus.InProgress).ToString(), "timer", "blue")
|
| 173 |
+
@TaskMetricCard("Overdue", FilteredList.Count(t => t.DueDate.Date < DateTime.Today && t.StatusId != AppTaskStatus.Done).ToString(), "alarm-clock", "rose")
|
| 174 |
+
</div>
|
| 175 |
+
|
| 176 |
+
<div class="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
|
| 177 |
+
<h3 class="text-sm font-semibold text-slate-900">Key Insights</h3>
|
| 178 |
+
<div class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-3">
|
| 179 |
+
@foreach (var insight in TaskInsights)
|
| 180 |
+
{
|
| 181 |
+
<div class="rounded-lg bg-slate-50 px-3 py-2 text-sm text-slate-700">@insight</div>
|
| 182 |
+
}
|
| 183 |
+
</div>
|
| 184 |
+
</div>
|
| 185 |
+
|
| 186 |
@if (isChartView)
|
| 187 |
{
|
| 188 |
<!-- Chart View -->
|
|
|
|
| 279 |
}
|
| 280 |
</div>
|
| 281 |
</div>
|
| 282 |
+
|
| 283 |
+
<div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
| 284 |
+
<h3 class="font-semibold text-slate-900 mb-4">Tasks by Project</h3>
|
| 285 |
+
@MiniBars(FilteredList.GroupBy(t => t.ProjectName).OrderByDescending(g => g.Count()).Take(5).Select(g => (g.Key, g.Count())).ToArray())
|
| 286 |
+
</div>
|
| 287 |
+
|
| 288 |
+
<div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
| 289 |
+
<h3 class="font-semibold text-slate-900 mb-4">Tasks with Most Issues</h3>
|
| 290 |
+
@MiniBars(FilteredList.OrderByDescending(t => t.OpenIssues + t.OverdueIssues).Take(5).Select(t => (t.Title, t.OpenIssues + t.OverdueIssues)).ToArray())
|
| 291 |
+
</div>
|
| 292 |
</div>
|
| 293 |
}
|
| 294 |
else
|
|
|
|
| 303 |
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Project</th>
|
| 304 |
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Status</th>
|
| 305 |
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Priority</th>
|
| 306 |
+
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Health</th>
|
| 307 |
+
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Issues</th>
|
| 308 |
</tr>
|
| 309 |
</thead>
|
| 310 |
<tbody class="divide-y divide-slate-100">
|
|
|
|
| 315 |
var t = entry.t;
|
| 316 |
<tr class="hover:bg-slate-50/50 transition-colors">
|
| 317 |
<td class="px-6 py-3 text-sm font-semibold text-slate-500">@((currentPage - 1) * pageSize + entry.index + 1)</td>
|
| 318 |
+
<td class="px-6 py-3">
|
| 319 |
+
<button @onclick="() => ToggleExpanded(t.TaskId)" class="text-left text-sm font-medium text-slate-900 hover:text-violet-600">@t.Title</button>
|
| 320 |
+
</td>
|
| 321 |
<td class="px-6 py-3 text-sm text-slate-500">@t.ProjectName</td>
|
| 322 |
<td class="px-6 py-3">
|
| 323 |
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium @GetStatusBadge(t.StatusId)">@t.StatusName</span>
|
|
|
|
| 325 |
<td class="px-6 py-3">
|
| 326 |
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium @GetPriorityBadge(t.PriorityId)">@t.PriorityName</span>
|
| 327 |
</td>
|
| 328 |
+
<td class="px-6 py-3">
|
| 329 |
+
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold @GetHealthBadge(t.TaskHealth)">@t.TaskHealth</span>
|
| 330 |
+
</td>
|
| 331 |
+
<td class="px-6 py-3 text-sm text-slate-600">@t.IssueSummary</td>
|
| 332 |
</tr>
|
| 333 |
+
@if (expandedTasks.Contains(t.TaskId))
|
| 334 |
+
{
|
| 335 |
+
<tr class="bg-slate-50/60">
|
| 336 |
+
<td colspan="7" class="px-6 py-4">
|
| 337 |
+
<div class="grid grid-cols-1 gap-3 text-sm text-slate-600 md:grid-cols-4">
|
| 338 |
+
<div><span class="font-semibold text-slate-800">Assignee:</span> @(t.AssignedToUser ?? "Unassigned")</div>
|
| 339 |
+
<div><span class="font-semibold text-slate-800">Due:</span> @DisplayFormats.Date(t.DueDate)</div>
|
| 340 |
+
<div><span class="font-semibold text-slate-800">Open issues:</span> @t.OpenIssues</div>
|
| 341 |
+
<div><span class="font-semibold text-slate-800">Last activity:</span> @DisplayFormats.Date(t.LastActivity)</div>
|
| 342 |
+
</div>
|
| 343 |
+
</td>
|
| 344 |
+
</tr>
|
| 345 |
+
}
|
| 346 |
}
|
| 347 |
}
|
| 348 |
else
|
|
|
|
| 383 |
private bool isLoading = true;
|
| 384 |
private bool isChartView = false;
|
| 385 |
private List<TaskReportDto> taskReports = new();
|
| 386 |
+
private HashSet<long> expandedTasks = new();
|
| 387 |
private PagedResult<TaskReportDto>? taskReportPage;
|
| 388 |
private List<ProjectDto> projects = new();
|
| 389 |
+
private string? errorMessage;
|
| 390 |
|
| 391 |
// Filters
|
| 392 |
private string filterPeriod = "all";
|
|
|
|
| 457 |
|
| 458 |
private IEnumerable<TaskReportDto> PagedList => taskReportPage?.Items ?? Enumerable.Empty<TaskReportDto>();
|
| 459 |
|
| 460 |
+
private IEnumerable<string> TaskInsights => new[]
|
| 461 |
+
{
|
| 462 |
+
$"{FilteredList.Count(t => t.TaskHealth == "Critical")} critical tasks need attention.",
|
| 463 |
+
$"{FilteredList.Sum(t => t.OpenIssues)} open issues are attached to reported tasks.",
|
| 464 |
+
$"{FilteredList.Count(t => t.OverdueIssues > 0)} tasks have overdue issues."
|
| 465 |
+
};
|
| 466 |
+
|
| 467 |
protected override async Task OnInitializedAsync()
|
| 468 |
{
|
| 469 |
var client = ApiClient.CreateClient();
|
|
|
|
| 491 |
isChartView = chartView;
|
| 492 |
}
|
| 493 |
|
| 494 |
+
private void ToggleExpanded(long taskId)
|
| 495 |
+
{
|
| 496 |
+
if (!expandedTasks.Remove(taskId))
|
| 497 |
+
{
|
| 498 |
+
expandedTasks.Add(taskId);
|
| 499 |
+
}
|
| 500 |
+
}
|
| 501 |
+
|
| 502 |
private void ShowProjectSuggestions()
|
| 503 |
{
|
| 504 |
projectSearchQuery = projectSearchInput;
|
|
|
|
| 562 |
var client = ApiClient.CreateClient();
|
| 563 |
try
|
| 564 |
{
|
| 565 |
+
errorMessage = null;
|
| 566 |
var query = BuildTaskReportQuery();
|
| 567 |
var fullTask = client.GetFromJsonAsync<Result<List<TaskReportDto>>>(query);
|
| 568 |
var pagedTask = client.GetFromJsonAsync<PagedResult<TaskReportDto>>(BuildTaskReportQuery(includePaging: true));
|
|
|
|
| 572 |
{
|
| 573 |
taskReports = fullTask.Result.Value;
|
| 574 |
}
|
| 575 |
+
else if (fullTask.Result?.IsSuccess == false)
|
| 576 |
+
{
|
| 577 |
+
errorMessage = fullTask.Result.ErrorMessage ?? "The server returned an error while loading task report data.";
|
| 578 |
+
}
|
| 579 |
|
| 580 |
if (pagedTask.Result != null)
|
| 581 |
{
|
|
|
|
| 587 |
catch (Exception ex)
|
| 588 |
{
|
| 589 |
Console.WriteLine($"Task report error: {ex.Message}");
|
| 590 |
+
errorMessage = ex.Message;
|
| 591 |
+
taskReports = new();
|
| 592 |
+
taskReportPage = null;
|
| 593 |
}
|
| 594 |
finally
|
| 595 |
{
|
|
|
|
| 697 |
TaskPriority.High => "bg-red-100 text-red-700",
|
| 698 |
_ => "bg-slate-100 text-slate-600"
|
| 699 |
};
|
| 700 |
+
|
| 701 |
+
private static string GetHealthBadge(string health) => health switch
|
| 702 |
+
{
|
| 703 |
+
"Critical" => "bg-rose-50 text-rose-700",
|
| 704 |
+
"At Risk" => "bg-amber-50 text-amber-700",
|
| 705 |
+
_ => "bg-emerald-50 text-emerald-700"
|
| 706 |
+
};
|
| 707 |
+
|
| 708 |
+
private RenderFragment TaskMetricCard(string label, string value, string icon, string color) => builder =>
|
| 709 |
+
{
|
| 710 |
+
builder.OpenElement(0, "div");
|
| 711 |
+
builder.AddAttribute(1, "class", $"rounded-xl border border-{color}-100 bg-{color}-50 p-5 shadow-sm");
|
| 712 |
+
builder.AddMarkupContent(2, $"<div class=\"flex items-center justify-between\"><div><p class=\"text-xs font-semibold uppercase tracking-wider text-{color}-600\">{label}</p><p class=\"mt-1 text-3xl font-bold text-{color}-700\">{value}</p></div><span class=\"flex h-11 w-11 items-center justify-center rounded-xl bg-white/70\"><i data-lucide=\"{icon}\" class=\"h-6 w-6 text-{color}-600\"></i></span></div>");
|
| 713 |
+
builder.CloseElement();
|
| 714 |
+
};
|
| 715 |
+
|
| 716 |
+
private RenderFragment MiniBars(IReadOnlyList<(string Label, int Value)> rows) => builder =>
|
| 717 |
+
{
|
| 718 |
+
var max = Math.Max(1, rows.Select(r => r.Value).DefaultIfEmpty(0).Max());
|
| 719 |
+
var seq = 0;
|
| 720 |
+
builder.OpenElement(seq++, "div");
|
| 721 |
+
builder.AddAttribute(seq++, "class", "space-y-3");
|
| 722 |
+
foreach (var row in rows)
|
| 723 |
+
{
|
| 724 |
+
var width = row.Value * 100 / max;
|
| 725 |
+
builder.AddMarkupContent(seq++, $"<div><div class=\"mb-1 flex justify-between text-xs font-semibold text-slate-600\"><span>{row.Label}</span><span>{row.Value}</span></div><div class=\"h-2.5 rounded-full bg-slate-100\"><div class=\"h-full rounded-full bg-violet-600\" style=\"width:{width}%\"></div></div></div>");
|
| 726 |
+
}
|
| 727 |
+
builder.CloseElement();
|
| 728 |
+
};
|
| 729 |
}
|
TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/TimeTrackingReport.razor
CHANGED
|
@@ -22,6 +22,14 @@
|
|
| 22 |
<p class="text-slate-500 mt-1">Review estimated hours, completion timelines, and resource effort distribution across issues.</p>
|
| 23 |
</div>
|
| 24 |
<div class="flex items-center space-x-3">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
<button @onclick="DownloadExcel" class="inline-flex items-center rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 transition-colors shadow-sm">
|
| 26 |
<i data-lucide="download" class="h-4 w-4 mr-2"></i> Export Excel
|
| 27 |
</button>
|
|
@@ -49,8 +57,8 @@
|
|
| 49 |
<div class="rounded-xl border border-emerald-100 bg-emerald-50 p-5 shadow-sm">
|
| 50 |
<div class="flex items-center justify-between">
|
| 51 |
<div>
|
| 52 |
-
<p class="text-xs font-semibold uppercase tracking-wider text-emerald-500">
|
| 53 |
-
<p class="mt-1 text-3xl font-bold text-emerald-700">@
|
| 54 |
</div>
|
| 55 |
<span class="flex h-11 w-11 items-center justify-center rounded-xl bg-emerald-100">
|
| 56 |
<i data-lucide="check-circle-2" class="h-6 w-6 text-emerald-600"></i>
|
|
@@ -60,8 +68,8 @@
|
|
| 60 |
<div class="rounded-xl border border-amber-100 bg-amber-50 p-5 shadow-sm">
|
| 61 |
<div class="flex items-center justify-between">
|
| 62 |
<div>
|
| 63 |
-
<p class="text-xs font-semibold uppercase tracking-wider text-amber-500">
|
| 64 |
-
<p class="mt-1 text-3xl font-bold text-amber-700">@
|
| 65 |
</div>
|
| 66 |
<span class="flex h-11 w-11 items-center justify-center rounded-xl bg-amber-100">
|
| 67 |
<i data-lucide="timer" class="h-6 w-6 text-amber-600"></i>
|
|
@@ -71,8 +79,8 @@
|
|
| 71 |
<div class="rounded-xl border border-rose-100 bg-rose-50 p-5 shadow-sm">
|
| 72 |
<div class="flex items-center justify-between">
|
| 73 |
<div>
|
| 74 |
-
<p class="text-xs font-semibold uppercase tracking-wider text-rose-500">
|
| 75 |
-
<p class="mt-1 text-3xl font-bold text-rose-700">@
|
| 76 |
</div>
|
| 77 |
<span class="flex h-11 w-11 items-center justify-center rounded-xl bg-rose-100">
|
| 78 |
<i data-lucide="alert-triangle" class="h-6 w-6 text-rose-600"></i>
|
|
@@ -137,6 +145,17 @@
|
|
| 137 |
}
|
| 138 |
else
|
| 139 |
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
<!-- Table -->
|
| 141 |
<div class="rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden">
|
| 142 |
<div class="px-6 py-4 border-b border-slate-100 flex items-center justify-between">
|
|
@@ -151,7 +170,8 @@
|
|
| 151 |
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Issue</th>
|
| 152 |
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Task & Project</th>
|
| 153 |
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Assignee</th>
|
| 154 |
-
|
|
|
|
| 155 |
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Due Date</th>
|
| 156 |
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Status</th>
|
| 157 |
</tr>
|
|
@@ -195,6 +215,9 @@
|
|
| 195 |
<td class="px-6 py-4 text-sm font-semibold text-slate-800">
|
| 196 |
@((issue.EstimatedHours ?? 0).ToString("N1")) hrs
|
| 197 |
</td>
|
|
|
|
|
|
|
|
|
|
| 198 |
<td class="px-6 py-4 text-sm text-slate-600">
|
| 199 |
@DisplayFormats.Date(issue.DueDate)
|
| 200 |
</td>
|
|
@@ -213,7 +236,7 @@
|
|
| 213 |
}
|
| 214 |
else
|
| 215 |
{
|
| 216 |
-
<EmptyState ColSpan="
|
| 217 |
}
|
| 218 |
</tbody>
|
| 219 |
</table>
|
|
@@ -226,11 +249,13 @@
|
|
| 226 |
OnPageChanged="HandlePageChanged"
|
| 227 |
OnPageSizeChanged="HandlePageSizeChanged" />
|
| 228 |
</div>
|
|
|
|
| 229 |
}
|
| 230 |
</div>
|
| 231 |
|
| 232 |
@code {
|
| 233 |
private bool isLoading = true;
|
|
|
|
| 234 |
private List<IssueDto> allIssues = new();
|
| 235 |
private List<ProjectDto> allProjects = new();
|
| 236 |
|
|
@@ -254,9 +279,9 @@
|
|
| 254 |
|
| 255 |
// Metrics
|
| 256 |
private decimal totalHours = 0;
|
| 257 |
-
private decimal
|
| 258 |
-
private decimal
|
| 259 |
-
private
|
| 260 |
|
| 261 |
private int TotalPages => (int)Math.Ceiling((double)FilteredIssues.Count() / pageSize);
|
| 262 |
|
|
@@ -353,16 +378,38 @@
|
|
| 353 |
await JS.InvokeVoidAsync("initIcons");
|
| 354 |
}
|
| 355 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
private void ComputeMetrics()
|
| 357 |
{
|
| 358 |
var today = DateTime.Today;
|
| 359 |
var list = FilteredIssues.ToList();
|
| 360 |
totalHours = list.Sum(i => i.EstimatedHours ?? 0);
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
}
|
| 365 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 366 |
private Task ApplyFilters()
|
| 367 |
{
|
| 368 |
appliedSearch = searchInput;
|
|
@@ -455,7 +502,7 @@
|
|
| 455 |
$"Assigned To Me: {(filterAssignToMe ? "Yes" : "No")}",
|
| 456 |
$"Assigned To My Team: {(filterAssignToMyTeam ? "Yes" : "No")}",
|
| 457 |
$"Total Effort: {totalHours.ToString("N1")} hrs",
|
| 458 |
-
$"
|
| 459 |
$"Generated: {DisplayFormats.Date(DateTime.Today)}"
|
| 460 |
};
|
| 461 |
|
|
|
|
| 22 |
<p class="text-slate-500 mt-1">Review estimated hours, completion timelines, and resource effort distribution across issues.</p>
|
| 23 |
</div>
|
| 24 |
<div class="flex items-center space-x-3">
|
| 25 |
+
<div class="inline-flex rounded-lg border border-slate-200 bg-white p-0.5 shadow-sm">
|
| 26 |
+
<button @onclick="() => SetView(false)" class="inline-flex items-center rounded-md px-3 py-1.5 text-xs font-semibold @(!isChartView ? "bg-violet-600 text-white" : "text-slate-600 hover:text-slate-900") transition-colors">
|
| 27 |
+
<i data-lucide="list" class="h-3.5 w-3.5 mr-1"></i> List View
|
| 28 |
+
</button>
|
| 29 |
+
<button @onclick="() => SetView(true)" class="inline-flex items-center rounded-md px-3 py-1.5 text-xs font-semibold @(isChartView ? "bg-violet-600 text-white" : "text-slate-600 hover:text-slate-900") transition-colors">
|
| 30 |
+
<i data-lucide="bar-chart-3" class="h-3.5 w-3.5 mr-1"></i> Chart View
|
| 31 |
+
</button>
|
| 32 |
+
</div>
|
| 33 |
<button @onclick="DownloadExcel" class="inline-flex items-center rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 transition-colors shadow-sm">
|
| 34 |
<i data-lucide="download" class="h-4 w-4 mr-2"></i> Export Excel
|
| 35 |
</button>
|
|
|
|
| 57 |
<div class="rounded-xl border border-emerald-100 bg-emerald-50 p-5 shadow-sm">
|
| 58 |
<div class="flex items-center justify-between">
|
| 59 |
<div>
|
| 60 |
+
<p class="text-xs font-semibold uppercase tracking-wider text-emerald-500">Actual Hours</p>
|
| 61 |
+
<p class="mt-1 text-3xl font-bold text-emerald-700">@actualHours.ToString("N1") hrs</p>
|
| 62 |
</div>
|
| 63 |
<span class="flex h-11 w-11 items-center justify-center rounded-xl bg-emerald-100">
|
| 64 |
<i data-lucide="check-circle-2" class="h-6 w-6 text-emerald-600"></i>
|
|
|
|
| 68 |
<div class="rounded-xl border border-amber-100 bg-amber-50 p-5 shadow-sm">
|
| 69 |
<div class="flex items-center justify-between">
|
| 70 |
<div>
|
| 71 |
+
<p class="text-xs font-semibold uppercase tracking-wider text-amber-500">Variance</p>
|
| 72 |
+
<p class="mt-1 text-3xl font-bold text-amber-700">@varianceHours.ToString("+0.0;-0.0;0.0") hrs</p>
|
| 73 |
</div>
|
| 74 |
<span class="flex h-11 w-11 items-center justify-center rounded-xl bg-amber-100">
|
| 75 |
<i data-lucide="timer" class="h-6 w-6 text-amber-600"></i>
|
|
|
|
| 79 |
<div class="rounded-xl border border-rose-100 bg-rose-50 p-5 shadow-sm">
|
| 80 |
<div class="flex items-center justify-between">
|
| 81 |
<div>
|
| 82 |
+
<p class="text-xs font-semibold uppercase tracking-wider text-rose-500">Utilization</p>
|
| 83 |
+
<p class="mt-1 text-3xl font-bold text-rose-700">@utilizationPercent.ToString("N0")%</p>
|
| 84 |
</div>
|
| 85 |
<span class="flex h-11 w-11 items-center justify-center rounded-xl bg-rose-100">
|
| 86 |
<i data-lucide="alert-triangle" class="h-6 w-6 text-rose-600"></i>
|
|
|
|
| 145 |
}
|
| 146 |
else
|
| 147 |
{
|
| 148 |
+
@if (isChartView)
|
| 149 |
+
{
|
| 150 |
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 151 |
+
@EffortChart("Estimated vs Actual", new[] { ("Estimated", (double)totalHours), ("Actual", (double)actualHours) })
|
| 152 |
+
@EffortChart("Effort by Project", FilteredIssues.GroupBy(i => i.ProjectName).OrderByDescending(g => g.Sum(i => i.ActualHours ?? 0)).Take(5).Select(g => (g.Key, (double)g.Sum(i => i.ActualHours ?? 0))).ToArray())
|
| 153 |
+
@EffortChart("Effort by Employee", FilteredIssues.GroupBy(i => i.AssignedToName ?? "Unassigned").OrderByDescending(g => g.Sum(i => i.ActualHours ?? 0)).Take(5).Select(g => (g.Key, (double)g.Sum(i => i.ActualHours ?? 0))).ToArray())
|
| 154 |
+
@EffortChart("Estimate Accuracy", new[] { ("On / Under", (double)FilteredIssues.Count(i => (i.ActualHours ?? 0) <= (i.EstimatedHours ?? 0))), ("Over", (double)FilteredIssues.Count(i => (i.ActualHours ?? 0) > (i.EstimatedHours ?? 0))) })
|
| 155 |
+
</div>
|
| 156 |
+
}
|
| 157 |
+
else
|
| 158 |
+
{
|
| 159 |
<!-- Table -->
|
| 160 |
<div class="rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden">
|
| 161 |
<div class="px-6 py-4 border-b border-slate-100 flex items-center justify-between">
|
|
|
|
| 170 |
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Issue</th>
|
| 171 |
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Task & Project</th>
|
| 172 |
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Assignee</th>
|
| 173 |
+
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Est. Hours</th>
|
| 174 |
+
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Actual Hours</th>
|
| 175 |
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Due Date</th>
|
| 176 |
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Status</th>
|
| 177 |
</tr>
|
|
|
|
| 215 |
<td class="px-6 py-4 text-sm font-semibold text-slate-800">
|
| 216 |
@((issue.EstimatedHours ?? 0).ToString("N1")) hrs
|
| 217 |
</td>
|
| 218 |
+
<td class="px-6 py-4 text-sm font-semibold @(GetVariance(issue) > 0 ? "text-rose-600" : "text-emerald-600")">
|
| 219 |
+
@((issue.ActualHours ?? 0).ToString("N1")) hrs
|
| 220 |
+
</td>
|
| 221 |
<td class="px-6 py-4 text-sm text-slate-600">
|
| 222 |
@DisplayFormats.Date(issue.DueDate)
|
| 223 |
</td>
|
|
|
|
| 236 |
}
|
| 237 |
else
|
| 238 |
{
|
| 239 |
+
<EmptyState ColSpan="8" Icon="clock" Title="No effort entries" Subtitle="No timesheet effort entries matched your filters." />
|
| 240 |
}
|
| 241 |
</tbody>
|
| 242 |
</table>
|
|
|
|
| 249 |
OnPageChanged="HandlePageChanged"
|
| 250 |
OnPageSizeChanged="HandlePageSizeChanged" />
|
| 251 |
</div>
|
| 252 |
+
}
|
| 253 |
}
|
| 254 |
</div>
|
| 255 |
|
| 256 |
@code {
|
| 257 |
private bool isLoading = true;
|
| 258 |
+
private bool isChartView = false;
|
| 259 |
private List<IssueDto> allIssues = new();
|
| 260 |
private List<ProjectDto> allProjects = new();
|
| 261 |
|
|
|
|
| 279 |
|
| 280 |
// Metrics
|
| 281 |
private decimal totalHours = 0;
|
| 282 |
+
private decimal actualHours = 0;
|
| 283 |
+
private decimal varianceHours = 0;
|
| 284 |
+
private double utilizationPercent = 0;
|
| 285 |
|
| 286 |
private int TotalPages => (int)Math.Ceiling((double)FilteredIssues.Count() / pageSize);
|
| 287 |
|
|
|
|
| 378 |
await JS.InvokeVoidAsync("initIcons");
|
| 379 |
}
|
| 380 |
|
| 381 |
+
private void SetView(bool chartView)
|
| 382 |
+
{
|
| 383 |
+
isChartView = chartView;
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
private void ComputeMetrics()
|
| 387 |
{
|
| 388 |
var today = DateTime.Today;
|
| 389 |
var list = FilteredIssues.ToList();
|
| 390 |
totalHours = list.Sum(i => i.EstimatedHours ?? 0);
|
| 391 |
+
actualHours = list.Sum(i => i.ActualHours ?? 0);
|
| 392 |
+
varianceHours = actualHours - totalHours;
|
| 393 |
+
utilizationPercent = totalHours > 0 ? Math.Round((double)(actualHours / totalHours) * 100, 1) : 0;
|
| 394 |
}
|
| 395 |
|
| 396 |
+
private static decimal GetVariance(IssueDto issue) => (issue.ActualHours ?? 0) - (issue.EstimatedHours ?? 0);
|
| 397 |
+
|
| 398 |
+
private RenderFragment EffortChart(string title, IReadOnlyList<(string Label, double Value)> rows) => builder =>
|
| 399 |
+
{
|
| 400 |
+
var max = Math.Max(1, rows.Select(r => r.Value).DefaultIfEmpty(0).Max());
|
| 401 |
+
builder.OpenElement(0, "div");
|
| 402 |
+
builder.AddAttribute(1, "class", "rounded-xl border border-slate-200 bg-white p-6 shadow-sm");
|
| 403 |
+
builder.AddMarkupContent(2, $"<h3 class=\"mb-4 text-sm font-semibold text-slate-900\">{title}</h3>");
|
| 404 |
+
var seq = 3;
|
| 405 |
+
foreach (var row in rows)
|
| 406 |
+
{
|
| 407 |
+
var width = row.Value * 100 / max;
|
| 408 |
+
builder.AddMarkupContent(seq++, $"<div class=\"mb-4\"><div class=\"mb-1 flex justify-between text-xs font-semibold text-slate-600\"><span>{row.Label}</span><span>{row.Value:N1}</span></div><div class=\"h-3 rounded-full bg-slate-100\"><div class=\"h-full rounded-full bg-violet-600\" style=\"width:{width}%\"></div></div></div>");
|
| 409 |
+
}
|
| 410 |
+
builder.CloseElement();
|
| 411 |
+
};
|
| 412 |
+
|
| 413 |
private Task ApplyFilters()
|
| 414 |
{
|
| 415 |
appliedSearch = searchInput;
|
|
|
|
| 502 |
$"Assigned To Me: {(filterAssignToMe ? "Yes" : "No")}",
|
| 503 |
$"Assigned To My Team: {(filterAssignToMyTeam ? "Yes" : "No")}",
|
| 504 |
$"Total Effort: {totalHours.ToString("N1")} hrs",
|
| 505 |
+
$"Actual Hours: {actualHours.ToString("N1")} hrs",
|
| 506 |
$"Generated: {DisplayFormats.Date(DateTime.Today)}"
|
| 507 |
};
|
| 508 |
|