Spaces:
Running
Running
task-tracking-system / TaskTrackingSystem.WebApp /Components /Pages /Features /Dashboard /ManagerDashboard.razor
| @page "/dashboard/manager" | |
| @using Microsoft.AspNetCore.Authorization | |
| @rendermode @(new InteractiveServerRenderMode(prerender: false)) | |
| @attribute [Authorize] | |
| @inject ApiClientService ApiClient | |
| @inject NavigationManager Navigation | |
| @inject IJSRuntime JS | |
| @inject AuthenticationStateProvider AuthStateProvider | |
| <PageTitle>Manager Dashboard</PageTitle> | |
| <div class="min-h-screen bg-slate-50 px-4 py-6 text-slate-900 sm:px-6 lg:px-8 dark:bg-slate-950 dark:text-slate-50"> | |
| <div class="mx-auto max-w-[1440px] space-y-6"> | |
| <div class="rounded-xl border border-slate-200 bg-white px-8 py-8 shadow-sm"> | |
| <div class="flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between"> | |
| <div> | |
| <p class="text-sm font-semibold uppercase tracking-[0.08em] text-violet-700">Team View</p> | |
| <h2 class="mt-2 text-3xl font-bold tracking-tight text-slate-950">My Projects</h2> | |
| <p class="mt-3 max-w-2xl text-sm text-slate-600"> | |
| Track your team, project health, and weekly task progress in one place. | |
| @if (summary.PendingTasksCount > 0) | |
| { | |
| <span class="text-slate-500"> @summary.PendingTasksCount tasks are still pending.</span> | |
| } | |
| </p> | |
| </div> | |
| <div class="flex flex-wrap gap-3"> | |
| <a href="/projects/all" class="inline-flex items-center rounded-2xl bg-violet-700 px-5 py-3 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-violet-600"> | |
| <i data-lucide="folder-kanban" class="mr-2 h-4 w-4"></i> | |
| All Projects | |
| </a> | |
| <a href="/reports/tasks" class="inline-flex items-center rounded-2xl border border-slate-200 bg-white px-5 py-3 text-sm font-semibold text-slate-700 shadow-sm transition-colors hover:bg-slate-50"> | |
| <i data-lucide="bar-chart-3" class="mr-2 h-4 w-4"></i> | |
| Task Report | |
| </a> | |
| </div> | |
| </div> | |
| </div> | |
| @if (isLoading) | |
| { | |
| <div class="rounded-xl border border-slate-200 bg-white px-6 py-12 text-center shadow-sm"> | |
| <div class="mx-auto flex items-center justify-center gap-3 text-slate-500"> | |
| <svg class="h-5 w-5 animate-spin" viewBox="0 0 24 24"> | |
| <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"></circle> | |
| <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path> | |
| </svg> | |
| <span class="text-sm font-medium">Loading dashboard...</span> | |
| </div> | |
| </div> | |
| } | |
| else if (!string.IsNullOrWhiteSpace(errorMessage)) | |
| { | |
| <div class="rounded-xl border border-rose-200 bg-rose-50 px-6 py-5 text-sm text-rose-700 shadow-sm"> | |
| @errorMessage | |
| </div> | |
| } | |
| else | |
| { | |
| <ManagerOverview Metrics="SummaryMetrics" /> | |
| <div class="grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1.7fr)_minmax(280px,1fr)]"> | |
| <ChartCard Title="Weekly Task Progress" Subtitle="Completed vs pending tasks per week" TitleClass="text-xl font-bold text-slate-950"> | |
| <Actions> | |
| <div class="text-sm text-slate-500">@WeeklyRangeLabel</div> | |
| </Actions> | |
| <ChildContent> | |
| <BarChart Groups="WeeklyBarGroups" /> | |
| </ChildContent> | |
| </ChartCard> | |
| <ChartCard Title="Team Progress" Subtitle="Members with active workload" TitleClass="text-xl font-bold text-slate-950"> | |
| <ChildContent> | |
| <div class="max-h-[460px] overflow-y-auto pr-1"> | |
| <ProgressListChart Items="TeamProgressItems" EmptyText="No assigned work yet." /> | |
| </div> | |
| </ChildContent> | |
| </ChartCard> | |
| </div> | |
| <div class="grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1.05fr)_minmax(0,0.95fr)]"> | |
| <div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm"> | |
| <div class="flex items-start justify-between gap-4"> | |
| <div> | |
| <h3 class="text-xl font-bold text-slate-950">@CalendarMonthLabel</h3> | |
| <p class="mt-1 text-sm text-slate-500">@CalendarTodayLabel</p> | |
| </div> | |
| </div> | |
| <div class="mt-5 grid grid-cols-7 gap-2 text-center text-xs font-semibold uppercase tracking-wider text-slate-400"> | |
| @foreach (var day in WeekdayLabels) | |
| { | |
| <span>@day</span> | |
| } | |
| </div> | |
| <div class="mt-3 grid grid-cols-7 gap-2"> | |
| @foreach (var cell in CalendarCells) | |
| { | |
| <div class="@GetCalendarCellClasses(cell)"> | |
| <div class="flex h-full flex-col justify-between"> | |
| <div class="text-sm font-semibold @(cell.IsCurrentMonth ? (cell.IsToday ? "text-white" : "text-slate-900") : "text-slate-400")">@cell.Date.Day</div> | |
| <div class="flex items-center gap-1"> | |
| @foreach (var dot in cell.DeadlineDots) | |
| { | |
| <span class="h-1.5 w-1.5 rounded-full" style="background-color: @dot;"></span> | |
| } | |
| </div> | |
| </div> | |
| </div> | |
| } | |
| </div> | |
| </div> | |
| <div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm"> | |
| <h3 class="text-xl font-bold text-slate-950">Upcoming Deadlines</h3> | |
| <p class="mt-1 text-sm text-slate-500">Projects ending soon</p> | |
| <div class="mt-6 max-h-[460px] space-y-3 overflow-y-auto pr-1"> | |
| @if (UpcomingDeadlines.Any()) | |
| { | |
| @foreach (var item in UpcomingDeadlines) | |
| { | |
| <div class="flex items-center gap-3 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3"> | |
| <span class="h-2.5 w-2.5 rounded-full" style="background-color: @item.Color;"></span> | |
| <div class="min-w-0 flex-1"> | |
| <div class="truncate text-sm font-semibold text-slate-950">@item.Name</div> | |
| <div class="text-xs text-slate-500">ends @item.EndDateLabel</div> | |
| </div> | |
| <div class="@item.DaysLeftClass">@item.DaysLeftLabel</div> | |
| </div> | |
| } | |
| } | |
| else | |
| { | |
| <div class="rounded-2xl border border-dashed border-slate-200 bg-slate-50 px-4 py-8 text-center text-sm text-slate-500"> | |
| No upcoming deadlines. | |
| </div> | |
| } | |
| </div> | |
| </div> | |
| </div> | |
| <div class="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm"> | |
| <div class="flex items-center justify-between gap-4 border-b border-slate-100 px-6 py-5"> | |
| <div> | |
| <h3 class="text-xl font-bold text-slate-950">My Projects</h3> | |
| <p class="mt-1 text-sm text-slate-500">Click "View Tasks" to drill in</p> | |
| </div> | |
| <a href="/projects/all" class="inline-flex items-center rounded-2xl border border-slate-200 bg-white px-4 py-2.5 text-sm font-semibold text-violet-700 shadow-sm transition-colors hover:bg-slate-50"> | |
| View all | |
| </a> | |
| </div> | |
| <div class="max-h-[760px] divide-y divide-slate-100 overflow-y-auto"> | |
| @if (ProjectCards.Any()) | |
| { | |
| @foreach (var project in ProjectCards) | |
| { | |
| <div class="px-6 py-6 transition-colors hover:bg-slate-50"> | |
| <div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"> | |
| <div class="min-w-0 flex-1"> | |
| <div class="flex flex-wrap items-center gap-3"> | |
| <span class="h-2.5 w-2.5 rounded-full" style="background-color: @project.Color;"></span> | |
| <h4 class="truncate text-lg font-semibold text-slate-950">@project.Name</h4> | |
| <span class="@project.StatusClass">@project.StatusLabel</span> | |
| </div> | |
| <div class="mt-4 flex flex-wrap items-center gap-x-5 gap-y-2 text-sm text-slate-500"> | |
| <span>@project.Completed/@project.Total tasks</span> | |
| <span class="text-rose-600">@project.Overdue overdue</span> | |
| <span>@project.TeamSize members</span> | |
| </div> | |
| <div class="mt-4 h-2 overflow-hidden rounded-full bg-slate-200"> | |
| <div class="h-full rounded-full transition-all" style="width: @project.ProgressPercentage%; background-color: @project.Color;"></div> | |
| </div> | |
| </div> | |
| <div class="flex flex-col items-start gap-3 lg:items-end"> | |
| <div class="text-right"> | |
| <div class="text-xl font-bold text-slate-950">@project.ProgressPercentage.ToString("F0")%</div> | |
| <div class="text-xs uppercase tracking-[0.08em] text-slate-500">Complete</div> | |
| </div> | |
| <button @onclick="() => ViewProjectTasks(project.Id)" class="inline-flex items-center rounded-2xl bg-violet-700 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-violet-600"> | |
| <i data-lucide="eye" class="mr-2 h-4 w-4"></i> | |
| View Tasks | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| } | |
| } | |
| else | |
| { | |
| <div class="px-6 py-10 text-sm text-slate-500">No projects available for this account.</div> | |
| } | |
| </div> | |
| </div> | |
| } | |
| </div> | |
| </div> | |
| @code { | |
| private const double SvgWidth = 760; | |
| private const double PlotLeft = 56; | |
| private const double PlotRight = 724; | |
| private const double PlotTop = 40; | |
| private const double PlotBottom = 200; | |
| private const double BarWidth = 18; | |
| private bool isLoading = true; | |
| private string? errorMessage; | |
| private List<ProjectDto> projects = new(); | |
| private List<TaskDto> tasks = new(); | |
| private List<UserDto> users = new(); | |
| private List<ProjectProgressDto> projectProgress = new(); | |
| private DashboardSummaryDto summary = new(); | |
| private int? hoveredWeekIndex; | |
| private List<WeeklyProgressViewModel> WeeklyProgress => BuildWeeklyProgress(); | |
| private WeeklyProgressViewModel? hoveredWeek => hoveredWeekIndex is int index && index >= 0 && index < WeeklyProgress.Count ? WeeklyProgress[index] : null; | |
| private string HoveredWeekTooltipStyle => hoveredWeek is null | |
| ? string.Empty | |
| : $"left: {hoveredWeek.CenterPercent:F2}%; top: 34px; transform: translateX(-50%);"; | |
| private string WeeklyRangeLabel | |
| => WeeklyProgress.Count > 0 | |
| ? $"{WeeklyProgress.First().DateRangeLabel} - {WeeklyProgress.Last().DateRangeLabel}" | |
| : DateTime.Today.ToString("MMMM yyyy"); | |
| private List<DashboardMetric> SummaryMetrics => BuildSummaryMetrics(); | |
| private List<ProjectCardViewModel> ProjectCards => BuildProjectCards(); | |
| private List<TeamProgressViewModel> TeamProgress => BuildTeamProgress(); | |
| private List<ProgressListChartItem> TeamProgressItems => TeamProgress | |
| .Select(member => new ProgressListChartItem( | |
| member.FullName, | |
| member.CompletionPercentage, | |
| $"{member.Completed}/{member.Total}", | |
| member.Color, | |
| $"{member.Label} - {member.CompletionPercentage:F0}%", | |
| member.Initials)) | |
| .ToList(); | |
| private List<BarChartGroup> WeeklyBarGroups => WeeklyProgress | |
| .Select(week => new BarChartGroup( | |
| week.Label, | |
| new[] | |
| { | |
| new ChartDataPoint("Completed", week.Completed, "#3b82f6"), | |
| new ChartDataPoint("Pending", week.Pending, "#14b8a6") | |
| }, | |
| week.DateRangeLabel)) | |
| .ToList(); | |
| private List<YAxisTickViewModel> YTicks => BuildYAxisTicks(); | |
| private List<CalendarCellViewModel> CalendarCells => BuildCalendarCells(); | |
| private List<DeadlineItemViewModel> UpcomingDeadlines => BuildUpcomingDeadlines(); | |
| private readonly string[] WeekdayLabels = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; | |
| private string CalendarMonthLabel => DateTime.Today.ToString("MMMM yyyy"); | |
| private string CalendarTodayLabel => DateTime.Today.ToString("dddd, d"); | |
| private string CurrentTimeLabel => DateTime.Now.ToString("h:mm tt"); | |
| private string TimeZoneLabel | |
| { | |
| get | |
| { | |
| var offset = TimeZoneInfo.Local.GetUtcOffset(DateTime.Now); | |
| var sign = offset >= TimeSpan.Zero ? "+" : "-"; | |
| var hours = Math.Abs(offset.Hours); | |
| var minutes = Math.Abs(offset.Minutes); | |
| return minutes == 0 ? $"GMT{sign}{hours}" : $"GMT{sign}{hours}:{minutes:00}"; | |
| } | |
| } | |
| protected override async Task OnInitializedAsync() | |
| { | |
| await LoadDashboardData(); | |
| } | |
| protected override async Task OnAfterRenderAsync(bool firstRender) | |
| { | |
| await JS.InvokeVoidAsync("initIcons"); | |
| } | |
| private async Task LoadDashboardData() | |
| { | |
| isLoading = true; | |
| errorMessage = null; | |
| try | |
| { | |
| var client = ApiClient.CreateClient(); | |
| var summaryTask = client.GetFromJsonAsync<Result<DashboardSummaryDto>>("Dashboard/summary", Serialization.CaseInsensitive); | |
| var progressTask = client.GetFromJsonAsync<Result<List<ProjectProgressDto>>>("Dashboard/project-progress", Serialization.CaseInsensitive); | |
| var projectsTask = client.GetFromJsonAsync<List<ProjectDto>>("Project", Serialization.CaseInsensitive); | |
| var tasksTask = client.GetFromJsonAsync<List<TaskDto>>("Task", Serialization.CaseInsensitive); | |
| var usersTask = client.GetFromJsonAsync<List<UserDto>>("User", Serialization.CaseInsensitive); | |
| await Task.WhenAll(summaryTask, progressTask, projectsTask, tasksTask, usersTask); | |
| var summaryResult = await summaryTask; | |
| if (summaryResult?.IsSuccess == true) | |
| { | |
| summary = summaryResult.Value ?? new DashboardSummaryDto(); | |
| } | |
| var progressResult = await progressTask; | |
| if (progressResult?.IsSuccess == true) | |
| { | |
| projectProgress = progressResult.Value?.ToList() ?? new List<ProjectProgressDto>(); | |
| } | |
| projects = (await projectsTask) ?? new List<ProjectDto>(); | |
| tasks = (await tasksTask) ?? new List<TaskDto>(); | |
| users = (await usersTask) ?? new List<UserDto>(); | |
| } | |
| catch (Exception ex) | |
| { | |
| errorMessage = $"Failed to load dashboard data: {ex.Message}"; | |
| } | |
| finally | |
| { | |
| isLoading = false; | |
| } | |
| } | |
| private void ViewProjectTasks(long projectId) | |
| { | |
| Navigation.NavigateTo($"/projects/{projectId}/tasks"); | |
| } | |
| private void SetHoveredWeek(int index) | |
| { | |
| hoveredWeekIndex = index; | |
| StateHasChanged(); | |
| } | |
| private void ClearHoveredWeek() | |
| { | |
| if (hoveredWeekIndex is null) | |
| { | |
| return; | |
| } | |
| hoveredWeekIndex = null; | |
| StateHasChanged(); | |
| } | |
| private List<DashboardMetric> BuildSummaryMetrics() | |
| { | |
| var activeProjects = summary.ActiveProjectsCount > 0 ? summary.ActiveProjectsCount : projects.Count; | |
| var totalTasks = tasks.Count; | |
| var teamMembers = summary.TotalUsers > 0 ? summary.TotalUsers : users.Count; | |
| var overdueTasks = tasks.Count(task => task.DueDate.Date < DateTime.Today && task.StatusId != AppTaskStatus.Done); | |
| return new List<DashboardMetric> | |
| { | |
| new("folder-kanban", "all active", activeProjects.ToString(), "Active Projects", "rounded-2xl bg-violet-700 p-3 text-white", "text-sm font-semibold text-emerald-700"), | |
| new("check-square", "across projects", totalTasks.ToString(), "Total Tasks", "rounded-2xl bg-cyan-600 p-3 text-white", "text-sm font-semibold text-emerald-700"), | |
| new("users", "your team", teamMembers.ToString(), "Team Members", "rounded-2xl bg-emerald-700 p-3 text-white", "text-sm font-semibold text-emerald-700"), | |
| new("triangle-alert", "needs attention", overdueTasks.ToString(), "Overdue Tasks", "rounded-2xl bg-rose-600 p-3 text-white", "text-sm font-semibold text-rose-600") | |
| }; | |
| } | |
| private List<ProjectCardViewModel> BuildProjectCards() | |
| { | |
| var progressByProject = projectProgress.ToDictionary(item => item.ProjectId, item => item); | |
| var palette = new[] { "#2563eb", "#0ea5e9", "#10b981", "#f59e0b", "#f97316" }; | |
| return projects | |
| .OrderByDescending(project => progressByProject.TryGetValue(project.Id, out var progress) ? progress.CompletionPercentage : 0) | |
| .ThenBy(project => project.EndDate) | |
| .Select((project, index) => | |
| { | |
| progressByProject.TryGetValue(project.Id, out var progress); | |
| var projectTasks = tasks.Where(task => task.ProjectId == project.Id).ToList(); | |
| var total = progress?.TotalTasksCount ?? projectTasks.Count; | |
| var completed = progress?.CompletedTasksCount ?? projectTasks.Count(task => task.StatusId == AppTaskStatus.Done); | |
| var percentage = total > 0 ? Math.Round((double)completed / total * 100, 2) : 0; | |
| var overdue = projectTasks.Count(task => task.DueDate.Date < DateTime.Today && task.StatusId != AppTaskStatus.Done); | |
| var teamSize = projectTasks | |
| .Where(task => task.AssignedTo.HasValue) | |
| .Select(task => task.AssignedTo!.Value) | |
| .Distinct() | |
| .Count(); | |
| var isOverdue = project.EndDate.Date < DateTime.Today && percentage < 100; | |
| var isUpcoming = project.StartDate.Date > DateTime.Today; | |
| var statusLabel = isOverdue ? "At Risk" : isUpcoming ? "Upcoming" : "Active"; | |
| var statusClass = isOverdue | |
| ? "rounded-full border border-rose-500/30 bg-rose-500/10 px-3 py-1 text-xs font-semibold text-rose-300" | |
| : isUpcoming | |
| ? "rounded-full border border-amber-500/30 bg-amber-500/10 px-3 py-1 text-xs font-semibold text-amber-300" | |
| : "rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1 text-xs font-semibold text-emerald-300"; | |
| return new ProjectCardViewModel( | |
| project.Id, | |
| project.Name, | |
| completed, | |
| total, | |
| overdue, | |
| teamSize, | |
| percentage, | |
| statusLabel, | |
| statusClass, | |
| palette[index % palette.Length]); | |
| }) | |
| .ToList(); | |
| } | |
| private List<TeamProgressViewModel> BuildTeamProgress() | |
| { | |
| var palette = new[] { "#2563eb", "#0ea5e9", "#10b981", "#f59e0b", "#f97316", "#ec4899" }; | |
| return users | |
| .Select((user, index) => | |
| { | |
| var userTasks = tasks.Where(task => task.AssignedTo == user.Id).ToList(); | |
| var total = userTasks.Count; | |
| if (total == 0) | |
| { | |
| return null; | |
| } | |
| var completed = userTasks.Count(task => task.StatusId == AppTaskStatus.Done); | |
| var percentage = Math.Round((double)completed / total * 100, 2); | |
| var initials = BuildInitials(user.FirstName, user.LastName); | |
| var label = total == 1 ? "1 task" : $"{total} tasks"; | |
| return new TeamProgressViewModel( | |
| user.Id, | |
| $"{user.FirstName} {user.LastName}".Trim(), | |
| initials, | |
| completed, | |
| total, | |
| percentage, | |
| label, | |
| palette[index % palette.Length]); | |
| }) | |
| .Where(item => item is not null) | |
| .Cast<TeamProgressViewModel>() | |
| .OrderByDescending(item => item.Total) | |
| .ThenBy(item => item.FullName) | |
| .ToList(); | |
| } | |
| private List<CalendarCellViewModel> BuildCalendarCells() | |
| { | |
| var today = DateTime.Today; | |
| var firstOfMonth = new DateTime(today.Year, today.Month, 1); | |
| var start = firstOfMonth.AddDays(-(int)firstOfMonth.DayOfWeek); | |
| var deadlineMap = projects | |
| .Where(project => project.EndDate.Date >= start.Date && project.EndDate.Date < start.AddDays(42)) | |
| .GroupBy(project => project.EndDate.Date) | |
| .ToDictionary(group => group.Key, group => group.Select(project => GetProjectColor(project.Id)).ToList()); | |
| var cells = new List<CalendarCellViewModel>(); | |
| for (var i = 0; i < 42; i++) | |
| { | |
| var date = start.AddDays(i); | |
| deadlineMap.TryGetValue(date.Date, out var dots); | |
| cells.Add(new CalendarCellViewModel( | |
| date, | |
| date.Month == today.Month, | |
| date.Date == today.Date, | |
| dots ?? new List<string>())); | |
| } | |
| return cells; | |
| } | |
| private List<DeadlineItemViewModel> BuildUpcomingDeadlines() | |
| { | |
| return projects | |
| .Where(project => project.EndDate.Date >= DateTime.Today) | |
| .OrderBy(project => project.EndDate) | |
| .Select(project => | |
| { | |
| var daysLeft = (project.EndDate.Date - DateTime.Today).Days; | |
| var label = daysLeft == 0 ? "Today" : daysLeft == 1 ? "1d" : $"{daysLeft}d"; | |
| var labelClass = daysLeft <= 14 ? "text-amber-500 font-semibold" : "text-slate-500"; | |
| return new DeadlineItemViewModel( | |
| project.Id, | |
| project.Name, | |
| project.EndDate.ToString("yyyy-MM-dd"), | |
| label, | |
| labelClass, | |
| GetProjectColor(project.Id)); | |
| }) | |
| .ToList(); | |
| } | |
| private List<WeeklyProgressViewModel> BuildWeeklyProgress() | |
| { | |
| var today = DateTime.Today; | |
| var currentWeekStart = StartOfWeek(today); | |
| var weekStarts = Enumerable.Range(-3, 4) | |
| .Select(offset => currentWeekStart.AddDays(offset * 7)) | |
| .ToList(); | |
| var rows = new List<WeeklyProgressViewModel>(); | |
| var maxTotal = 1; | |
| foreach (var weekStart in weekStarts) | |
| { | |
| var weekEnd = weekStart.AddDays(7); | |
| var weekTasks = tasks.Where(task => task.CreatedAt >= weekStart && task.CreatedAt < weekEnd).ToList(); | |
| var completed = weekTasks.Count(task => task.StatusId == AppTaskStatus.Done); | |
| var pending = weekTasks.Count - completed; | |
| maxTotal = Math.Max(maxTotal, completed + pending); | |
| rows.Add(new WeeklyProgressViewModel( | |
| $"W{rows.Count + 1}", | |
| weekStart, | |
| weekEnd.AddDays(-1), | |
| completed, | |
| pending, | |
| 0, | |
| 0, | |
| 0, | |
| 0, | |
| PlotBottom, | |
| 0, | |
| 0, | |
| FormatDateRange(weekStart, weekEnd.AddDays(-1)))); | |
| } | |
| if (rows.Count == 0) | |
| { | |
| return rows; | |
| } | |
| var slotWidth = (PlotRight - PlotLeft) / 4.0; | |
| for (var i = 0; i < rows.Count; i++) | |
| { | |
| var row = rows[i]; | |
| var total = row.Completed + row.Pending; | |
| var completedHeight = total > 0 ? Math.Round(row.Completed / (double)maxTotal * (PlotBottom - PlotTop), 2) : 0; | |
| var pendingHeight = total > 0 ? Math.Round(row.Pending / (double)maxTotal * (PlotBottom - PlotTop), 2) : 0; | |
| var centerX = PlotLeft + (slotWidth * i) + (slotWidth / 2); | |
| var completedX = centerX - 22; | |
| var pendingX = centerX + 4; | |
| var hoverX = PlotLeft + (slotWidth * i) + 10; | |
| var hoverWidth = slotWidth - 20; | |
| rows[i] = row with | |
| { | |
| CompletedHeight = completedHeight, | |
| PendingHeight = pendingHeight, | |
| CompletedX = completedX, | |
| PendingX = pendingX, | |
| HoverX = hoverX, | |
| HoverWidth = hoverWidth, | |
| LabelX = centerX, | |
| CenterPercent = centerX / SvgWidth * 100 | |
| }; | |
| } | |
| return rows; | |
| } | |
| private List<YAxisTickViewModel> BuildYAxisTicks() | |
| { | |
| var max = Math.Max(1, WeeklyProgress.Max(item => item.Completed + item.Pending)); | |
| var interval = Math.Max(1, (int)Math.Ceiling(max / 4.0)); | |
| var highest = interval * 4; | |
| return new List<YAxisTickViewModel> | |
| { | |
| new(0, "0"), | |
| new(highest * 0.25, (highest * 0.25).ToString("0")), | |
| new(highest * 0.5, (highest * 0.5).ToString("0")), | |
| new(highest * 0.75, (highest * 0.75).ToString("0")), | |
| new(highest, highest.ToString("0")) | |
| }; | |
| } | |
| private double TickToY(double tickValue) | |
| { | |
| var max = Math.Max(1, WeeklyProgress.Max(item => item.Completed + item.Pending)); | |
| var chartHeight = PlotBottom - PlotTop; | |
| return PlotBottom - (Math.Min(tickValue, max) / max * chartHeight); | |
| } | |
| private static DateTime StartOfWeek(DateTime date) | |
| { | |
| var diff = (int)date.DayOfWeek; | |
| return date.AddDays(-diff).Date; | |
| } | |
| private static string FormatDateRange(DateTime start, DateTime end) | |
| { | |
| if (start.Month == end.Month && start.Year == end.Year) | |
| { | |
| return $"{start:MMM d} - {end:dd}"; | |
| } | |
| return $"{start:MMM d} - {end:MMM d}"; | |
| } | |
| private static string BuildInitials(string firstName, string lastName) | |
| { | |
| var first = string.IsNullOrWhiteSpace(firstName) ? string.Empty : firstName.Trim()[0].ToString().ToUpperInvariant(); | |
| var last = string.IsNullOrWhiteSpace(lastName) ? string.Empty : lastName.Trim()[0].ToString().ToUpperInvariant(); | |
| return $"{first}{last}"; | |
| } | |
| private string GetProjectColor(long projectId) | |
| { | |
| var palette = new[] | |
| { | |
| "#2563eb", | |
| "#0ea5e9", | |
| "#10b981", | |
| "#f59e0b", | |
| "#f97316" | |
| }; | |
| return palette[(int)(Math.Abs(projectId) % palette.Length)]; | |
| } | |
| private string GetCalendarCellClasses(CalendarCellViewModel cell) | |
| { | |
| var classes = new List<string> { "min-h-[62px] rounded-2xl border p-2" }; | |
| if (cell.IsToday) | |
| { | |
| classes.Add("border-violet-600 bg-violet-600"); | |
| } | |
| else if (cell.IsCurrentMonth) | |
| { | |
| classes.Add("border-slate-200 bg-white"); | |
| } | |
| else | |
| { | |
| classes.Add("border-slate-100 bg-slate-50 text-slate-400"); | |
| } | |
| return string.Join(" ", classes); | |
| } | |
| private sealed record ProjectCardViewModel( | |
| long Id, | |
| string Name, | |
| int Completed, | |
| int Total, | |
| int Overdue, | |
| int TeamSize, | |
| double ProgressPercentage, | |
| string StatusLabel, | |
| string StatusClass, | |
| string Color); | |
| private sealed record TeamProgressViewModel( | |
| long UserId, | |
| string FullName, | |
| string Initials, | |
| int Completed, | |
| int Total, | |
| double CompletionPercentage, | |
| string Label, | |
| string Color); | |
| private sealed record WeeklyProgressViewModel( | |
| string Label, | |
| DateTime StartDate, | |
| DateTime EndDate, | |
| int Completed, | |
| int Pending, | |
| double CompletedHeight, | |
| double PendingHeight, | |
| double CompletedX, | |
| double PendingX, | |
| double BaseY, | |
| double HoverX, | |
| double HoverWidth, | |
| string DateRangeLabel) | |
| { | |
| public double LabelX { get; init; } | |
| public double CenterPercent { get; init; } | |
| } | |
| private sealed record YAxisTickViewModel(double Value, string Label); | |
| private sealed record CalendarCellViewModel( | |
| DateTime Date, | |
| bool IsCurrentMonth, | |
| bool IsToday, | |
| List<string> DeadlineDots); | |
| private sealed record DeadlineItemViewModel( | |
| long ProjectId, | |
| string Name, | |
| string EndDateLabel, | |
| string DaysLeftLabel, | |
| string DaysLeftClass, | |
| string Color); | |
| } | |