Spaces:
Running
Running
File size: 43,215 Bytes
3418204 691d646 3418204 ecb51fa 3418204 72f2be7 ecb51fa 3418204 691d646 e40d850 481309b e40d850 3418204 481309b 691d646 e40d850 3418204 f48479e 72f2be7 481309b f48479e 72f2be7 481309b f48479e 3418204 e40d850 3418204 917b070 e40d850 be17a46 481309b e40d850 f48479e f6d34ff 691d646 3418204 e40d850 691d646 615f326 691d646 e40d850 691d646 e40d850 691d646 615f326 e40d850 691d646 615f326 691d646 f48479e 691d646 e40d850 691d646 e40d850 f48479e 3418204 e40d850 3418204 917b070 e40d850 be17a46 481309b 1201c4d f6d34ff 1201c4d 481309b 1201c4d e40d850 1201c4d 481309b 1201c4d e40d850 481309b 691d646 f6d34ff 691d646 e40d850 691d646 f48479e 691d646 e40d850 691d646 1201c4d 691d646 e40d850 f48479e 3418204 691d646 e40d850 481309b 1201c4d 691d646 f6d34ff 1201c4d 691d646 481309b 691d646 e40d850 3418204 691d646 f6d34ff 1201c4d 691d646 1201c4d 3418204 e40d850 3418204 e40d850 3418204 691d646 72f2be7 1201c4d e40d850 72543d1 691d646 e40d850 72543d1 e40d850 691d646 917b070 e40d850 691d646 917b070 481309b 917b070 691d646 1201c4d 691d646 1201c4d 3418204 72f2be7 ecb51fa 3418204 691d646 3418204 f48479e 3418204 f48479e 3418204 e40d850 f48479e e40d850 f48479e e40d850 f48479e e40d850 f48479e 3418204 1201c4d 691d646 72543d1 3418204 e40d850 3418204 72543d1 1201c4d 72543d1 1201c4d 72543d1 3418204 691d646 f48479e 691d646 f48479e e40d850 691d646 f48479e e40d850 f48479e e40d850 691d646 e40d850 f48479e 691d646 f48479e e40d850 f48479e e40d850 f48479e e40d850 f48479e 691d646 f48479e 3418204 691d646 e40d850 691d646 e40d850 3418204 691d646 f48479e 691d646 f48479e 691d646 f48479e 3418204 691d646 3418204 e40d850 691d646 e40d850 f48479e e40d850 f48479e 3418204 e40d850 691d646 e40d850 3418204 e40d850 f48479e 691d646 f48479e e40d850 3418204 691d646 3418204 47ad472 e40d850 47ad472 e40d850 f48479e 615f326 691d646 47ad472 ecb51fa e40d850 47ad472 691d646 47ad472 3418204 47ad472 ecb51fa 47ad472 f48479e 691d646 e40d850 691d646 e40d850 691d646 72543d1 691d646 f48479e 691d646 3418204 e40d850 691d646 e40d850 691d646 e40d850 691d646 ecb51fa 691d646 47ad472 691d646 ecb51fa 691d646 47ad472 691d646 47ad472 691d646 e40d850 1201c4d 691d646 e40d850 691d646 615f326 691d646 615f326 691d646 e40d850 691d646 e40d850 691d646 3418204 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 | @page "/"
@page "/Home"
@page "/dashboard"
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using TaskTrackingSystem.Shared.Enums
@using TaskTrackingSystem.Shared.Models.Project
@using TaskTrackingSystem.Shared.Models.Task
@using TaskTrackingSystem.Shared.Models.User
@using TaskTrackingSystem.Shared.Models.Dashboard
@rendermode @(new InteractiveServerRenderMode(prerender: false))
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@inject ApiClientService ApiClient
@inject IJSRuntime JS
@inject AuthenticationStateProvider AuthStateProvider
@inject MenuAuthorizationService MenuAuthorization
@inject NavigationManager NavigationManager
<PageTitle>Dashboard</PageTitle>
<div class="space-y-8 text-slate-900 dark:text-slate-100">
<div class="rounded-xl border border-slate-200 bg-white px-8 py-8 text-slate-900 shadow-sm shadow-slate-200/60 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-100">
<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-teal-700 dark:text-teal-300">Overview</p>
<h2 class="mt-2 text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-50">Welcome back, @currentUserFullName</h2>
<p class="mt-3 max-w-2xl text-sm text-slate-600 dark:text-slate-300">
Projects, users, and team progress at a glance.
</p>
</div>
<div class="flex flex-wrap gap-3">
@if (canCreateTask)
{
<a href="/tasks/all" class="inline-flex items-center rounded-lg bg-teal-700 px-5 py-3 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-teal-800 dark:bg-teal-600 dark:hover:bg-teal-500">
<i data-lucide="plus" class="mr-2 h-4 w-4"></i>
New Task
</a>
}
<a href="/reports/tasks" class="inline-flex items-center rounded-lg 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 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800">
<i data-lucide="bar-chart-3" class="mr-2 h-4 w-4"></i>
Reports
</a>
</div>
</div>
</div>
<AdminOverview Metrics="SummaryMetrics" />
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1.7fr)_minmax(280px,1fr)]">
<div class="rounded-xl border border-slate-200 bg-white p-6 text-slate-900 shadow-sm shadow-slate-200/60 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-100">
<div class="flex items-start justify-between gap-4">
<div>
<h3 class="text-xl font-bold text-slate-900">Project Activity</h3>
<p class="mt-1 text-sm text-slate-500">Active vs completed tasks per month.</p>
</div>
<div class="text-sm text-slate-500">@ActivityRangeLabel</div>
</div>
<div class="mt-6">
<div class="relative">
<svg viewBox="0 0 760 340" class="h-[340px] w-full">
<defs>
<linearGradient id="activityFill" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="#0f766e" stop-opacity="0.18" />
<stop offset="100%" stop-color="#0f766e" stop-opacity="0" />
</linearGradient>
</defs>
@for (var i = 0; i < 5; i++)
{
var y = 60 + (i * 55);
<line x1="48" y1="@y" x2="730" y2="@y" stroke="#e2e8f0" stroke-width="1" />
}
@for (var i = 0; i < 6; i++)
{
var x = 60 + (i * 125);
<line x1="@x" y1="40" x2="@x" y2="280" stroke="#e2e8f0" stroke-width="1" />
}
<path d="@ActivityAreaPath" fill="url(#activityFill)" />
<path d="@ActivityLinePath" fill="none" stroke="#0f766e" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" />
@for (var i = 0; i < ActivityMonths.Count; i++)
{
var index = i;
var point = ActivityPoints[i];
<circle cx="@point.X" cy="@point.Y" r="5"
fill="#0f766e"
stroke="#ccfbf1"
stroke-width="3"
@onmouseenter="() => SetHoveredActivity(index)"
@onmouseleave="ClearHoveredActivity" />
}
</svg>
@if (hoveredActivity is not null)
{
<div class="pointer-events-none absolute z-20 rounded-2xl border border-slate-200 bg-white px-4 py-3 shadow-lg shadow-slate-200/70"
style="@HoveredActivityStyle">
<div class="text-sm font-semibold text-slate-900">@hoveredActivity.Value.Label</div>
<div class="mt-2 text-sm text-emerald-600">Completed: @hoveredActivity.Value.Completed</div>
<div class="mt-1 text-sm text-violet-600">Active: @hoveredActivity.Value.Active</div>
</div>
}
</div>
<div class="mt-4 flex items-center justify-between text-xs text-slate-500">
@foreach (var label in ActivityLabels)
{
<span class="w-1/6 text-center">@label</span>
}
</div>
</div>
</div>
<ChartCard Title="Task Distribution" Subtitle="@($"{TotalTasksCount} total tasks")">
<ChildContent>
<DonutChart Items="DistributionChartItems" />
</ChildContent>
</ChartCard>
</div>
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1.25fr)_minmax(280px,0.75fr)]">
<div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm shadow-slate-200/60 dark:border-slate-800 dark:bg-slate-900">
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="text-xl font-bold text-slate-900">All Projects</h3>
<p class="mt-1 text-sm text-slate-500">A quick snapshot of the current project list.</p>
</div>
<a href="/projects/all" class="inline-flex items-center rounded-lg border border-slate-200 bg-white px-4 py-2.5 text-sm font-semibold text-teal-700 shadow-sm transition-colors hover:bg-teal-50 dark:border-slate-700 dark:bg-slate-900 dark:text-teal-300 dark:hover:bg-slate-800">
View all
</a>
</div>
<div class="mt-5 max-h-[560px] space-y-3 overflow-y-auto pr-1">
@foreach (var project in ProjectListItems)
{
var progress = GetProjectProgress(project.Id);
<div class="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-4">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<p class="truncate text-sm font-semibold text-slate-900">@project.Name</p>
<span class="@GetProjectBadgeClasses(project)">@GetProjectBadgeLabel(project)</span>
</div>
<p class="mt-1 text-xs text-slate-500">@DisplayFormats.Date(project.StartDate) to @DisplayFormats.Date(project.EndDate)</p>
</div>
<div class="text-right">
<div class="text-sm font-semibold text-slate-900">@progress.CompletedTasksCount/@progress.TotalTasksCount tasks</div>
<div class="text-xs text-slate-500">@progress.CompletionPercentage%</div>
</div>
</div>
<div class="mt-3 h-2 overflow-hidden rounded-full bg-slate-200">
<div class="h-full rounded-full bg-teal-700 dark:bg-teal-500" style="width: @progress.CompletionPercentage%"></div>
</div>
</div>
}
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-6 text-slate-900 shadow-sm shadow-slate-200/60 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-100">
<div class="flex items-start justify-between gap-4">
<div>
<h3 class="text-xl font-bold text-slate-900">Calendar</h3>
<p class="mt-1 text-sm text-slate-500">Today and upcoming project starts.</p>
</div>
<div class="text-right">
<div class="text-sm font-semibold text-violet-600">@CalendarMonthLabel</div>
</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 ? "text-slate-900" : "text-slate-400")">@cell.Date.Day</div>
<div class="flex items-center gap-1">
@foreach (var dot in cell.ProjectDots)
{
<span class="h-1.5 w-1.5 rounded-full" style="background-color: @dot;"></span>
}
</div>
</div>
</div>
}
</div>
<div class="mt-6 max-h-[360px] space-y-3 overflow-y-auto pr-1">
<div class="text-xs font-semibold uppercase tracking-wider text-slate-400">Events</div>
@foreach (var item in UpcomingProjectStarts)
{
<div class="flex items-start gap-3 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3">
<span class="mt-1 h-8 w-1.5 rounded-full" style="background-color: @item.Color"></span>
<div class="min-w-0">
<div class="truncate text-sm font-semibold text-slate-900">@item.Name</div>
<div class="text-xs text-slate-500">@item.DateLabel</div>
</div>
</div>
}
</div>
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-5 text-slate-900 shadow-sm shadow-slate-200/60 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-100">
<div class="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<h3 class="text-xl font-bold text-slate-900">Project Timeline</h3>
<p class="mt-1 text-sm text-slate-500">Browse every project in a gantt view and adjust the month window below.</p>
</div>
<a href="/projects/all" class="inline-flex items-center rounded-lg border border-slate-200 bg-white px-4 py-2.5 text-sm font-semibold text-teal-700 shadow-sm transition-colors hover:bg-teal-50 dark:border-slate-700 dark:bg-slate-900 dark:text-teal-300 dark:hover:bg-slate-800">
View all
</a>
</div>
<div class="mt-4 flex flex-wrap items-end gap-3 rounded-xl border border-slate-200 bg-slate-50 px-3 py-3">
<div class="flex flex-col gap-1">
<label class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">From</label>
<input type="month"
class="rounded-2xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm outline-none transition-colors focus:border-violet-400 focus:ring-2 focus:ring-violet-100"
value="@timelineStartMonthValue"
@onchange="OnTimelineStartMonthChanged" />
</div>
<div class="flex flex-col gap-1">
<label class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">To</label>
<input type="month"
class="rounded-2xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm outline-none transition-colors focus:border-violet-400 focus:ring-2 focus:ring-violet-100"
value="@timelineEndMonthValue"
@onchange="OnTimelineEndMonthChanged" />
</div>
<div class="pb-1 text-sm font-medium text-slate-500">
@TimelineRangeLabel
</div>
</div>
@if (TimelineProjects.Count > 0)
{
<div class="mt-4 max-h-[620px] overflow-auto pr-1">
<div id="project-timeline-chart" class="min-w-[1100px] w-full"></div>
</div>
}
else
{
<div class="flex items-center justify-center py-16 text-slate-400 text-sm">No projects to display.</div>
}
</div>
</div>
@code {
private DashboardSummaryDto summary = new();
private List<TaskStatusOverviewDto> taskStatuses = new();
private List<ProjectProgressDto> projectProgress = new();
private List<TaskDto> taskList = new();
private List<UserDto> usersList = new();
private List<ProjectDto> projectsList = new();
private string currentUserName = "User";
private string currentUserFullName = "User";
private bool canCreateTask = false;
private string timelineStartMonthValue = string.Empty;
private string timelineEndMonthValue = string.Empty;
private bool _dataLoaded = false;
private bool _shouldRenderCharts = false;
private int? hoveredActivityIndex;
private int? hoveredSliceIndex;
private readonly string[] WeekdayLabels = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
private List<ActivityMonthStat> ActivityMonths => BuildActivityMonths();
private List<string> ActivityLabels => ActivityMonths.Select(month => month.Label).ToList();
private string ActivityRangeLabel => $"{ActivityMonths.First().Label} - {ActivityMonths.Last().Label}";
private string ActivityLinePath => BuildActivityPath(ActivityMonths);
private string ActivityAreaPath => BuildActivityAreaPath(ActivityMonths);
private List<ActivityPoint> ActivityPoints => BuildActivityPoints();
private List<DistributionSlice> DonutSlices => BuildDonutSlices();
private List<DistributionLegendItem> DistributionLegend => BuildDistributionLegend();
private List<ChartDataPoint> DistributionChartItems => DistributionLegend
.Select(item => new ChartDataPoint(item.Label, item.Count, item.Color))
.ToList();
private int TotalTasksCount => taskList.Count;
private int ActiveTasksCount => taskList.Count(t => t.StatusId != AppTaskStatus.Done);
private int CompletedTasksCount => taskList.Count(t => t.StatusId == AppTaskStatus.Done);
private int CompletionRate => TotalTasksCount > 0 ? (int)Math.Round((double)CompletedTasksCount / TotalTasksCount * 100) : 0;
private string UsersDeltaText => BuildDeltaText(GetUsersThisMonth() - GetUsersPreviousMonth());
private string ProjectsDeltaText => BuildDeltaText(GetProjectsThisMonth() - GetProjectsPreviousMonth());
private string TasksDeltaText => BuildDeltaText(GetActiveTasksThisMonth() - GetActiveTasksPreviousMonth());
private string CompletionDeltaText => BuildCompletionDeltaText();
private List<DashboardMetric> SummaryMetrics => new()
{
new("users", UsersDeltaText, summary.TotalUsers.ToString(), "Total users", "flex h-12 w-12 items-center justify-center rounded-lg bg-teal-700 text-white dark:bg-teal-600", "text-sm font-semibold text-emerald-600 dark:text-emerald-300"),
new("folder-kanban", ProjectsDeltaText, projectsList.Count.ToString(), "Total projects", "flex h-12 w-12 items-center justify-center rounded-2xl bg-sky-500 text-white", "text-sm font-semibold text-emerald-500"),
new("check-square", TasksDeltaText, ActiveTasksCount.ToString(), "Active tasks", "flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-500 text-white", "text-sm font-semibold text-emerald-500"),
new("trending-up", CompletionDeltaText, $"{CompletionRate}%", "Completion rate", "flex h-12 w-12 items-center justify-center rounded-2xl bg-amber-500 text-white", "text-sm font-semibold text-emerald-500")
};
private string CalendarMonthLabel => DateTime.Today.ToString("MMMM yyyy");
private List<CalendarCell> CalendarCells => BuildCalendarCells();
private List<CalendarEventItem> UpcomingProjectStarts => BuildUpcomingProjectStarts();
private List<ProjectDto> FeaturedProjects => TimelineProjects.Take(5).ToList();
private List<ProjectDto> ProjectListItems => projectsList.OrderBy(p => p.StartDate).ThenByDescending(p => p.CreatedAt).ToList();
private List<ProjectDto> TimelineProjects => projectsList.OrderBy(p => p.StartDate).ThenByDescending(p => p.CreatedAt).ToList();
private List<DateTime> TimelineMonths => BuildTimelineMonths();
private string TimelineRangeLabel => TryGetTimelineRange(out var range) ? $"{range.Start:MMM yyyy} - {range.End:MMM yyyy}" : string.Empty;
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var user = authState.User;
var codes = await MenuAuthorization.GetCurrentAccessCodesAsync(user);
if (codes.Contains("Projects_Create") && !codes.Contains("Roles_List"))
{
NavigationManager.NavigateTo("/dashboard/manager");
return;
}
else if (!codes.Contains("Projects_Create") && !codes.Contains("Roles_List"))
{
NavigationManager.NavigateTo("/dashboard/employee");
return;
}
canCreateTask = codes.Contains("Tasks_Create");
await LoadDashboardData();
}
private async Task LoadDashboardData()
{
var client = ApiClient.CreateClient();
try
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var user = authState.User;
currentUserName = user.Identity?.Name ?? "User";
var summaryResult = await client.GetFromJsonAsync<Result<DashboardSummaryDto>>("Dashboard/summary", Serialization.CaseInsensitive);
if (summaryResult?.IsSuccess == true && summaryResult.Value != null)
{
summary = summaryResult.Value;
}
var tasksOverviewResult = await client.GetFromJsonAsync<Result<List<TaskStatusOverviewDto>>>("Dashboard/tasks-overview", Serialization.CaseInsensitive);
if (tasksOverviewResult?.IsSuccess == true && tasksOverviewResult.Value != null)
{
taskStatuses = tasksOverviewResult.Value;
}
var progressResult = await client.GetFromJsonAsync<Result<List<ProjectProgressDto>>>("Dashboard/project-progress", Serialization.CaseInsensitive);
if (progressResult?.IsSuccess == true && progressResult.Value != null)
{
projectProgress = progressResult.Value;
}
taskList = await client.GetFromJsonAsync<List<TaskDto>>("Task", Serialization.CaseInsensitive) ?? new();
usersList = await client.GetFromJsonAsync<List<UserDto>>("User", Serialization.CaseInsensitive) ?? new();
projectsList = await client.GetFromJsonAsync<List<ProjectDto>>("Project", Serialization.CaseInsensitive) ?? new();
InitializeTimelineRange();
currentUserFullName = ResolveCurrentUserFullName();
_dataLoaded = true;
_shouldRenderCharts = true;
StateHasChanged();
}
catch (Exception ex)
{
Console.WriteLine($"Dashboard load error: {ex.Message}");
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await JS.InvokeVoidAsync("initIcons");
if (_shouldRenderCharts && _dataLoaded)
{
_shouldRenderCharts = false;
await RenderChartsAsync();
}
}
private async Task RenderChartsAsync()
{
try
{
var ganttData = TimelineProjects.Select(p => {
var progress = GetProjectProgress(p.Id);
return new {
id = p.Id.ToString(),
name = p.Name,
start = new DateTimeOffset(DateTime.SpecifyKind(p.StartDate.Date, DateTimeKind.Utc)).ToUnixTimeMilliseconds(),
end = new DateTimeOffset(DateTime.SpecifyKind(p.EndDate.Date, DateTimeKind.Utc)).ToUnixTimeMilliseconds(),
completed = progress.CompletionPercentage / 100.0,
color = GetProjectColor(p.Id)
};
}).ToList();
if (ganttData.Count > 0 && TryGetTimelineRange(out var timelineRange))
{
await JS.InvokeVoidAsync("renderGanttChart", "project-timeline-chart", ganttData, new
{
height = 420,
minWidth = Math.Max(900, GetTimelineMonthSpan(timelineRange.Start, timelineRange.End) * 110),
xAxisMin = ToUnixMilliseconds(timelineRange.Start),
xAxisMax = ToUnixMilliseconds(timelineRange.End)
});
}
}
catch (Exception ex)
{
Console.WriteLine($"Error rendering Highcharts: {ex.Message}");
}
}
private string ResolveCurrentUserFullName()
{
var user = usersList.FirstOrDefault(u => string.Equals(u.Username, currentUserName, StringComparison.OrdinalIgnoreCase));
if (user != null)
{
return $"{user.FirstName} {user.LastName}".Trim();
}
return currentUserName;
}
private List<ActivityMonthStat> BuildActivityMonths()
{
var now = DateTime.Today;
var start = new DateTime(now.Year, now.Month, 1).AddMonths(-5);
var months = new List<ActivityMonthStat>();
for (var i = 0; i < 6; i++)
{
var monthStart = start.AddMonths(i);
var nextMonth = monthStart.AddMonths(1);
var active = taskList.Count(task =>
task.CreatedAt >= monthStart &&
task.CreatedAt < nextMonth &&
task.StatusId != AppTaskStatus.Done);
var completed = taskList.Count(task =>
task.CompletedAt.HasValue &&
task.CompletedAt.Value >= monthStart &&
task.CompletedAt.Value < nextMonth);
months.Add(new ActivityMonthStat(GetMonthLabel(monthStart), active, completed));
}
return months;
}
private string BuildActivityPath(IReadOnlyList<ActivityMonthStat> months)
{
if (months.Count == 0)
{
return string.Empty;
}
const double left = 48;
const double right = 730;
const double top = 48;
const double bottom = 280;
const double maxValue = 20;
double X(int i) => left + (i * ((right - left) / (months.Count - 1)));
double Y(double value) => bottom - Math.Min(value, maxValue) / maxValue * (bottom - top);
var points = new List<string>();
for (var i = 0; i < months.Count; i++)
{
points.Add($"{X(i):0},{Y(months[i].Active):0}");
}
var path = $"M {points[0]}";
for (var i = 1; i < points.Count; i++)
{
path += $" L {points[i]}";
}
return path;
}
private string BuildActivityAreaPath(IReadOnlyList<ActivityMonthStat> months)
{
if (months.Count == 0)
{
return string.Empty;
}
const double left = 48;
const double right = 730;
const double top = 48;
const double bottom = 280;
const double maxValue = 20;
double X(int i) => left + (i * ((right - left) / (months.Count - 1)));
double Y(double value) => bottom - Math.Min(value, maxValue) / maxValue * (bottom - top);
var points = new List<string>();
for (var i = 0; i < months.Count; i++)
{
points.Add($"{X(i):0},{Y(months[i].Active):0}");
}
var path = $"M {points[0]}";
for (var i = 1; i < points.Count; i++)
{
path += $" L {points[i]}";
}
return $"{path} L {X(months.Count - 1):0},{bottom:0} L {X(0):0},{bottom:0} Z";
}
private List<ActivityPoint> BuildActivityPoints()
{
var months = ActivityMonths;
var points = new List<ActivityPoint>();
if (months.Count == 0)
{
return points;
}
const double left = 48;
const double right = 730;
const double top = 48;
const double bottom = 280;
var maxValue = Math.Max(months.Max(m => m.Active), 1);
double X(int i) => left + (i * ((right - left) / (months.Count - 1)));
double Y(double value) => bottom - Math.Min(value, maxValue) / (double)maxValue * (bottom - top);
for (var i = 0; i < months.Count; i++)
{
points.Add(new ActivityPoint(months[i].Label, X(i), Y(months[i].Active), months[i].Active, months[i].Completed));
}
return points;
}
private List<DistributionSlice> BuildDonutSlices()
{
// 1. Calculate counts entirely from the same taskList to fix data mismatches
var todoCount = taskList.Count(t => t.StatusId == AppTaskStatus.Todo);
var inProgressCount = taskList.Count(t => t.StatusId == AppTaskStatus.InProgress);
var overdueCount = taskList.Count(t => t.DueDate.Date < DateTime.Today && t.StatusId != AppTaskStatus.Done);
var rawCounts = new[]
{
CompletedTasksCount, // Already calculated from taskList
inProgressCount,
todoCount,
overdueCount
};
var colors = new[] { "#0f766e", "#38bdf8", "#94a3b8", "#dc2626" };
// 2. Adjust total so it maps 100% cleanly to the math displayed
var total = Math.Max(TotalTasksCount, 1);
var circumference = 2 * Math.PI * 74; // 2πr for r=74
var slices = new List<DistributionSlice>();
var cumulativeArc = 0.0;
for (var i = 0; i < rawCounts.Length; i++)
{
if (rawCounts[i] <= 0)
{
// Arc length 0 with a full-circumference gap → invisible.
// Avoids stroke-linecap="round" rendering a phantom dot.
slices.Add(new DistributionSlice(GetDistributionLabel(i), 0, colors[i], $"0 {circumference:0.##}", 0));
continue;
}
var fraction = rawCounts[i] / (double)total;
var arcLength = circumference * fraction;
// dasharray: "{draw} {gap}" where gap = circumference - arcLength so the
// pattern period equals exactly one full revolution. This prevents any
// slice from bleeding into the next revolution of the ring.
var dash = $"{arcLength:0.##} {(circumference - arcLength):0.##}";
// dashOffset = -cumulativeArc:
// A negative offset shifts the start of the dash pattern forward along the
// path by cumulativeArc pixels, so this slice begins exactly where the
// previous slice ended. (The circle starts at 12 o'clock via rotate=-90.)
var dashOffset = -cumulativeArc;
slices.Add(new DistributionSlice(GetDistributionLabel(i), rawCounts[i], colors[i], dash, dashOffset));
cumulativeArc += arcLength;
}
return slices;
}
private static string GetDistributionLabel(int index) => index switch
{
0 => "Completed",
1 => "In Progress",
2 => "To Do",
_ => "Overdue"
};
private List<DistributionLegendItem> BuildDistributionLegend()
{
return DonutSlices.Select(slice => new DistributionLegendItem(slice.Label, slice.Count, slice.Color)).ToList();
}
private List<CalendarCell> BuildCalendarCells()
{
var today = DateTime.Today;
var firstOfMonth = new DateTime(today.Year, today.Month, 1);
var start = firstOfMonth.AddDays(-(int)firstOfMonth.DayOfWeek);
var projectStarts = projectsList
.Select(project => new { project.StartDate, Color = GetProjectColor(project.Id) })
.Where(item => item.StartDate.Date >= start.Date && item.StartDate.Date < start.AddDays(42))
.ToList();
var cells = new List<CalendarCell>();
for (var i = 0; i < 42; i++)
{
var date = start.AddDays(i);
var markers = projectStarts
.Where(item => item.StartDate.Date == date.Date)
.Select(item => item.Color)
.ToList();
cells.Add(new CalendarCell(date, date.Month == today.Month, date.Date == today.Date, markers));
}
return cells;
}
private List<CalendarEventItem> BuildUpcomingProjectStarts()
{
return projectsList
.Where(project => project.StartDate.Date >= DateTime.Today)
.OrderBy(project => project.StartDate)
.Select(project => new CalendarEventItem(project.Name, project.StartDate.ToString("MMM d"), GetProjectColor(project.Id)))
.ToList();
}
private List<DateTime> BuildTimelineMonths()
{
var featured = FeaturedProjects;
if (featured.Count == 0)
{
return new List<DateTime>();
}
var start = new DateTime(featured.Min(p => p.StartDate).Year, featured.Min(p => p.StartDate).Month, 1);
var end = new DateTime(featured.Max(p => p.EndDate).Year, featured.Max(p => p.EndDate).Month, 1);
var months = new List<DateTime>();
var current = start;
while (current <= end)
{
months.Add(current);
current = current.AddMonths(1);
}
return months;
}
private List<TimelineRow> BuildTimelineRows()
{
var months = TimelineMonths;
if (months.Count == 0)
{
return new List<TimelineRow>();
}
var first = months.First();
var n = months.Count; // total columns ΓÇö each flex-1 month slot = 1/n of bar width
return FeaturedProjects.Select(project =>
{
var startIndex = ((project.StartDate.Year - first.Year) * 12) + project.StartDate.Month - first.Month;
var endIndex = ((project.EndDate.Year - first.Year) * 12) + project.EndDate.Month - first.Month;
startIndex = Math.Clamp(startIndex, 0, n - 1);
endIndex = Math.Clamp(endIndex, startIndex, n - 1);
// Use n as divisor so positions match the flex-1 month columns.
var left = (startIndex / (double)n) * 100;
var width = ((endIndex - startIndex + 1) / (double)n) * 100;
width = Math.Max(2, width);
var progress = GetProjectProgress(project.Id);
return new TimelineRow(
project.Name,
$"{DisplayFormats.Date(project.StartDate)} ΓÇô {DisplayFormats.Date(project.EndDate)}",
GetProjectColor(project.Id),
left,
width,
progress.CompletionPercentage);
}).ToList();
}
private string GetTimelineTodayMarkerLeft()
{
var months = TimelineMonths;
if (months.Count == 0) return "0%";
var first = months.First();
var n = months.Count;
var monthIndex = ((DateTime.Today.Year - first.Year) * 12) + DateTime.Today.Month - first.Month;
// Clamp to visible range; if today is outside the timeline keep it at the edge.
monthIndex = Math.Clamp(monthIndex, 0, n - 1);
// Add fractional day-of-month so the line sits at the correct intra-month position.
var dayFraction = (DateTime.Today.Day - 1.0) / DateTime.DaysInMonth(DateTime.Today.Year, DateTime.Today.Month);
return $"{((monthIndex + dayFraction) / n * 100):0.##}%";
}
private void InitializeTimelineRange()
{
if (TimelineProjects.Count == 0)
{
var currentMonth = new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1);
timelineStartMonthValue = currentMonth.ToString("yyyy-MM");
timelineEndMonthValue = currentMonth.ToString("yyyy-MM");
return;
}
var start = new DateTime(TimelineProjects.Min(p => p.StartDate).Year, TimelineProjects.Min(p => p.StartDate).Month, 1);
var end = new DateTime(TimelineProjects.Max(p => p.EndDate).Year, TimelineProjects.Max(p => p.EndDate).Month, 1);
timelineStartMonthValue = start.ToString("yyyy-MM");
timelineEndMonthValue = end.ToString("yyyy-MM");
}
private Task OnTimelineStartMonthChanged(ChangeEventArgs args)
{
timelineStartMonthValue = args.Value?.ToString() ?? timelineStartMonthValue;
NormalizeTimelineRange();
_shouldRenderCharts = true;
StateHasChanged();
return Task.CompletedTask;
}
private Task OnTimelineEndMonthChanged(ChangeEventArgs args)
{
timelineEndMonthValue = args.Value?.ToString() ?? timelineEndMonthValue;
NormalizeTimelineRange();
_shouldRenderCharts = true;
StateHasChanged();
return Task.CompletedTask;
}
private void NormalizeTimelineRange()
{
if (!TryParseMonthValue(timelineStartMonthValue, out var start) ||
!TryParseMonthValue(timelineEndMonthValue, out var end))
{
return;
}
if (start > end)
{
(start, end) = (end, start);
}
timelineStartMonthValue = start.ToString("yyyy-MM");
timelineEndMonthValue = end.ToString("yyyy-MM");
}
private bool TryGetTimelineRange(out (DateTime Start, DateTime End) range)
{
if (!TryParseMonthValue(timelineStartMonthValue, out var start) ||
!TryParseMonthValue(timelineEndMonthValue, out var end))
{
range = default;
return false;
}
if (start > end)
{
(start, end) = (end, start);
}
var startOfMonth = new DateTime(start.Year, start.Month, 1);
var endOfMonth = new DateTime(end.Year, end.Month, DateTime.DaysInMonth(end.Year, end.Month));
range = (startOfMonth, endOfMonth);
return true;
}
private static bool TryParseMonthValue(string? value, out DateTime month)
{
month = default;
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
return DateTime.TryParseExact($"{value}-01", "yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out month);
}
private static long ToUnixMilliseconds(DateTime value)
{
var utc = DateTime.SpecifyKind(value.Date, DateTimeKind.Utc);
return new DateTimeOffset(utc).ToUnixTimeMilliseconds();
}
private static int GetTimelineMonthSpan(DateTime start, DateTime end)
{
var span = ((end.Year - start.Year) * 12) + end.Month - start.Month + 1;
return Math.Max(span, 1);
}
private ProjectProgressDto GetProjectProgress(long projectId)
{
return projectProgress.FirstOrDefault(item => item.ProjectId == projectId)
?? new ProjectProgressDto
{
ProjectId = projectId,
ProjectName = string.Empty,
TotalTasksCount = taskList.Count(task => task.ProjectId == projectId),
CompletedTasksCount = taskList.Count(task => task.ProjectId == projectId && task.StatusId == AppTaskStatus.Done),
CompletionPercentage = 0
};
}
private string GetProjectBadgeLabel(ProjectDto project)
{
return project.StartDate.Date > DateTime.Today ? "Upcoming" : "Active";
}
private string GetProjectBadgeClasses(ProjectDto project)
{
return project.StartDate.Date > DateTime.Today
? "rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700"
: "rounded-full border border-violet-200 bg-violet-50 px-3 py-1 text-xs font-semibold text-violet-700";
}
private string GetProjectColor(long projectId)
{
var palette = new[]
{
"#0f766e",
"#38bdf8",
"#10b981",
"#b45309",
"#64748b"
};
var index = (int)(projectId % palette.Length);
return palette[index];
}
private string GetCalendarCellClasses(CalendarCell cell)
{
var classes = new List<string>
{
"min-h-[62px] rounded-2xl border p-2"
};
if (cell.IsToday)
{
classes.Add("border-violet-200 bg-violet-50");
}
else if (cell.IsCurrentMonth)
{
classes.Add("border-slate-200 bg-white");
}
else
{
classes.Add("border-slate-100 bg-slate-50/70");
}
return string.Join(' ', classes);
}
private void SetHoveredActivity(int index)
{
hoveredActivityIndex = index;
}
private void ClearHoveredActivity()
{
hoveredActivityIndex = null;
}
private void SetHoveredSlice(int index)
{
hoveredSliceIndex = index;
}
private void ClearHoveredSlice()
{
hoveredSliceIndex = null;
}
private ActivityMonthStat? hoveredActivity =>
hoveredActivityIndex.HasValue && hoveredActivityIndex.Value < ActivityMonths.Count
? ActivityMonths[hoveredActivityIndex.Value]
: null;
private DistributionSlice? hoveredSlice =>
hoveredSliceIndex.HasValue && hoveredSliceIndex.Value < DonutSlices.Count
? DonutSlices[hoveredSliceIndex.Value]
: null;
private string HoveredActivityStyle
{
get
{
if (!hoveredActivityIndex.HasValue || hoveredActivityIndex.Value >= ActivityPoints.Count)
{
return string.Empty;
}
var point = ActivityPoints[hoveredActivityIndex.Value];
return $"left: {point.X / 760d * 100:0.##}%; top: {point.Y / 340d * 100:0.##}%; transform: translate(-50%, -110%);";
}
}
private string BuildDeltaText(int delta)
{
if (delta > 0)
{
return $"+{delta} this month";
}
if (delta < 0)
{
return $"{delta} this month";
}
return "0 this month";
}
private string BuildCompletionDeltaText()
{
var current = GetCompletionRateForMonth(DateTime.Today);
var previous = GetCompletionRateForMonth(DateTime.Today.AddMonths(-1));
var delta = current - previous;
if (delta > 0)
{
return $"+{delta}% vs last mo";
}
if (delta < 0)
{
return $"{delta}% vs last mo";
}
return "0% vs last mo";
}
private int GetUsersThisMonth() =>
usersList.Count(user => user.CreatedAt.Year == DateTime.Today.Year && user.CreatedAt.Month == DateTime.Today.Month);
private int GetUsersPreviousMonth()
{
var previous = DateTime.Today.AddMonths(-1);
return usersList.Count(user => user.CreatedAt.Year == previous.Year && user.CreatedAt.Month == previous.Month);
}
private int GetProjectsThisMonth() =>
projectsList.Count(project => project.CreatedAt.Year == DateTime.Today.Year && project.CreatedAt.Month == DateTime.Today.Month);
private int GetProjectsPreviousMonth()
{
var previous = DateTime.Today.AddMonths(-1);
return projectsList.Count(project => project.CreatedAt.Year == previous.Year && project.CreatedAt.Month == previous.Month);
}
private int GetActiveTasksThisMonth() =>
taskList.Count(task => task.CreatedAt.Year == DateTime.Today.Year && task.CreatedAt.Month == DateTime.Today.Month && task.StatusId != AppTaskStatus.Done);
private int GetActiveTasksPreviousMonth()
{
var previous = DateTime.Today.AddMonths(-1);
return taskList.Count(task => task.CreatedAt.Year == previous.Year && task.CreatedAt.Month == previous.Month && task.StatusId != AppTaskStatus.Done);
}
private int GetCompletionRateForMonth(DateTime date)
{
var monthTasks = taskList.Where(task => task.CreatedAt.Year == date.Year && task.CreatedAt.Month == date.Month).ToList();
if (monthTasks.Count == 0)
{
return 0;
}
return (int)Math.Round(monthTasks.Count(task => task.StatusId == AppTaskStatus.Done) / (double)monthTasks.Count * 100);
}
private string GetMonthLabel(DateTime date) => date.ToString("MMM");
private readonly record struct ActivityMonthStat(string Label, int Active, int Completed);
private readonly record struct ActivityPoint(string Label, double X, double Y, int Active, int Completed);
private readonly record struct DistributionSlice(string Label, int Count, string Color, string DashArray, double DashOffset);
private readonly record struct DistributionLegendItem(string Label, int Count, string Color);
private readonly record struct CalendarCell(DateTime Date, bool IsCurrentMonth, bool IsToday, List<string> ProjectDots);
private readonly record struct CalendarEventItem(string Name, string DateLabel, string Color);
private readonly record struct TimelineRow(string Name, string DateLabel, string Color, double Left, double Width, double CompletionPercentage);
}
|