Spaces:
Running
Running
User commited on
Commit ·
691d646
1
Parent(s): e40d850
add: admin dashboard
Browse files- TaskTrackingSystem.WebApi/Program.cs +182 -0
- TaskTrackingSystem.WebApp/AccountEndpoints.cs +64 -4
- TaskTrackingSystem.WebApp/Components/Layout/MainLayout.razor +40 -1
- TaskTrackingSystem.WebApp/Components/Layout/NavMenu.razor +7 -8
- TaskTrackingSystem.WebApp/Components/Pages/EmployeeDashboard.razor +46 -0
- TaskTrackingSystem.WebApp/Components/Pages/Home.razor +583 -144
- TaskTrackingSystem.WebApp/Components/Pages/Login.razor +54 -1
- TaskTrackingSystem.WebApp/Components/Pages/ManagerDashboard.razor +46 -0
TaskTrackingSystem.WebApi/Program.cs
CHANGED
|
@@ -128,6 +128,188 @@ static async System.Threading.Tasks.Task EnsureSeedDataAsync(WebApplication app)
|
|
| 128 |
backlogMenu.UpdatedAt = DateTime.UtcNow;
|
| 129 |
}
|
| 130 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
// Soft-delete existing tasks that belong to deleted projects
|
| 132 |
var orphanedTasks = await db.Tasks
|
| 133 |
.Where(t => t.IsDeleted != true && t.Project.IsDeleted == true)
|
|
|
|
| 128 |
backlogMenu.UpdatedAt = DateTime.UtcNow;
|
| 129 |
}
|
| 130 |
|
| 131 |
+
var dashboardMenu = await db.Menus.FirstOrDefaultAsync(m => m.MenuCode == "DASHBOARD");
|
| 132 |
+
if (dashboardMenu == null)
|
| 133 |
+
{
|
| 134 |
+
dashboardMenu = new Menu
|
| 135 |
+
{
|
| 136 |
+
MenuCode = "DASHBOARD",
|
| 137 |
+
MenuName = "Dashboard",
|
| 138 |
+
MenuUrl = null,
|
| 139 |
+
Icon = "layout-grid",
|
| 140 |
+
Visible = true,
|
| 141 |
+
OrderNo = 0,
|
| 142 |
+
IsDeleted = false,
|
| 143 |
+
CreatedAt = DateTime.UtcNow
|
| 144 |
+
};
|
| 145 |
+
db.Menus.Add(dashboardMenu);
|
| 146 |
+
await db.SaveChangesAsync();
|
| 147 |
+
}
|
| 148 |
+
else
|
| 149 |
+
{
|
| 150 |
+
dashboardMenu.MenuName = "Dashboard";
|
| 151 |
+
dashboardMenu.MenuUrl = null;
|
| 152 |
+
dashboardMenu.Icon = "layout-grid";
|
| 153 |
+
dashboardMenu.Visible = true;
|
| 154 |
+
dashboardMenu.OrderNo = 0;
|
| 155 |
+
dashboardMenu.IsDeleted = false;
|
| 156 |
+
dashboardMenu.UpdatedAt = DateTime.UtcNow;
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
var adminDashboardMenu = await db.Menus.FirstOrDefaultAsync(m => m.MenuCode == "DASHBOARD_ADMIN");
|
| 160 |
+
if (adminDashboardMenu == null)
|
| 161 |
+
{
|
| 162 |
+
adminDashboardMenu = new Menu
|
| 163 |
+
{
|
| 164 |
+
MenuCode = "DASHBOARD_ADMIN",
|
| 165 |
+
ParentMenuId = dashboardMenu.MenuId,
|
| 166 |
+
MenuName = "Admin Dashboard",
|
| 167 |
+
MenuUrl = "/dashboard",
|
| 168 |
+
Icon = "layout-dashboard",
|
| 169 |
+
Visible = true,
|
| 170 |
+
OrderNo = 1,
|
| 171 |
+
IsDeleted = false,
|
| 172 |
+
CreatedAt = DateTime.UtcNow
|
| 173 |
+
};
|
| 174 |
+
db.Menus.Add(adminDashboardMenu);
|
| 175 |
+
}
|
| 176 |
+
else
|
| 177 |
+
{
|
| 178 |
+
adminDashboardMenu.ParentMenuId = dashboardMenu.MenuId;
|
| 179 |
+
adminDashboardMenu.MenuName = "Admin Dashboard";
|
| 180 |
+
adminDashboardMenu.MenuUrl = "/dashboard";
|
| 181 |
+
adminDashboardMenu.Icon = "layout-dashboard";
|
| 182 |
+
adminDashboardMenu.Visible = true;
|
| 183 |
+
adminDashboardMenu.OrderNo = 1;
|
| 184 |
+
adminDashboardMenu.IsDeleted = false;
|
| 185 |
+
adminDashboardMenu.UpdatedAt = DateTime.UtcNow;
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
var managerDashboardMenu = await db.Menus.FirstOrDefaultAsync(m => m.MenuCode == "DASHBOARD_MANAGER");
|
| 189 |
+
if (managerDashboardMenu == null)
|
| 190 |
+
{
|
| 191 |
+
managerDashboardMenu = new Menu
|
| 192 |
+
{
|
| 193 |
+
MenuCode = "DASHBOARD_MANAGER",
|
| 194 |
+
ParentMenuId = dashboardMenu.MenuId,
|
| 195 |
+
MenuName = "Manager Dashboard",
|
| 196 |
+
MenuUrl = "/dashboard/manager",
|
| 197 |
+
Icon = "layout-dashboard",
|
| 198 |
+
Visible = true,
|
| 199 |
+
OrderNo = 2,
|
| 200 |
+
IsDeleted = false,
|
| 201 |
+
CreatedAt = DateTime.UtcNow
|
| 202 |
+
};
|
| 203 |
+
db.Menus.Add(managerDashboardMenu);
|
| 204 |
+
}
|
| 205 |
+
else
|
| 206 |
+
{
|
| 207 |
+
managerDashboardMenu.ParentMenuId = dashboardMenu.MenuId;
|
| 208 |
+
managerDashboardMenu.MenuName = "Manager Dashboard";
|
| 209 |
+
managerDashboardMenu.MenuUrl = "/dashboard/manager";
|
| 210 |
+
managerDashboardMenu.Icon = "layout-dashboard";
|
| 211 |
+
managerDashboardMenu.Visible = true;
|
| 212 |
+
managerDashboardMenu.OrderNo = 2;
|
| 213 |
+
managerDashboardMenu.IsDeleted = false;
|
| 214 |
+
managerDashboardMenu.UpdatedAt = DateTime.UtcNow;
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
var employeeDashboardMenu = await db.Menus.FirstOrDefaultAsync(m => m.MenuCode == "DASHBOARD_EMPLOYEE");
|
| 218 |
+
if (employeeDashboardMenu == null)
|
| 219 |
+
{
|
| 220 |
+
employeeDashboardMenu = new Menu
|
| 221 |
+
{
|
| 222 |
+
MenuCode = "DASHBOARD_EMPLOYEE",
|
| 223 |
+
ParentMenuId = dashboardMenu.MenuId,
|
| 224 |
+
MenuName = "Employee Dashboard",
|
| 225 |
+
MenuUrl = "/dashboard/employee",
|
| 226 |
+
Icon = "layout-dashboard",
|
| 227 |
+
Visible = true,
|
| 228 |
+
OrderNo = 3,
|
| 229 |
+
IsDeleted = false,
|
| 230 |
+
CreatedAt = DateTime.UtcNow
|
| 231 |
+
};
|
| 232 |
+
db.Menus.Add(employeeDashboardMenu);
|
| 233 |
+
}
|
| 234 |
+
else
|
| 235 |
+
{
|
| 236 |
+
employeeDashboardMenu.ParentMenuId = dashboardMenu.MenuId;
|
| 237 |
+
employeeDashboardMenu.MenuName = "Employee Dashboard";
|
| 238 |
+
employeeDashboardMenu.MenuUrl = "/dashboard/employee";
|
| 239 |
+
employeeDashboardMenu.Icon = "layout-dashboard";
|
| 240 |
+
employeeDashboardMenu.Visible = true;
|
| 241 |
+
employeeDashboardMenu.OrderNo = 3;
|
| 242 |
+
employeeDashboardMenu.IsDeleted = false;
|
| 243 |
+
employeeDashboardMenu.UpdatedAt = DateTime.UtcNow;
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
await db.SaveChangesAsync();
|
| 247 |
+
|
| 248 |
+
var adminRole = await db.Roles.FirstOrDefaultAsync(r => r.Name == "Admin" && !r.IsDeleted);
|
| 249 |
+
if (adminRole != null)
|
| 250 |
+
{
|
| 251 |
+
var adminDashboardAccess = await db.RoleMenus.FirstOrDefaultAsync(rm => rm.RoleId == adminRole.Id && rm.MenuId == adminDashboardMenu.MenuId);
|
| 252 |
+
if (adminDashboardAccess == null)
|
| 253 |
+
{
|
| 254 |
+
db.RoleMenus.Add(new RoleMenu
|
| 255 |
+
{
|
| 256 |
+
RoleId = adminRole.Id,
|
| 257 |
+
MenuId = adminDashboardMenu.MenuId,
|
| 258 |
+
IsDeleted = false,
|
| 259 |
+
CreatedAt = DateTime.UtcNow
|
| 260 |
+
});
|
| 261 |
+
}
|
| 262 |
+
else
|
| 263 |
+
{
|
| 264 |
+
adminDashboardAccess.IsDeleted = false;
|
| 265 |
+
adminDashboardAccess.UpdatedAt = DateTime.UtcNow;
|
| 266 |
+
}
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
var managerRole = await db.Roles.FirstOrDefaultAsync(r => r.Name == "Manager" && !r.IsDeleted);
|
| 270 |
+
if (managerRole != null)
|
| 271 |
+
{
|
| 272 |
+
var managerDashboardAccess = await db.RoleMenus.FirstOrDefaultAsync(rm => rm.RoleId == managerRole.Id && rm.MenuId == managerDashboardMenu.MenuId);
|
| 273 |
+
if (managerDashboardAccess == null)
|
| 274 |
+
{
|
| 275 |
+
db.RoleMenus.Add(new RoleMenu
|
| 276 |
+
{
|
| 277 |
+
RoleId = managerRole.Id,
|
| 278 |
+
MenuId = managerDashboardMenu.MenuId,
|
| 279 |
+
IsDeleted = false,
|
| 280 |
+
CreatedAt = DateTime.UtcNow
|
| 281 |
+
});
|
| 282 |
+
}
|
| 283 |
+
else
|
| 284 |
+
{
|
| 285 |
+
managerDashboardAccess.IsDeleted = false;
|
| 286 |
+
managerDashboardAccess.UpdatedAt = DateTime.UtcNow;
|
| 287 |
+
}
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
var employeeRole = await db.Roles.FirstOrDefaultAsync(r => r.Name == "Employee" && !r.IsDeleted);
|
| 291 |
+
if (employeeRole != null)
|
| 292 |
+
{
|
| 293 |
+
var employeeDashboardAccess = await db.RoleMenus.FirstOrDefaultAsync(rm => rm.RoleId == employeeRole.Id && rm.MenuId == employeeDashboardMenu.MenuId);
|
| 294 |
+
if (employeeDashboardAccess == null)
|
| 295 |
+
{
|
| 296 |
+
db.RoleMenus.Add(new RoleMenu
|
| 297 |
+
{
|
| 298 |
+
RoleId = employeeRole.Id,
|
| 299 |
+
MenuId = employeeDashboardMenu.MenuId,
|
| 300 |
+
IsDeleted = false,
|
| 301 |
+
CreatedAt = DateTime.UtcNow
|
| 302 |
+
});
|
| 303 |
+
}
|
| 304 |
+
else
|
| 305 |
+
{
|
| 306 |
+
employeeDashboardAccess.IsDeleted = false;
|
| 307 |
+
employeeDashboardAccess.UpdatedAt = DateTime.UtcNow;
|
| 308 |
+
}
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
await db.SaveChangesAsync();
|
| 312 |
+
|
| 313 |
// Soft-delete existing tasks that belong to deleted projects
|
| 314 |
var orphanedTasks = await db.Tasks
|
| 315 |
.Where(t => t.IsDeleted != true && t.Project.IsDeleted == true)
|
TaskTrackingSystem.WebApp/AccountEndpoints.cs
CHANGED
|
@@ -23,6 +23,7 @@ public static class AccountEndpoints
|
|
| 23 |
private static async Task<IResult> LoginAsync(
|
| 24 |
HttpContext context,
|
| 25 |
IHttpClientFactory httpClientFactory,
|
|
|
|
| 26 |
[FromForm] string usernameOrEmail,
|
| 27 |
[FromForm] string password,
|
| 28 |
[FromForm] bool? rememberMe,
|
|
@@ -60,12 +61,14 @@ public static class AccountEndpoints
|
|
| 60 |
}
|
| 61 |
|
| 62 |
await SignInUserAsync(context, result.Value, rememberMe ?? false);
|
| 63 |
-
|
|
|
|
| 64 |
}
|
| 65 |
|
| 66 |
private static async Task<IResult> RegisterAsync(
|
| 67 |
HttpContext context,
|
| 68 |
IHttpClientFactory httpClientFactory,
|
|
|
|
| 69 |
[FromForm] RegisterDto registerDto)
|
| 70 |
{
|
| 71 |
HttpResponseMessage response;
|
|
@@ -87,7 +90,8 @@ public static class AccountEndpoints
|
|
| 87 |
if (result?.IsSuccess == true && result.Value != null && !string.IsNullOrWhiteSpace(result.Value.Username))
|
| 88 |
{
|
| 89 |
await SignInUserAsync(context, result.Value, false);
|
| 90 |
-
|
|
|
|
| 91 |
}
|
| 92 |
else if (result != null && !string.IsNullOrEmpty(result.ErrorMessage))
|
| 93 |
{
|
|
@@ -305,13 +309,69 @@ public static class AccountEndpoints
|
|
| 305 |
return Results.Redirect($"/login?error={error}{safeReturnUrl}");
|
| 306 |
}
|
| 307 |
|
| 308 |
-
private static string GetSafeReturnUrl(string? returnUrl)
|
| 309 |
{
|
| 310 |
if (string.IsNullOrWhiteSpace(returnUrl) || !returnUrl.StartsWith('/') || returnUrl.StartsWith("//"))
|
| 311 |
{
|
| 312 |
-
return
|
| 313 |
}
|
| 314 |
|
| 315 |
return returnUrl;
|
| 316 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
}
|
|
|
|
| 23 |
private static async Task<IResult> LoginAsync(
|
| 24 |
HttpContext context,
|
| 25 |
IHttpClientFactory httpClientFactory,
|
| 26 |
+
MenuAuthorizationService menuAuthorization,
|
| 27 |
[FromForm] string usernameOrEmail,
|
| 28 |
[FromForm] string password,
|
| 29 |
[FromForm] bool? rememberMe,
|
|
|
|
| 61 |
}
|
| 62 |
|
| 63 |
await SignInUserAsync(context, result.Value, rememberMe ?? false);
|
| 64 |
+
var landingPage = await ResolveLandingPageAsync(menuAuthorization, result.Value);
|
| 65 |
+
return Results.Redirect(GetSafeReturnUrl(returnUrl, landingPage));
|
| 66 |
}
|
| 67 |
|
| 68 |
private static async Task<IResult> RegisterAsync(
|
| 69 |
HttpContext context,
|
| 70 |
IHttpClientFactory httpClientFactory,
|
| 71 |
+
MenuAuthorizationService menuAuthorization,
|
| 72 |
[FromForm] RegisterDto registerDto)
|
| 73 |
{
|
| 74 |
HttpResponseMessage response;
|
|
|
|
| 90 |
if (result?.IsSuccess == true && result.Value != null && !string.IsNullOrWhiteSpace(result.Value.Username))
|
| 91 |
{
|
| 92 |
await SignInUserAsync(context, result.Value, false);
|
| 93 |
+
var landingPage = await ResolveLandingPageAsync(menuAuthorization, result.Value);
|
| 94 |
+
return Results.Redirect(GetSafeReturnUrl(null, landingPage));
|
| 95 |
}
|
| 96 |
else if (result != null && !string.IsNullOrEmpty(result.ErrorMessage))
|
| 97 |
{
|
|
|
|
| 309 |
return Results.Redirect($"/login?error={error}{safeReturnUrl}");
|
| 310 |
}
|
| 311 |
|
| 312 |
+
private static string GetSafeReturnUrl(string? returnUrl, string landingPage)
|
| 313 |
{
|
| 314 |
if (string.IsNullOrWhiteSpace(returnUrl) || !returnUrl.StartsWith('/') || returnUrl.StartsWith("//"))
|
| 315 |
{
|
| 316 |
+
return landingPage;
|
| 317 |
}
|
| 318 |
|
| 319 |
return returnUrl;
|
| 320 |
}
|
| 321 |
+
|
| 322 |
+
private static async Task<string> ResolveLandingPageAsync(MenuAuthorizationService menuAuthorization, AuthResponseDto authResult)
|
| 323 |
+
{
|
| 324 |
+
var principal = BuildTemporaryPrincipal(authResult);
|
| 325 |
+
var menus = await menuAuthorization.GetUserMenusAsync(principal);
|
| 326 |
+
var dashboardMenu = FindFirstDashboardMenu(menus);
|
| 327 |
+
return dashboardMenu ?? "/dashboard";
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
private static ClaimsPrincipal BuildTemporaryPrincipal(AuthResponseDto authResult)
|
| 331 |
+
{
|
| 332 |
+
var claims = new List<Claim>
|
| 333 |
+
{
|
| 334 |
+
new(ClaimTypes.Name, authResult.Username),
|
| 335 |
+
new(ClaimTypes.Email, authResult.Email),
|
| 336 |
+
new(ClaimTypes.Role, authResult.RoleName),
|
| 337 |
+
new("role_id", authResult.RoleId.ToString()),
|
| 338 |
+
new("jwt_token", authResult.Token)
|
| 339 |
+
};
|
| 340 |
+
|
| 341 |
+
return new ClaimsPrincipal(new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme));
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
private static string? FindFirstDashboardMenu(IEnumerable<TaskTrackingSystem.Shared.Models.Menu.MenuDto> menus)
|
| 345 |
+
{
|
| 346 |
+
foreach (var menu in menus)
|
| 347 |
+
{
|
| 348 |
+
var found = FindFirstDashboardMenu(menu);
|
| 349 |
+
if (!string.IsNullOrWhiteSpace(found))
|
| 350 |
+
{
|
| 351 |
+
return found;
|
| 352 |
+
}
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
return null;
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
private static string? FindFirstDashboardMenu(TaskTrackingSystem.Shared.Models.Menu.MenuDto menu)
|
| 359 |
+
{
|
| 360 |
+
if (!string.IsNullOrWhiteSpace(menu.MenuUrl) &&
|
| 361 |
+
menu.MenuUrl.StartsWith("/dashboard", StringComparison.OrdinalIgnoreCase))
|
| 362 |
+
{
|
| 363 |
+
return menu.MenuUrl;
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
foreach (var subMenu in menu.SubMenus)
|
| 367 |
+
{
|
| 368 |
+
var found = FindFirstDashboardMenu(subMenu);
|
| 369 |
+
if (!string.IsNullOrWhiteSpace(found))
|
| 370 |
+
{
|
| 371 |
+
return found;
|
| 372 |
+
}
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
return null;
|
| 376 |
+
}
|
| 377 |
}
|
TaskTrackingSystem.WebApp/Components/Layout/MainLayout.razor
CHANGED
|
@@ -89,7 +89,7 @@
|
|
| 89 |
<p class="text-center mt-2 max-w-sm text-sm" style="color: var(--text-muted);">
|
| 90 |
You do not have permission to access this page. Please contact your system administrator.
|
| 91 |
</p>
|
| 92 |
-
<a href="
|
| 93 |
<i data-lucide="arrow-left" class="h-4 w-4 mr-2"></i>
|
| 94 |
Back to Dashboard
|
| 95 |
</a>
|
|
@@ -106,6 +106,7 @@
|
|
| 106 |
[Inject] private Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider AuthStateProvider { get; set; } = default!;
|
| 107 |
|
| 108 |
private bool isAuthorizedPage = true;
|
|
|
|
| 109 |
|
| 110 |
private bool IsDashboardPage =>
|
| 111 |
string.IsNullOrEmpty(Navigation.ToBaseRelativePath(Navigation.Uri)) ||
|
|
@@ -118,6 +119,9 @@
|
|
| 118 |
"reports" => "Reports",
|
| 119 |
"roles" => "Roles & Access",
|
| 120 |
"users" => "Users",
|
|
|
|
|
|
|
|
|
|
| 121 |
_ => "Workspace"
|
| 122 |
};
|
| 123 |
|
|
@@ -159,6 +163,7 @@
|
|
| 159 |
}
|
| 160 |
|
| 161 |
var menus = await MenuAuthorization.GetUserMenusAsync(user);
|
|
|
|
| 162 |
var allowed = MenuAuthorization.IsRouteAllowed(user, relativePath);
|
| 163 |
Console.WriteLine($"[DEBUG MainLayout] User: {user.Identity?.Name}, Path: {relativePath}, Allowed: {allowed}");
|
| 164 |
if (menus != null)
|
|
@@ -188,4 +193,38 @@
|
|
| 188 |
{
|
| 189 |
Navigation.LocationChanged -= OnLocationChanged;
|
| 190 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
}
|
|
|
|
| 89 |
<p class="text-center mt-2 max-w-sm text-sm" style="color: var(--text-muted);">
|
| 90 |
You do not have permission to access this page. Please contact your system administrator.
|
| 91 |
</p>
|
| 92 |
+
<a href="@dashboardHref" class="mt-6 inline-flex items-center rounded-lg px-4 py-2.5 text-sm font-medium text-white transition-colors shadow-sm hover:opacity-90" style="background-color: var(--brand-primary);">
|
| 93 |
<i data-lucide="arrow-left" class="h-4 w-4 mr-2"></i>
|
| 94 |
Back to Dashboard
|
| 95 |
</a>
|
|
|
|
| 106 |
[Inject] private Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider AuthStateProvider { get; set; } = default!;
|
| 107 |
|
| 108 |
private bool isAuthorizedPage = true;
|
| 109 |
+
private string dashboardHref = "/dashboard";
|
| 110 |
|
| 111 |
private bool IsDashboardPage =>
|
| 112 |
string.IsNullOrEmpty(Navigation.ToBaseRelativePath(Navigation.Uri)) ||
|
|
|
|
| 119 |
"reports" => "Reports",
|
| 120 |
"roles" => "Roles & Access",
|
| 121 |
"users" => "Users",
|
| 122 |
+
"dashboard" => "Dashboard",
|
| 123 |
+
"dashboard/manager" => "Manager Dashboard",
|
| 124 |
+
"dashboard/employee" => "Employee Dashboard",
|
| 125 |
_ => "Workspace"
|
| 126 |
};
|
| 127 |
|
|
|
|
| 163 |
}
|
| 164 |
|
| 165 |
var menus = await MenuAuthorization.GetUserMenusAsync(user);
|
| 166 |
+
dashboardHref = ResolveDashboardHref(menus);
|
| 167 |
var allowed = MenuAuthorization.IsRouteAllowed(user, relativePath);
|
| 168 |
Console.WriteLine($"[DEBUG MainLayout] User: {user.Identity?.Name}, Path: {relativePath}, Allowed: {allowed}");
|
| 169 |
if (menus != null)
|
|
|
|
| 193 |
{
|
| 194 |
Navigation.LocationChanged -= OnLocationChanged;
|
| 195 |
}
|
| 196 |
+
|
| 197 |
+
private static string ResolveDashboardHref(IEnumerable<TaskTrackingSystem.Shared.Models.Menu.MenuDto> menus)
|
| 198 |
+
{
|
| 199 |
+
foreach (var menu in menus)
|
| 200 |
+
{
|
| 201 |
+
var found = ResolveDashboardHref(menu);
|
| 202 |
+
if (!string.IsNullOrWhiteSpace(found))
|
| 203 |
+
{
|
| 204 |
+
return found;
|
| 205 |
+
}
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
return "/dashboard";
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
private static string? ResolveDashboardHref(TaskTrackingSystem.Shared.Models.Menu.MenuDto menu)
|
| 212 |
+
{
|
| 213 |
+
if (!string.IsNullOrWhiteSpace(menu.MenuUrl) &&
|
| 214 |
+
menu.MenuUrl.StartsWith("/dashboard", StringComparison.OrdinalIgnoreCase))
|
| 215 |
+
{
|
| 216 |
+
return menu.MenuUrl;
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
foreach (var subMenu in menu.SubMenus)
|
| 220 |
+
{
|
| 221 |
+
var found = ResolveDashboardHref(subMenu);
|
| 222 |
+
if (!string.IsNullOrWhiteSpace(found))
|
| 223 |
+
{
|
| 224 |
+
return found;
|
| 225 |
+
}
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
return null;
|
| 229 |
+
}
|
| 230 |
}
|
TaskTrackingSystem.WebApp/Components/Layout/NavMenu.razor
CHANGED
|
@@ -109,14 +109,13 @@
|
|
| 109 |
|
| 110 |
private List<MenuDto> ProcessMenus(List<MenuDto> rawMenus)
|
| 111 |
{
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
return result;
|
| 120 |
}
|
| 121 |
|
| 122 |
private async Task RefreshMenusAsync()
|
|
|
|
| 109 |
|
| 110 |
private List<MenuDto> ProcessMenus(List<MenuDto> rawMenus)
|
| 111 |
{
|
| 112 |
+
return rawMenus
|
| 113 |
+
.Where(menu => !string.Equals(menu.MenuCode, "ROLE_LAYOUTS", StringComparison.OrdinalIgnoreCase) &&
|
| 114 |
+
!string.Equals(menu.MenuName, "Role Layouts", StringComparison.OrdinalIgnoreCase))
|
| 115 |
+
.OrderBy(menu => string.Equals(menu.MenuCode, "DASHBOARD", StringComparison.OrdinalIgnoreCase) ? 0 : 1)
|
| 116 |
+
.ThenBy(menu => menu.OrderNo)
|
| 117 |
+
.ThenBy(menu => menu.MenuName)
|
| 118 |
+
.ToList();
|
|
|
|
| 119 |
}
|
| 120 |
|
| 121 |
private async Task RefreshMenusAsync()
|
TaskTrackingSystem.WebApp/Components/Pages/EmployeeDashboard.razor
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@page "/dashboard/employee"
|
| 2 |
+
@using Microsoft.AspNetCore.Authorization
|
| 3 |
+
@rendermode @(new InteractiveServerRenderMode(prerender: false))
|
| 4 |
+
@attribute [Authorize(Roles = "Employee")]
|
| 5 |
+
|
| 6 |
+
<PageTitle>Employee Dashboard</PageTitle>
|
| 7 |
+
|
| 8 |
+
<div class="space-y-6 text-slate-900">
|
| 9 |
+
<div class="rounded-[32px] border border-slate-200 bg-white px-8 py-8 shadow-sm shadow-slate-200/60">
|
| 10 |
+
<p class="text-sm font-semibold uppercase tracking-[0.22em] text-emerald-600">Work View</p>
|
| 11 |
+
<h2 class="mt-2 text-4xl font-bold tracking-tight text-slate-900">Welcome back, team member</h2>
|
| 12 |
+
<p class="mt-3 max-w-2xl text-sm text-slate-600">
|
| 13 |
+
This dashboard is reserved for personal tasks, due dates, and daily progress.
|
| 14 |
+
</p>
|
| 15 |
+
</div>
|
| 16 |
+
|
| 17 |
+
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
| 18 |
+
<div class="rounded-[24px] border border-slate-200 bg-white p-5 shadow-sm shadow-slate-200/50">
|
| 19 |
+
<p class="text-sm font-semibold text-slate-500">In Progress</p>
|
| 20 |
+
<p class="mt-4 text-4xl font-bold text-slate-900">--</p>
|
| 21 |
+
<p class="mt-2 text-sm text-slate-500">Placeholder metric</p>
|
| 22 |
+
</div>
|
| 23 |
+
<div class="rounded-[24px] border border-slate-200 bg-white p-5 shadow-sm shadow-slate-200/50">
|
| 24 |
+
<p class="text-sm font-semibold text-slate-500">To Do</p>
|
| 25 |
+
<p class="mt-4 text-4xl font-bold text-slate-900">--</p>
|
| 26 |
+
<p class="mt-2 text-sm text-slate-500">Placeholder metric</p>
|
| 27 |
+
</div>
|
| 28 |
+
<div class="rounded-[24px] border border-slate-200 bg-white p-5 shadow-sm shadow-slate-200/50">
|
| 29 |
+
<p class="text-sm font-semibold text-slate-500">Due Soon</p>
|
| 30 |
+
<p class="mt-4 text-4xl font-bold text-slate-900">--</p>
|
| 31 |
+
<p class="mt-2 text-sm text-slate-500">Placeholder metric</p>
|
| 32 |
+
</div>
|
| 33 |
+
<div class="rounded-[24px] border border-slate-200 bg-white p-5 shadow-sm shadow-slate-200/50">
|
| 34 |
+
<p class="text-sm font-semibold text-slate-500">Completed</p>
|
| 35 |
+
<p class="mt-4 text-4xl font-bold text-slate-900">--</p>
|
| 36 |
+
<p class="mt-2 text-sm text-slate-500">Placeholder metric</p>
|
| 37 |
+
</div>
|
| 38 |
+
</div>
|
| 39 |
+
|
| 40 |
+
<div class="rounded-[28px] border border-slate-200 bg-white p-6 shadow-sm shadow-slate-200/60">
|
| 41 |
+
<h3 class="text-2xl font-bold text-slate-900">Employee dashboard shell</h3>
|
| 42 |
+
<p class="mt-2 text-sm text-slate-500">
|
| 43 |
+
We’ll plug the real task list, calendar, and progress widgets into this page next.
|
| 44 |
+
</p>
|
| 45 |
+
</div>
|
| 46 |
+
</div>
|
TaskTrackingSystem.WebApp/Components/Pages/Home.razor
CHANGED
|
@@ -3,28 +3,28 @@
|
|
| 3 |
@page "/dashboard"
|
| 4 |
@using Microsoft.AspNetCore.Authorization
|
| 5 |
@using Microsoft.AspNetCore.Components.Authorization
|
|
|
|
|
|
|
| 6 |
@using TaskTrackingSystem.Shared.Models.Task
|
| 7 |
@using TaskTrackingSystem.Shared.Models.User
|
| 8 |
-
@using TaskTrackingSystem.Shared.Models.Project
|
| 9 |
@using TaskTrackingSystem.Shared.Models.Dashboard
|
| 10 |
-
@using TaskTrackingSystem.Shared.Enums
|
| 11 |
@rendermode @(new InteractiveServerRenderMode(prerender: false))
|
| 12 |
-
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
| 13 |
@inject ApiClientService ApiClient
|
| 14 |
@inject IJSRuntime JS
|
| 15 |
@inject AuthenticationStateProvider AuthStateProvider
|
| 16 |
@inject MenuAuthorizationService MenuAuthorization
|
| 17 |
|
| 18 |
-
<PageTitle>
|
| 19 |
|
| 20 |
<div class="space-y-8 text-slate-900">
|
| 21 |
<div class="rounded-[32px] border border-slate-200 bg-white px-8 py-8 text-slate-900 shadow-sm shadow-slate-200/60">
|
| 22 |
<div class="flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
|
| 23 |
<div>
|
| 24 |
<p class="text-sm font-semibold uppercase tracking-[0.22em] text-violet-600">Overview</p>
|
| 25 |
-
<h2 class="mt-2 text-4xl font-bold tracking-tight text-slate-900">Welcome back, @
|
| 26 |
<p class="mt-3 max-w-2xl text-sm text-slate-600">
|
| 27 |
-
Projects,
|
| 28 |
</p>
|
| 29 |
</div>
|
| 30 |
<div class="flex flex-wrap gap-3">
|
|
@@ -49,7 +49,7 @@
|
|
| 49 |
<div class="flex h-12 w-12 items-center justify-center rounded-2xl bg-violet-500 text-white">
|
| 50 |
<i data-lucide="users" class="h-5 w-5"></i>
|
| 51 |
</div>
|
| 52 |
-
<span class="text-sm font-semibold text-emerald-500">
|
| 53 |
</div>
|
| 54 |
<p class="mt-5 text-4xl font-bold">@summary.TotalUsers</p>
|
| 55 |
<p class="mt-2 text-sm text-slate-500">Total users</p>
|
|
@@ -60,9 +60,9 @@
|
|
| 60 |
<div class="flex h-12 w-12 items-center justify-center rounded-2xl bg-sky-500 text-white">
|
| 61 |
<i data-lucide="folder-kanban" class="h-5 w-5"></i>
|
| 62 |
</div>
|
| 63 |
-
<span class="text-sm font-semibold text-emerald-500">
|
| 64 |
</div>
|
| 65 |
-
<p class="mt-5 text-4xl font-bold">@
|
| 66 |
<p class="mt-2 text-sm text-slate-500">Total projects</p>
|
| 67 |
</div>
|
| 68 |
|
|
@@ -71,7 +71,7 @@
|
|
| 71 |
<div class="flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-500 text-white">
|
| 72 |
<i data-lucide="check-square" class="h-5 w-5"></i>
|
| 73 |
</div>
|
| 74 |
-
<span class="text-sm font-semibold text-emerald-500">
|
| 75 |
</div>
|
| 76 |
<p class="mt-5 text-4xl font-bold">@ActiveTasksCount</p>
|
| 77 |
<p class="mt-2 text-sm text-slate-500">Active tasks</p>
|
|
@@ -82,7 +82,7 @@
|
|
| 82 |
<div class="flex h-12 w-12 items-center justify-center rounded-2xl bg-amber-500 text-white">
|
| 83 |
<i data-lucide="trending-up" class="h-5 w-5"></i>
|
| 84 |
</div>
|
| 85 |
-
<span class="text-sm font-semibold text-emerald-500">
|
| 86 |
</div>
|
| 87 |
<p class="mt-5 text-4xl font-bold">@CompletionRate%</p>
|
| 88 |
<p class="mt-2 text-sm text-slate-500">Completion rate</p>
|
|
@@ -94,41 +94,59 @@
|
|
| 94 |
<div class="flex items-start justify-between gap-4">
|
| 95 |
<div>
|
| 96 |
<h3 class="text-2xl font-bold text-slate-900">Project Activity</h3>
|
| 97 |
-
<p class="mt-1 text-sm text-slate-500">
|
| 98 |
</div>
|
| 99 |
<div class="text-sm text-slate-500">@ActivityRangeLabel</div>
|
| 100 |
</div>
|
| 101 |
|
| 102 |
<div class="mt-6">
|
| 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 |
-
</svg>
|
| 132 |
|
| 133 |
<div class="mt-4 flex items-center justify-between text-xs text-slate-500">
|
| 134 |
@foreach (var label in ActivityLabels)
|
|
@@ -147,8 +165,10 @@
|
|
| 147 |
<div class="relative">
|
| 148 |
<svg viewBox="0 0 220 220" class="h-56 w-56">
|
| 149 |
<circle cx="110" cy="110" r="74" fill="none" stroke="#e5e7eb" stroke-width="24" />
|
| 150 |
-
@
|
| 151 |
{
|
|
|
|
|
|
|
| 152 |
<circle cx="110" cy="110" r="74"
|
| 153 |
fill="none"
|
| 154 |
stroke="@slice.Color"
|
|
@@ -156,19 +176,35 @@
|
|
| 156 |
stroke-linecap="round"
|
| 157 |
stroke-dasharray="@slice.DashArray"
|
| 158 |
stroke-dashoffset="@slice.DashOffset"
|
| 159 |
-
transform="rotate(-90 110 110)"
|
|
|
|
|
|
|
| 160 |
}
|
| 161 |
</svg>
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
</div>
|
| 167 |
</div>
|
| 168 |
|
| 169 |
<div class="mt-3 space-y-3">
|
| 170 |
-
@
|
| 171 |
{
|
|
|
|
| 172 |
<div class="flex items-center justify-between rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3">
|
| 173 |
<div class="flex items-center gap-3">
|
| 174 |
<span class="h-3 w-3 rounded-full" style="background-color: @item.Color"></span>
|
|
@@ -181,54 +217,138 @@
|
|
| 181 |
</div>
|
| 182 |
</div>
|
| 183 |
|
| 184 |
-
<div class="grid grid-cols-1 gap-5 xl:grid-cols-
|
| 185 |
-
<div class="rounded-[28px] border border-slate-200 bg-white p-6 text-slate-900 shadow-sm shadow-slate-200/60
|
| 186 |
-
<div class="flex items-center justify-between">
|
| 187 |
<div>
|
| 188 |
-
<h3 class="text-
|
| 189 |
-
<p class="mt-1 text-sm text-slate-500">
|
| 190 |
</div>
|
|
|
|
| 191 |
</div>
|
| 192 |
|
| 193 |
-
<div class="mt-5
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
</div>
|
| 202 |
-
<span class="text-xs font-semibold text-slate-600">@TaskCountForProject(project.Id) tasks</span>
|
| 203 |
</div>
|
| 204 |
-
|
| 205 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
</div>
|
| 207 |
</div>
|
| 208 |
|
| 209 |
<div class="rounded-[28px] border border-slate-200 bg-white p-6 text-slate-900 shadow-sm shadow-slate-200/60">
|
| 210 |
-
<
|
| 211 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
|
| 213 |
-
<div class="mt-
|
| 214 |
-
@foreach (var
|
| 215 |
{
|
| 216 |
-
<div>
|
| 217 |
-
<div class="flex
|
| 218 |
-
<
|
| 219 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
</div>
|
| 221 |
-
|
| 222 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
</div>
|
| 224 |
</div>
|
| 225 |
}
|
| 226 |
</div>
|
|
|
|
|
|
|
| 227 |
|
| 228 |
-
|
| 229 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
</a>
|
| 231 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
</div>
|
| 233 |
</div>
|
| 234 |
|
|
@@ -240,26 +360,40 @@
|
|
| 240 |
private List<UserDto> usersList = new();
|
| 241 |
private List<ProjectDto> projectsList = new();
|
| 242 |
private string currentUserName = "User";
|
|
|
|
| 243 |
private bool canCreateTask = false;
|
| 244 |
|
| 245 |
-
private
|
|
|
|
| 246 |
|
| 247 |
-
private
|
| 248 |
-
GetActivityMonths();
|
| 249 |
|
| 250 |
-
private
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
private string ActivityLinePath => BuildActivityPath(ActivityMonths
|
| 254 |
-
private string ActivityAreaPath =>
|
| 255 |
-
private
|
| 256 |
-
private
|
| 257 |
-
private
|
| 258 |
private int TotalTasksCount => taskList.Count;
|
| 259 |
private int ActiveTasksCount => taskList.Count(t => t.StatusId != AppTaskStatus.Done);
|
| 260 |
private int CompletedTasksCount => taskList.Count(t => t.StatusId == AppTaskStatus.Done);
|
| 261 |
private int CompletionRate => TotalTasksCount > 0 ? (int)Math.Round((double)CompletedTasksCount / TotalTasksCount * 100) : 0;
|
| 262 |
-
private
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
|
| 264 |
protected override async Task OnInitializedAsync()
|
| 265 |
{
|
|
@@ -276,6 +410,7 @@
|
|
| 276 |
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
| 277 |
var user = authState.User;
|
| 278 |
currentUserName = user.Identity?.Name ?? "User";
|
|
|
|
| 279 |
var summaryResult = await client.GetFromJsonAsync<Result<DashboardSummaryDto>>("Dashboard/summary", Serialization.CaseInsensitive);
|
| 280 |
if (summaryResult?.IsSuccess == true && summaryResult.Value != null)
|
| 281 |
{
|
|
@@ -297,6 +432,8 @@
|
|
| 297 |
taskList = await client.GetFromJsonAsync<List<TaskDto>>("Task", Serialization.CaseInsensitive) ?? new();
|
| 298 |
usersList = await client.GetFromJsonAsync<List<UserDto>>("User", Serialization.CaseInsensitive) ?? new();
|
| 299 |
projectsList = await client.GetFromJsonAsync<List<ProjectDto>>("Project", Serialization.CaseInsensitive) ?? new();
|
|
|
|
|
|
|
| 300 |
}
|
| 301 |
catch (Exception ex)
|
| 302 |
{
|
|
@@ -309,27 +446,44 @@
|
|
| 309 |
await JS.InvokeVoidAsync("initIcons");
|
| 310 |
}
|
| 311 |
|
| 312 |
-
private
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
|
| 314 |
-
private
|
| 315 |
{
|
| 316 |
-
var months = new List<ActivityMonth>();
|
| 317 |
var now = DateTime.Today;
|
| 318 |
var start = new DateTime(now.Year, now.Month, 1).AddMonths(-5);
|
|
|
|
| 319 |
|
| 320 |
for (var i = 0; i < 6; i++)
|
| 321 |
{
|
| 322 |
var monthStart = start.AddMonths(i);
|
| 323 |
var nextMonth = monthStart.AddMonths(1);
|
| 324 |
-
var
|
| 325 |
-
|
| 326 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 327 |
}
|
| 328 |
|
| 329 |
return months;
|
| 330 |
}
|
| 331 |
|
| 332 |
-
private string BuildActivityPath(IReadOnlyList<
|
| 333 |
{
|
| 334 |
if (months.Count == 0)
|
| 335 |
{
|
|
@@ -345,35 +499,56 @@
|
|
| 345 |
double X(int i) => left + (i * ((right - left) / (months.Count - 1)));
|
| 346 |
double Y(double value) => bottom - Math.Min(value, maxValue) / maxValue * (bottom - top);
|
| 347 |
|
| 348 |
-
var values = months.Select(m => (double)m.Created).ToList();
|
| 349 |
var points = new List<string>();
|
| 350 |
-
|
| 351 |
for (var i = 0; i < months.Count; i++)
|
| 352 |
{
|
| 353 |
-
points.Add($"{X(i):0},{Y(
|
| 354 |
}
|
| 355 |
|
| 356 |
-
var
|
| 357 |
-
|
| 358 |
{
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 363 |
}
|
| 364 |
|
| 365 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 366 |
{
|
| 367 |
-
|
| 368 |
}
|
| 369 |
|
| 370 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 371 |
}
|
| 372 |
|
| 373 |
-
private
|
| 374 |
{
|
| 375 |
-
var points = new List<ActivityPoint>();
|
| 376 |
var months = ActivityMonths;
|
|
|
|
| 377 |
if (months.Count == 0)
|
| 378 |
{
|
| 379 |
return points;
|
|
@@ -383,28 +558,21 @@
|
|
| 383 |
const double right = 730;
|
| 384 |
const double top = 48;
|
| 385 |
const double bottom = 280;
|
| 386 |
-
var maxValue = Math.Max(months.Max(m => m.
|
|
|
|
| 387 |
double X(int i) => left + (i * ((right - left) / (months.Count - 1)));
|
| 388 |
double Y(double value) => bottom - Math.Min(value, maxValue) / (double)maxValue * (bottom - top);
|
| 389 |
|
| 390 |
for (var i = 0; i < months.Count; i++)
|
| 391 |
{
|
| 392 |
-
points.Add(new ActivityPoint(X(i), Y(months[i].
|
| 393 |
}
|
| 394 |
|
| 395 |
return points;
|
| 396 |
}
|
| 397 |
|
| 398 |
-
private
|
| 399 |
{
|
| 400 |
-
var colors = new[]
|
| 401 |
-
{
|
| 402 |
-
"#10b981",
|
| 403 |
-
"#8b5cf6",
|
| 404 |
-
"#06b6d4",
|
| 405 |
-
"#ef4444"
|
| 406 |
-
};
|
| 407 |
-
|
| 408 |
var counts = new[]
|
| 409 |
{
|
| 410 |
CompletedTasksCount,
|
|
@@ -413,6 +581,8 @@
|
|
| 413 |
Math.Max(taskList.Count(t => t.DueDate.Date < DateTime.Today && t.StatusId != AppTaskStatus.Done), 0)
|
| 414 |
};
|
| 415 |
|
|
|
|
|
|
|
| 416 |
var total = counts.Sum();
|
| 417 |
if (total <= 0)
|
| 418 |
{
|
|
@@ -422,53 +592,322 @@
|
|
| 422 |
var circumference = 2 * Math.PI * 74;
|
| 423 |
var slices = new List<DistributionSlice>();
|
| 424 |
var offset = 0.0;
|
|
|
|
| 425 |
for (var i = 0; i < counts.Length; i++)
|
| 426 |
{
|
| 427 |
-
var
|
| 428 |
-
var fraction = value / (double)total;
|
| 429 |
var dash = $"{circumference * fraction:0.##} {circumference:0.##}";
|
| 430 |
var dashOffset = circumference - (circumference * offset);
|
| 431 |
-
slices.Add(new DistributionSlice(colors[i], dash, dashOffset));
|
| 432 |
offset += fraction;
|
| 433 |
}
|
|
|
|
| 434 |
return slices;
|
| 435 |
}
|
| 436 |
|
| 437 |
-
private
|
| 438 |
{
|
| 439 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 440 |
{
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 446 |
}
|
| 447 |
|
| 448 |
-
private
|
| 449 |
{
|
| 450 |
-
|
| 451 |
-
.Where(
|
| 452 |
-
.
|
|
|
|
|
|
|
| 453 |
.ToList();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 454 |
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
|
|
|
| 458 |
{
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 465 |
}
|
| 466 |
|
| 467 |
-
private
|
| 468 |
-
taskList.Count(task => task.ProjectId == projectId);
|
| 469 |
|
| 470 |
-
private readonly record struct
|
| 471 |
-
private readonly record struct ActivityPoint(double X, double Y);
|
| 472 |
-
private readonly record struct DistributionSlice(string Color, string DashArray, double DashOffset);
|
| 473 |
-
private readonly record struct
|
|
|
|
|
|
|
|
|
|
| 474 |
}
|
|
|
|
| 3 |
@page "/dashboard"
|
| 4 |
@using Microsoft.AspNetCore.Authorization
|
| 5 |
@using Microsoft.AspNetCore.Components.Authorization
|
| 6 |
+
@using TaskTrackingSystem.Shared.Enums
|
| 7 |
+
@using TaskTrackingSystem.Shared.Models.Project
|
| 8 |
@using TaskTrackingSystem.Shared.Models.Task
|
| 9 |
@using TaskTrackingSystem.Shared.Models.User
|
|
|
|
| 10 |
@using TaskTrackingSystem.Shared.Models.Dashboard
|
|
|
|
| 11 |
@rendermode @(new InteractiveServerRenderMode(prerender: false))
|
| 12 |
+
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Roles = "Admin")]
|
| 13 |
@inject ApiClientService ApiClient
|
| 14 |
@inject IJSRuntime JS
|
| 15 |
@inject AuthenticationStateProvider AuthStateProvider
|
| 16 |
@inject MenuAuthorizationService MenuAuthorization
|
| 17 |
|
| 18 |
+
<PageTitle>Dashboard</PageTitle>
|
| 19 |
|
| 20 |
<div class="space-y-8 text-slate-900">
|
| 21 |
<div class="rounded-[32px] border border-slate-200 bg-white px-8 py-8 text-slate-900 shadow-sm shadow-slate-200/60">
|
| 22 |
<div class="flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
|
| 23 |
<div>
|
| 24 |
<p class="text-sm font-semibold uppercase tracking-[0.22em] text-violet-600">Overview</p>
|
| 25 |
+
<h2 class="mt-2 text-4xl font-bold tracking-tight text-slate-900">Welcome back, @currentUserFullName</h2>
|
| 26 |
<p class="mt-3 max-w-2xl text-sm text-slate-600">
|
| 27 |
+
Projects, users, and team progress at a glance.
|
| 28 |
</p>
|
| 29 |
</div>
|
| 30 |
<div class="flex flex-wrap gap-3">
|
|
|
|
| 49 |
<div class="flex h-12 w-12 items-center justify-center rounded-2xl bg-violet-500 text-white">
|
| 50 |
<i data-lucide="users" class="h-5 w-5"></i>
|
| 51 |
</div>
|
| 52 |
+
<span class="text-sm font-semibold text-emerald-500">@UsersDeltaText</span>
|
| 53 |
</div>
|
| 54 |
<p class="mt-5 text-4xl font-bold">@summary.TotalUsers</p>
|
| 55 |
<p class="mt-2 text-sm text-slate-500">Total users</p>
|
|
|
|
| 60 |
<div class="flex h-12 w-12 items-center justify-center rounded-2xl bg-sky-500 text-white">
|
| 61 |
<i data-lucide="folder-kanban" class="h-5 w-5"></i>
|
| 62 |
</div>
|
| 63 |
+
<span class="text-sm font-semibold text-emerald-500">@ProjectsDeltaText</span>
|
| 64 |
</div>
|
| 65 |
+
<p class="mt-5 text-4xl font-bold">@projectsList.Count</p>
|
| 66 |
<p class="mt-2 text-sm text-slate-500">Total projects</p>
|
| 67 |
</div>
|
| 68 |
|
|
|
|
| 71 |
<div class="flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-500 text-white">
|
| 72 |
<i data-lucide="check-square" class="h-5 w-5"></i>
|
| 73 |
</div>
|
| 74 |
+
<span class="text-sm font-semibold text-emerald-500">@TasksDeltaText</span>
|
| 75 |
</div>
|
| 76 |
<p class="mt-5 text-4xl font-bold">@ActiveTasksCount</p>
|
| 77 |
<p class="mt-2 text-sm text-slate-500">Active tasks</p>
|
|
|
|
| 82 |
<div class="flex h-12 w-12 items-center justify-center rounded-2xl bg-amber-500 text-white">
|
| 83 |
<i data-lucide="trending-up" class="h-5 w-5"></i>
|
| 84 |
</div>
|
| 85 |
+
<span class="text-sm font-semibold text-emerald-500">@CompletionDeltaText</span>
|
| 86 |
</div>
|
| 87 |
<p class="mt-5 text-4xl font-bold">@CompletionRate%</p>
|
| 88 |
<p class="mt-2 text-sm text-slate-500">Completion rate</p>
|
|
|
|
| 94 |
<div class="flex items-start justify-between gap-4">
|
| 95 |
<div>
|
| 96 |
<h3 class="text-2xl font-bold text-slate-900">Project Activity</h3>
|
| 97 |
+
<p class="mt-1 text-sm text-slate-500">Active vs completed tasks per month.</p>
|
| 98 |
</div>
|
| 99 |
<div class="text-sm text-slate-500">@ActivityRangeLabel</div>
|
| 100 |
</div>
|
| 101 |
|
| 102 |
<div class="mt-6">
|
| 103 |
+
<div class="relative">
|
| 104 |
+
<svg viewBox="0 0 760 340" class="h-[340px] w-full">
|
| 105 |
+
<defs>
|
| 106 |
+
<linearGradient id="activityFill" x1="0" x2="0" y1="0" y2="1">
|
| 107 |
+
<stop offset="0%" stop-color="#7c3aed" stop-opacity="0.18" />
|
| 108 |
+
<stop offset="100%" stop-color="#7c3aed" stop-opacity="0" />
|
| 109 |
+
</linearGradient>
|
| 110 |
+
</defs>
|
| 111 |
+
|
| 112 |
+
@for (var i = 0; i < 5; i++)
|
| 113 |
+
{
|
| 114 |
+
var y = 60 + (i * 55);
|
| 115 |
+
<line x1="48" y1="@y" x2="730" y2="@y" stroke="#e2e8f0" stroke-width="1" />
|
| 116 |
+
}
|
| 117 |
|
| 118 |
+
@for (var i = 0; i < 6; i++)
|
| 119 |
+
{
|
| 120 |
+
var x = 60 + (i * 125);
|
| 121 |
+
<line x1="@x" y1="40" x2="@x" y2="280" stroke="#e2e8f0" stroke-width="1" />
|
| 122 |
+
}
|
| 123 |
|
| 124 |
+
<path d="@ActivityAreaPath" fill="url(#activityFill)" />
|
| 125 |
+
<path d="@ActivityLinePath" fill="none" stroke="#7c3aed" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" />
|
| 126 |
|
| 127 |
+
@for (var i = 0; i < ActivityMonths.Count; i++)
|
| 128 |
+
{
|
| 129 |
+
var index = i;
|
| 130 |
+
var point = ActivityPoints[i];
|
| 131 |
+
<circle cx="@point.X" cy="@point.Y" r="5"
|
| 132 |
+
fill="#7c3aed"
|
| 133 |
+
stroke="#ddd6fe"
|
| 134 |
+
stroke-width="3"
|
| 135 |
+
@onmouseenter="() => SetHoveredActivity(index)"
|
| 136 |
+
@onmouseleave="ClearHoveredActivity" />
|
| 137 |
+
}
|
| 138 |
+
</svg>
|
| 139 |
+
|
| 140 |
+
@if (hoveredActivity is not null)
|
| 141 |
{
|
| 142 |
+
<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"
|
| 143 |
+
style="@HoveredActivityStyle">
|
| 144 |
+
<div class="text-sm font-semibold text-slate-900">@hoveredActivity.Value.Label</div>
|
| 145 |
+
<div class="mt-2 text-sm text-emerald-600">Completed: @hoveredActivity.Value.Completed</div>
|
| 146 |
+
<div class="mt-1 text-sm text-violet-600">Active: @hoveredActivity.Value.Active</div>
|
| 147 |
+
</div>
|
| 148 |
}
|
| 149 |
+
</div>
|
|
|
|
| 150 |
|
| 151 |
<div class="mt-4 flex items-center justify-between text-xs text-slate-500">
|
| 152 |
@foreach (var label in ActivityLabels)
|
|
|
|
| 165 |
<div class="relative">
|
| 166 |
<svg viewBox="0 0 220 220" class="h-56 w-56">
|
| 167 |
<circle cx="110" cy="110" r="74" fill="none" stroke="#e5e7eb" stroke-width="24" />
|
| 168 |
+
@for (var i = 0; i < DonutSlices.Count; i++)
|
| 169 |
{
|
| 170 |
+
var index = i;
|
| 171 |
+
var slice = DonutSlices[i];
|
| 172 |
<circle cx="110" cy="110" r="74"
|
| 173 |
fill="none"
|
| 174 |
stroke="@slice.Color"
|
|
|
|
| 176 |
stroke-linecap="round"
|
| 177 |
stroke-dasharray="@slice.DashArray"
|
| 178 |
stroke-dashoffset="@slice.DashOffset"
|
| 179 |
+
transform="rotate(-90 110 110)"
|
| 180 |
+
@onmouseenter="() => SetHoveredSlice(index)"
|
| 181 |
+
@onmouseleave="ClearHoveredSlice" />
|
| 182 |
}
|
| 183 |
</svg>
|
| 184 |
+
|
| 185 |
+
@if (hoveredSlice is not null)
|
| 186 |
+
{
|
| 187 |
+
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
|
| 188 |
+
<div class="rounded-2xl border border-slate-200 bg-white px-4 py-3 shadow-lg shadow-slate-200/70">
|
| 189 |
+
<div class="text-sm font-semibold text-slate-900">@hoveredSlice.Value.Label</div>
|
| 190 |
+
<div class="mt-1 text-sm text-slate-500">@hoveredSlice.Value.Count tasks</div>
|
| 191 |
+
</div>
|
| 192 |
+
</div>
|
| 193 |
+
}
|
| 194 |
+
else
|
| 195 |
+
{
|
| 196 |
+
<div class="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
|
| 197 |
+
<div class="text-3xl font-bold text-slate-900">@TotalTasksCount</div>
|
| 198 |
+
<div class="text-xs text-slate-500">tasks</div>
|
| 199 |
+
</div>
|
| 200 |
+
}
|
| 201 |
</div>
|
| 202 |
</div>
|
| 203 |
|
| 204 |
<div class="mt-3 space-y-3">
|
| 205 |
+
@for (var i = 0; i < DistributionLegend.Count; i++)
|
| 206 |
{
|
| 207 |
+
var item = DistributionLegend[i];
|
| 208 |
<div class="flex items-center justify-between rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3">
|
| 209 |
<div class="flex items-center gap-3">
|
| 210 |
<span class="h-3 w-3 rounded-full" style="background-color: @item.Color"></span>
|
|
|
|
| 217 |
</div>
|
| 218 |
</div>
|
| 219 |
|
| 220 |
+
<div class="grid grid-cols-1 gap-5 xl:grid-cols-[minmax(0,1.25fr)_minmax(340px,0.75fr)]">
|
| 221 |
+
<div class="rounded-[28px] border border-slate-200 bg-white p-6 text-slate-900 shadow-sm shadow-slate-200/60">
|
| 222 |
+
<div class="flex items-center justify-between gap-4">
|
| 223 |
<div>
|
| 224 |
+
<h3 class="text-2xl font-bold text-slate-900">Project Timeline</h3>
|
| 225 |
+
<p class="mt-1 text-sm text-slate-500">Showcasing five projects across the current timeline.</p>
|
| 226 |
</div>
|
| 227 |
+
<div class="text-sm text-slate-500">@TimelineRangeLabel</div>
|
| 228 |
</div>
|
| 229 |
|
| 230 |
+
<div class="mt-5 overflow-hidden rounded-2xl border border-slate-200 bg-slate-50">
|
| 231 |
+
<div class="grid border-b border-slate-200 bg-white text-center text-xs font-semibold uppercase tracking-wider text-slate-500" style="@TimelineColumnsStyle">
|
| 232 |
+
<div class="px-3 py-3 text-left">Project</div>
|
| 233 |
+
@foreach (var month in TimelineMonths)
|
| 234 |
+
{
|
| 235 |
+
<div class="px-2 py-3">@month.ToString("MMM")</div>
|
| 236 |
+
}
|
| 237 |
+
</div>
|
| 238 |
+
|
| 239 |
+
<div class="divide-y divide-slate-200">
|
| 240 |
+
@foreach (var row in TimelineRows)
|
| 241 |
+
{
|
| 242 |
+
<div class="grid items-center gap-0 px-3 py-4" style="@TimelineColumnsStyle">
|
| 243 |
+
<div class="pr-3">
|
| 244 |
+
<div class="text-sm font-semibold text-slate-900">@row.Name</div>
|
| 245 |
+
<div class="mt-1 text-xs text-slate-500">@row.DateLabel</div>
|
| 246 |
+
</div>
|
| 247 |
+
<div class="relative h-8" style="@TimelineRowSpanStyle">
|
| 248 |
+
<div class="absolute inset-y-3 left-0 right-0 rounded-full bg-slate-200"></div>
|
| 249 |
+
<div class="absolute inset-y-3 rounded-full" style="left: @row.Left%; width: @row.Width%; min-width: 28px; background-color: @row.Color;"></div>
|
| 250 |
+
<div class="absolute inset-y-3 left-0 right-0 border-l-2 border-violet-400/60" style="left: @TodayMarkerLeft;"></div>
|
| 251 |
</div>
|
|
|
|
| 252 |
</div>
|
| 253 |
+
}
|
| 254 |
+
</div>
|
| 255 |
+
|
| 256 |
+
<div class="flex flex-wrap items-center gap-4 px-4 py-4 text-xs text-slate-500">
|
| 257 |
+
<div class="flex items-center gap-2"><span class="h-4 w-1 rounded-full bg-violet-400"></span> Today</div>
|
| 258 |
+
<div class="flex items-center gap-2"><span class="h-4 w-4 rounded-full bg-slate-300"></span> Planned span</div>
|
| 259 |
+
<div class="flex items-center gap-2"><span class="h-4 w-4 rounded-full bg-sky-400"></span> Project window</div>
|
| 260 |
+
</div>
|
| 261 |
</div>
|
| 262 |
</div>
|
| 263 |
|
| 264 |
<div class="rounded-[28px] border border-slate-200 bg-white p-6 text-slate-900 shadow-sm shadow-slate-200/60">
|
| 265 |
+
<div class="flex items-start justify-between gap-4">
|
| 266 |
+
<div>
|
| 267 |
+
<h3 class="text-2xl font-bold text-slate-900">Calendar</h3>
|
| 268 |
+
<p class="mt-1 text-sm text-slate-500">Today and upcoming project starts.</p>
|
| 269 |
+
</div>
|
| 270 |
+
<div class="text-right">
|
| 271 |
+
<div class="text-sm font-semibold text-violet-600">@CalendarMonthLabel</div>
|
| 272 |
+
<div class="text-xs text-slate-500">@DateTime.Now.ToString("h:mm tt")</div>
|
| 273 |
+
</div>
|
| 274 |
+
</div>
|
| 275 |
+
|
| 276 |
+
<div class="mt-5 grid grid-cols-7 gap-2 text-center text-xs font-semibold uppercase tracking-wider text-slate-400">
|
| 277 |
+
@foreach (var day in WeekdayLabels)
|
| 278 |
+
{
|
| 279 |
+
<span>@day</span>
|
| 280 |
+
}
|
| 281 |
+
</div>
|
| 282 |
|
| 283 |
+
<div class="mt-3 grid grid-cols-7 gap-2">
|
| 284 |
+
@foreach (var cell in CalendarCells)
|
| 285 |
{
|
| 286 |
+
<div class="@GetCalendarCellClasses(cell)">
|
| 287 |
+
<div class="flex h-full flex-col justify-between">
|
| 288 |
+
<div class="text-sm font-semibold @(cell.IsCurrentMonth ? "text-slate-900" : "text-slate-400")">@cell.Date.Day</div>
|
| 289 |
+
<div class="flex items-center gap-1">
|
| 290 |
+
@foreach (var dot in cell.ProjectDots)
|
| 291 |
+
{
|
| 292 |
+
<span class="h-1.5 w-1.5 rounded-full" style="background-color: @dot;"></span>
|
| 293 |
+
}
|
| 294 |
+
</div>
|
| 295 |
</div>
|
| 296 |
+
</div>
|
| 297 |
+
}
|
| 298 |
+
</div>
|
| 299 |
+
|
| 300 |
+
<div class="mt-6 space-y-3">
|
| 301 |
+
<div class="text-xs font-semibold uppercase tracking-wider text-slate-400">Events</div>
|
| 302 |
+
@foreach (var item in UpcomingProjectStarts)
|
| 303 |
+
{
|
| 304 |
+
<div class="flex items-start gap-3 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3">
|
| 305 |
+
<span class="mt-1 h-8 w-1.5 rounded-full" style="background-color: @item.Color"></span>
|
| 306 |
+
<div class="min-w-0">
|
| 307 |
+
<div class="truncate text-sm font-semibold text-slate-900">@item.Name</div>
|
| 308 |
+
<div class="text-xs text-slate-500">@item.DateLabel</div>
|
| 309 |
</div>
|
| 310 |
</div>
|
| 311 |
}
|
| 312 |
</div>
|
| 313 |
+
</div>
|
| 314 |
+
</div>
|
| 315 |
|
| 316 |
+
<div class="rounded-[28px] border border-slate-200 bg-white p-6 text-slate-900 shadow-sm shadow-slate-200/60">
|
| 317 |
+
<div class="flex items-center justify-between gap-4">
|
| 318 |
+
<div>
|
| 319 |
+
<h3 class="text-2xl font-bold text-slate-900">All Projects</h3>
|
| 320 |
+
<p class="mt-1 text-sm text-slate-500">A quick snapshot of the current project list.</p>
|
| 321 |
+
</div>
|
| 322 |
+
<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-violet-50">
|
| 323 |
+
View all
|
| 324 |
</a>
|
| 325 |
</div>
|
| 326 |
+
|
| 327 |
+
<div class="mt-5 space-y-3">
|
| 328 |
+
@foreach (var project in FeaturedProjects)
|
| 329 |
+
{
|
| 330 |
+
var progress = GetProjectProgress(project.Id);
|
| 331 |
+
<div class="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-4">
|
| 332 |
+
<div class="flex items-start justify-between gap-4">
|
| 333 |
+
<div class="min-w-0">
|
| 334 |
+
<div class="flex flex-wrap items-center gap-2">
|
| 335 |
+
<p class="truncate text-sm font-semibold text-slate-900">@project.Name</p>
|
| 336 |
+
<span class="@GetProjectBadgeClasses(project)" >@GetProjectBadgeLabel(project)</span>
|
| 337 |
+
</div>
|
| 338 |
+
<p class="mt-1 text-xs text-slate-500">@DisplayFormats.Date(project.StartDate) to @DisplayFormats.Date(project.EndDate)</p>
|
| 339 |
+
</div>
|
| 340 |
+
<div class="text-right">
|
| 341 |
+
<div class="text-sm font-semibold text-slate-900">@progress.CompletedTasksCount/@progress.TotalTasksCount tasks</div>
|
| 342 |
+
<div class="text-xs text-slate-500">@progress.CompletionPercentage%</div>
|
| 343 |
+
</div>
|
| 344 |
+
</div>
|
| 345 |
+
|
| 346 |
+
<div class="mt-3 h-2 overflow-hidden rounded-full bg-slate-200">
|
| 347 |
+
<div class="h-full rounded-full bg-violet-600" style="width: @progress.CompletionPercentage%"></div>
|
| 348 |
+
</div>
|
| 349 |
+
</div>
|
| 350 |
+
}
|
| 351 |
+
</div>
|
| 352 |
</div>
|
| 353 |
</div>
|
| 354 |
|
|
|
|
| 360 |
private List<UserDto> usersList = new();
|
| 361 |
private List<ProjectDto> projectsList = new();
|
| 362 |
private string currentUserName = "User";
|
| 363 |
+
private string currentUserFullName = "User";
|
| 364 |
private bool canCreateTask = false;
|
| 365 |
|
| 366 |
+
private int? hoveredActivityIndex;
|
| 367 |
+
private int? hoveredSliceIndex;
|
| 368 |
|
| 369 |
+
private readonly string[] WeekdayLabels = ["M", "T", "W", "T", "F", "S", "S"];
|
|
|
|
| 370 |
|
| 371 |
+
private List<ActivityMonthStat> ActivityMonths => BuildActivityMonths();
|
| 372 |
+
private List<string> ActivityLabels => ActivityMonths.Select(month => month.Label).ToList();
|
| 373 |
+
private string ActivityRangeLabel => $"{ActivityMonths.First().Label} - {ActivityMonths.Last().Label}";
|
| 374 |
+
private string ActivityLinePath => BuildActivityPath(ActivityMonths);
|
| 375 |
+
private string ActivityAreaPath => BuildActivityAreaPath(ActivityMonths);
|
| 376 |
+
private List<ActivityPoint> ActivityPoints => BuildActivityPoints();
|
| 377 |
+
private List<DistributionSlice> DonutSlices => BuildDonutSlices();
|
| 378 |
+
private List<DistributionLegendItem> DistributionLegend => BuildDistributionLegend();
|
| 379 |
private int TotalTasksCount => taskList.Count;
|
| 380 |
private int ActiveTasksCount => taskList.Count(t => t.StatusId != AppTaskStatus.Done);
|
| 381 |
private int CompletedTasksCount => taskList.Count(t => t.StatusId == AppTaskStatus.Done);
|
| 382 |
private int CompletionRate => TotalTasksCount > 0 ? (int)Math.Round((double)CompletedTasksCount / TotalTasksCount * 100) : 0;
|
| 383 |
+
private string UsersDeltaText => BuildDeltaText(GetUsersThisMonth() - GetUsersPreviousMonth());
|
| 384 |
+
private string ProjectsDeltaText => BuildDeltaText(GetProjectsThisMonth() - GetProjectsPreviousMonth());
|
| 385 |
+
private string TasksDeltaText => BuildDeltaText(GetActiveTasksThisMonth() - GetActiveTasksPreviousMonth());
|
| 386 |
+
private string CompletionDeltaText => BuildCompletionDeltaText();
|
| 387 |
+
private string CalendarMonthLabel => DateTime.Today.ToString("MMMM yyyy");
|
| 388 |
+
private List<CalendarCell> CalendarCells => BuildCalendarCells();
|
| 389 |
+
private List<CalendarEventItem> UpcomingProjectStarts => BuildUpcomingProjectStarts();
|
| 390 |
+
private List<ProjectDto> FeaturedProjects => projectsList.OrderBy(p => p.StartDate).ThenByDescending(p => p.CreatedAt).Take(5).ToList();
|
| 391 |
+
private List<TimelineRow> TimelineRows => BuildTimelineRows();
|
| 392 |
+
private List<DateTime> TimelineMonths => BuildTimelineMonths();
|
| 393 |
+
private string TimelineRangeLabel => TimelineMonths.Count > 0 ? $"{TimelineMonths.First():MMM yyyy} - {TimelineMonths.Last():MMM yyyy}" : string.Empty;
|
| 394 |
+
private string TimelineColumnsStyle => $"grid-template-columns: 180px repeat({TimelineMonths.Count}, minmax(44px, 1fr));";
|
| 395 |
+
private string TimelineRowSpanStyle => $"grid-column: 2 / span {TimelineMonths.Count};";
|
| 396 |
+
private string TodayMarkerLeft => GetTimelineTodayMarkerLeft();
|
| 397 |
|
| 398 |
protected override async Task OnInitializedAsync()
|
| 399 |
{
|
|
|
|
| 410 |
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
| 411 |
var user = authState.User;
|
| 412 |
currentUserName = user.Identity?.Name ?? "User";
|
| 413 |
+
|
| 414 |
var summaryResult = await client.GetFromJsonAsync<Result<DashboardSummaryDto>>("Dashboard/summary", Serialization.CaseInsensitive);
|
| 415 |
if (summaryResult?.IsSuccess == true && summaryResult.Value != null)
|
| 416 |
{
|
|
|
|
| 432 |
taskList = await client.GetFromJsonAsync<List<TaskDto>>("Task", Serialization.CaseInsensitive) ?? new();
|
| 433 |
usersList = await client.GetFromJsonAsync<List<UserDto>>("User", Serialization.CaseInsensitive) ?? new();
|
| 434 |
projectsList = await client.GetFromJsonAsync<List<ProjectDto>>("Project", Serialization.CaseInsensitive) ?? new();
|
| 435 |
+
|
| 436 |
+
currentUserFullName = ResolveCurrentUserFullName();
|
| 437 |
}
|
| 438 |
catch (Exception ex)
|
| 439 |
{
|
|
|
|
| 446 |
await JS.InvokeVoidAsync("initIcons");
|
| 447 |
}
|
| 448 |
|
| 449 |
+
private string ResolveCurrentUserFullName()
|
| 450 |
+
{
|
| 451 |
+
var user = usersList.FirstOrDefault(u => string.Equals(u.Username, currentUserName, StringComparison.OrdinalIgnoreCase));
|
| 452 |
+
if (user != null)
|
| 453 |
+
{
|
| 454 |
+
return $"{user.FirstName} {user.LastName}".Trim();
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
return currentUserName;
|
| 458 |
+
}
|
| 459 |
|
| 460 |
+
private List<ActivityMonthStat> BuildActivityMonths()
|
| 461 |
{
|
|
|
|
| 462 |
var now = DateTime.Today;
|
| 463 |
var start = new DateTime(now.Year, now.Month, 1).AddMonths(-5);
|
| 464 |
+
var months = new List<ActivityMonthStat>();
|
| 465 |
|
| 466 |
for (var i = 0; i < 6; i++)
|
| 467 |
{
|
| 468 |
var monthStart = start.AddMonths(i);
|
| 469 |
var nextMonth = monthStart.AddMonths(1);
|
| 470 |
+
var active = taskList.Count(task =>
|
| 471 |
+
task.CreatedAt >= monthStart &&
|
| 472 |
+
task.CreatedAt < nextMonth &&
|
| 473 |
+
task.StatusId != AppTaskStatus.Done);
|
| 474 |
+
|
| 475 |
+
var completed = taskList.Count(task =>
|
| 476 |
+
task.CompletedAt.HasValue &&
|
| 477 |
+
task.CompletedAt.Value >= monthStart &&
|
| 478 |
+
task.CompletedAt.Value < nextMonth);
|
| 479 |
+
|
| 480 |
+
months.Add(new ActivityMonthStat(GetMonthLabel(monthStart), active, completed));
|
| 481 |
}
|
| 482 |
|
| 483 |
return months;
|
| 484 |
}
|
| 485 |
|
| 486 |
+
private string BuildActivityPath(IReadOnlyList<ActivityMonthStat> months)
|
| 487 |
{
|
| 488 |
if (months.Count == 0)
|
| 489 |
{
|
|
|
|
| 499 |
double X(int i) => left + (i * ((right - left) / (months.Count - 1)));
|
| 500 |
double Y(double value) => bottom - Math.Min(value, maxValue) / maxValue * (bottom - top);
|
| 501 |
|
|
|
|
| 502 |
var points = new List<string>();
|
|
|
|
| 503 |
for (var i = 0; i < months.Count; i++)
|
| 504 |
{
|
| 505 |
+
points.Add($"{X(i):0},{Y(months[i].Active):0}");
|
| 506 |
}
|
| 507 |
|
| 508 |
+
var path = $"M {points[0]}";
|
| 509 |
+
for (var i = 1; i < points.Count; i++)
|
| 510 |
{
|
| 511 |
+
path += $" L {points[i]}";
|
| 512 |
+
}
|
| 513 |
+
|
| 514 |
+
return path;
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
private string BuildActivityAreaPath(IReadOnlyList<ActivityMonthStat> months)
|
| 518 |
+
{
|
| 519 |
+
if (months.Count == 0)
|
| 520 |
+
{
|
| 521 |
+
return string.Empty;
|
| 522 |
}
|
| 523 |
|
| 524 |
+
const double left = 48;
|
| 525 |
+
const double right = 730;
|
| 526 |
+
const double top = 48;
|
| 527 |
+
const double bottom = 280;
|
| 528 |
+
const double maxValue = 20;
|
| 529 |
+
|
| 530 |
+
double X(int i) => left + (i * ((right - left) / (months.Count - 1)));
|
| 531 |
+
double Y(double value) => bottom - Math.Min(value, maxValue) / maxValue * (bottom - top);
|
| 532 |
+
|
| 533 |
+
var points = new List<string>();
|
| 534 |
+
for (var i = 0; i < months.Count; i++)
|
| 535 |
{
|
| 536 |
+
points.Add($"{X(i):0},{Y(months[i].Active):0}");
|
| 537 |
}
|
| 538 |
|
| 539 |
+
var path = $"M {points[0]}";
|
| 540 |
+
for (var i = 1; i < points.Count; i++)
|
| 541 |
+
{
|
| 542 |
+
path += $" L {points[i]}";
|
| 543 |
+
}
|
| 544 |
+
|
| 545 |
+
return $"{path} L {X(months.Count - 1):0},{bottom:0} L {X(0):0},{bottom:0} Z";
|
| 546 |
}
|
| 547 |
|
| 548 |
+
private List<ActivityPoint> BuildActivityPoints()
|
| 549 |
{
|
|
|
|
| 550 |
var months = ActivityMonths;
|
| 551 |
+
var points = new List<ActivityPoint>();
|
| 552 |
if (months.Count == 0)
|
| 553 |
{
|
| 554 |
return points;
|
|
|
|
| 558 |
const double right = 730;
|
| 559 |
const double top = 48;
|
| 560 |
const double bottom = 280;
|
| 561 |
+
var maxValue = Math.Max(months.Max(m => m.Active), 1);
|
| 562 |
+
|
| 563 |
double X(int i) => left + (i * ((right - left) / (months.Count - 1)));
|
| 564 |
double Y(double value) => bottom - Math.Min(value, maxValue) / (double)maxValue * (bottom - top);
|
| 565 |
|
| 566 |
for (var i = 0; i < months.Count; i++)
|
| 567 |
{
|
| 568 |
+
points.Add(new ActivityPoint(months[i].Label, X(i), Y(months[i].Active), months[i].Active, months[i].Completed));
|
| 569 |
}
|
| 570 |
|
| 571 |
return points;
|
| 572 |
}
|
| 573 |
|
| 574 |
+
private List<DistributionSlice> BuildDonutSlices()
|
| 575 |
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 576 |
var counts = new[]
|
| 577 |
{
|
| 578 |
CompletedTasksCount,
|
|
|
|
| 581 |
Math.Max(taskList.Count(t => t.DueDate.Date < DateTime.Today && t.StatusId != AppTaskStatus.Done), 0)
|
| 582 |
};
|
| 583 |
|
| 584 |
+
var colors = new[] { "#10b981", "#8b5cf6", "#06b6d4", "#ef4444" };
|
| 585 |
+
|
| 586 |
var total = counts.Sum();
|
| 587 |
if (total <= 0)
|
| 588 |
{
|
|
|
|
| 592 |
var circumference = 2 * Math.PI * 74;
|
| 593 |
var slices = new List<DistributionSlice>();
|
| 594 |
var offset = 0.0;
|
| 595 |
+
|
| 596 |
for (var i = 0; i < counts.Length; i++)
|
| 597 |
{
|
| 598 |
+
var fraction = counts[i] / (double)total;
|
|
|
|
| 599 |
var dash = $"{circumference * fraction:0.##} {circumference:0.##}";
|
| 600 |
var dashOffset = circumference - (circumference * offset);
|
| 601 |
+
slices.Add(new DistributionSlice(GetDistributionLabel(i), counts[i], colors[i], dash, dashOffset));
|
| 602 |
offset += fraction;
|
| 603 |
}
|
| 604 |
+
|
| 605 |
return slices;
|
| 606 |
}
|
| 607 |
|
| 608 |
+
private static string GetDistributionLabel(int index) => index switch
|
| 609 |
{
|
| 610 |
+
0 => "Completed",
|
| 611 |
+
1 => "In Progress",
|
| 612 |
+
2 => "To Do",
|
| 613 |
+
_ => "Overdue"
|
| 614 |
+
};
|
| 615 |
+
|
| 616 |
+
private List<DistributionLegendItem> BuildDistributionLegend()
|
| 617 |
+
{
|
| 618 |
+
return DonutSlices.Select(slice => new DistributionLegendItem(slice.Label, slice.Count, slice.Color)).ToList();
|
| 619 |
+
}
|
| 620 |
+
|
| 621 |
+
private List<CalendarCell> BuildCalendarCells()
|
| 622 |
+
{
|
| 623 |
+
var today = DateTime.Today;
|
| 624 |
+
var firstOfMonth = new DateTime(today.Year, today.Month, 1);
|
| 625 |
+
var start = firstOfMonth.AddDays(-(int)((((int)firstOfMonth.DayOfWeek + 6) % 7)));
|
| 626 |
+
var projectStarts = projectsList
|
| 627 |
+
.Select(project => new { project.StartDate, Color = GetProjectColor(project.Id) })
|
| 628 |
+
.Where(item => item.StartDate.Date >= start.Date && item.StartDate.Date < start.AddDays(42))
|
| 629 |
+
.ToList();
|
| 630 |
+
|
| 631 |
+
var cells = new List<CalendarCell>();
|
| 632 |
+
for (var i = 0; i < 42; i++)
|
| 633 |
{
|
| 634 |
+
var date = start.AddDays(i);
|
| 635 |
+
var markers = projectStarts
|
| 636 |
+
.Where(item => item.StartDate.Date == date.Date)
|
| 637 |
+
.Select(item => item.Color)
|
| 638 |
+
.ToList();
|
| 639 |
+
|
| 640 |
+
cells.Add(new CalendarCell(date, date.Month == today.Month, date.Date == today.Date, markers));
|
| 641 |
+
}
|
| 642 |
+
|
| 643 |
+
return cells;
|
| 644 |
}
|
| 645 |
|
| 646 |
+
private List<CalendarEventItem> BuildUpcomingProjectStarts()
|
| 647 |
{
|
| 648 |
+
return projectsList
|
| 649 |
+
.Where(project => project.StartDate.Date >= DateTime.Today)
|
| 650 |
+
.OrderBy(project => project.StartDate)
|
| 651 |
+
.Take(4)
|
| 652 |
+
.Select(project => new CalendarEventItem(project.Name, project.StartDate.ToString("MMM d"), GetProjectColor(project.Id)))
|
| 653 |
.ToList();
|
| 654 |
+
}
|
| 655 |
+
|
| 656 |
+
private List<DateTime> BuildTimelineMonths()
|
| 657 |
+
{
|
| 658 |
+
var featured = FeaturedProjects;
|
| 659 |
+
if (featured.Count == 0)
|
| 660 |
+
{
|
| 661 |
+
return new List<DateTime>();
|
| 662 |
+
}
|
| 663 |
+
|
| 664 |
+
var start = new DateTime(featured.Min(p => p.StartDate).Year, featured.Min(p => p.StartDate).Month, 1);
|
| 665 |
+
var end = new DateTime(featured.Max(p => p.EndDate).Year, featured.Max(p => p.EndDate).Month, 1);
|
| 666 |
+
|
| 667 |
+
var months = new List<DateTime>();
|
| 668 |
+
var current = start;
|
| 669 |
+
while (current <= end)
|
| 670 |
+
{
|
| 671 |
+
months.Add(current);
|
| 672 |
+
current = current.AddMonths(1);
|
| 673 |
+
}
|
| 674 |
+
|
| 675 |
+
return months;
|
| 676 |
+
}
|
| 677 |
+
|
| 678 |
+
private List<TimelineRow> BuildTimelineRows()
|
| 679 |
+
{
|
| 680 |
+
var months = TimelineMonths;
|
| 681 |
+
if (months.Count == 0)
|
| 682 |
+
{
|
| 683 |
+
return new List<TimelineRow>();
|
| 684 |
+
}
|
| 685 |
+
|
| 686 |
+
var first = months.First();
|
| 687 |
+
var monthSpan = Math.Max(months.Count - 1, 1);
|
| 688 |
+
|
| 689 |
+
return FeaturedProjects.Select(project =>
|
| 690 |
+
{
|
| 691 |
+
var startIndex = ((project.StartDate.Year - first.Year) * 12) + project.StartDate.Month - first.Month;
|
| 692 |
+
var endIndex = ((project.EndDate.Year - first.Year) * 12) + project.EndDate.Month - first.Month;
|
| 693 |
+
startIndex = Math.Clamp(startIndex, 0, monthSpan);
|
| 694 |
+
endIndex = Math.Clamp(endIndex, startIndex, monthSpan);
|
| 695 |
+
|
| 696 |
+
var left = (startIndex / (double)monthSpan) * 100;
|
| 697 |
+
var width = Math.Max(6, ((endIndex - startIndex + 1) / (double)(monthSpan + 1)) * 100);
|
| 698 |
+
var progress = GetProjectProgress(project.Id);
|
| 699 |
+
|
| 700 |
+
return new TimelineRow(
|
| 701 |
+
project.Name,
|
| 702 |
+
$"{DisplayFormats.Date(project.StartDate)} to {DisplayFormats.Date(project.EndDate)}",
|
| 703 |
+
GetProjectColor(project.Id),
|
| 704 |
+
left,
|
| 705 |
+
width,
|
| 706 |
+
progress.CompletionPercentage);
|
| 707 |
+
}).ToList();
|
| 708 |
+
}
|
| 709 |
+
|
| 710 |
+
private string GetTimelineTodayMarkerLeft()
|
| 711 |
+
{
|
| 712 |
+
var months = TimelineMonths;
|
| 713 |
+
if (months.Count == 0)
|
| 714 |
+
{
|
| 715 |
+
return "0%";
|
| 716 |
+
}
|
| 717 |
+
|
| 718 |
+
var first = months.First();
|
| 719 |
+
var monthSpan = Math.Max(months.Count - 1, 1);
|
| 720 |
+
var currentIndex = ((DateTime.Today.Year - first.Year) * 12) + DateTime.Today.Month - first.Month;
|
| 721 |
+
currentIndex = Math.Clamp(currentIndex, 0, monthSpan);
|
| 722 |
+
return $"{(currentIndex / (double)monthSpan) * 100:0.##}%";
|
| 723 |
+
}
|
| 724 |
|
| 725 |
+
private ProjectProgressDto GetProjectProgress(long projectId)
|
| 726 |
+
{
|
| 727 |
+
return projectProgress.FirstOrDefault(item => item.ProjectId == projectId)
|
| 728 |
+
?? new ProjectProgressDto
|
| 729 |
{
|
| 730 |
+
ProjectId = projectId,
|
| 731 |
+
ProjectName = string.Empty,
|
| 732 |
+
TotalTasksCount = taskList.Count(task => task.ProjectId == projectId),
|
| 733 |
+
CompletedTasksCount = taskList.Count(task => task.ProjectId == projectId && task.StatusId == AppTaskStatus.Done),
|
| 734 |
+
CompletionPercentage = 0
|
| 735 |
+
};
|
| 736 |
+
}
|
| 737 |
+
|
| 738 |
+
private string GetProjectBadgeLabel(ProjectDto project)
|
| 739 |
+
{
|
| 740 |
+
return project.StartDate.Date > DateTime.Today ? "Upcoming" : "Active";
|
| 741 |
+
}
|
| 742 |
+
|
| 743 |
+
private string GetProjectBadgeClasses(ProjectDto project)
|
| 744 |
+
{
|
| 745 |
+
return project.StartDate.Date > DateTime.Today
|
| 746 |
+
? "rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700"
|
| 747 |
+
: "rounded-full border border-violet-200 bg-violet-50 px-3 py-1 text-xs font-semibold text-violet-700";
|
| 748 |
+
}
|
| 749 |
+
|
| 750 |
+
private string GetProjectColor(long projectId)
|
| 751 |
+
{
|
| 752 |
+
var palette = new[]
|
| 753 |
+
{
|
| 754 |
+
"#7c3aed",
|
| 755 |
+
"#0ea5e9",
|
| 756 |
+
"#10b981",
|
| 757 |
+
"#f59e0b",
|
| 758 |
+
"#f97316"
|
| 759 |
+
};
|
| 760 |
+
|
| 761 |
+
var index = (int)(projectId % palette.Length);
|
| 762 |
+
return palette[index];
|
| 763 |
+
}
|
| 764 |
+
|
| 765 |
+
private string GetCalendarCellClasses(CalendarCell cell)
|
| 766 |
+
{
|
| 767 |
+
var classes = new List<string>
|
| 768 |
+
{
|
| 769 |
+
"min-h-[62px] rounded-2xl border p-2"
|
| 770 |
+
};
|
| 771 |
+
|
| 772 |
+
if (cell.IsToday)
|
| 773 |
+
{
|
| 774 |
+
classes.Add("border-violet-200 bg-violet-50");
|
| 775 |
+
}
|
| 776 |
+
else if (cell.IsCurrentMonth)
|
| 777 |
+
{
|
| 778 |
+
classes.Add("border-slate-200 bg-white");
|
| 779 |
+
}
|
| 780 |
+
else
|
| 781 |
+
{
|
| 782 |
+
classes.Add("border-slate-100 bg-slate-50/70");
|
| 783 |
+
}
|
| 784 |
+
|
| 785 |
+
return string.Join(' ', classes);
|
| 786 |
+
}
|
| 787 |
+
|
| 788 |
+
private void SetHoveredActivity(int index)
|
| 789 |
+
{
|
| 790 |
+
hoveredActivityIndex = index;
|
| 791 |
+
}
|
| 792 |
+
|
| 793 |
+
private void ClearHoveredActivity()
|
| 794 |
+
{
|
| 795 |
+
hoveredActivityIndex = null;
|
| 796 |
+
}
|
| 797 |
+
|
| 798 |
+
private void SetHoveredSlice(int index)
|
| 799 |
+
{
|
| 800 |
+
hoveredSliceIndex = index;
|
| 801 |
+
}
|
| 802 |
+
|
| 803 |
+
private void ClearHoveredSlice()
|
| 804 |
+
{
|
| 805 |
+
hoveredSliceIndex = null;
|
| 806 |
+
}
|
| 807 |
+
|
| 808 |
+
private ActivityMonthStat? hoveredActivity =>
|
| 809 |
+
hoveredActivityIndex.HasValue && hoveredActivityIndex.Value < ActivityMonths.Count
|
| 810 |
+
? ActivityMonths[hoveredActivityIndex.Value]
|
| 811 |
+
: null;
|
| 812 |
+
|
| 813 |
+
private DistributionSlice? hoveredSlice =>
|
| 814 |
+
hoveredSliceIndex.HasValue && hoveredSliceIndex.Value < DonutSlices.Count
|
| 815 |
+
? DonutSlices[hoveredSliceIndex.Value]
|
| 816 |
+
: null;
|
| 817 |
+
|
| 818 |
+
private string HoveredActivityStyle
|
| 819 |
+
{
|
| 820 |
+
get
|
| 821 |
+
{
|
| 822 |
+
if (!hoveredActivityIndex.HasValue || hoveredActivityIndex.Value >= ActivityPoints.Count)
|
| 823 |
+
{
|
| 824 |
+
return string.Empty;
|
| 825 |
+
}
|
| 826 |
+
|
| 827 |
+
var point = ActivityPoints[hoveredActivityIndex.Value];
|
| 828 |
+
return $"left: {point.X / 760d * 100:0.##}%; top: {point.Y / 340d * 100:0.##}%; transform: translate(-50%, -110%);";
|
| 829 |
+
}
|
| 830 |
+
}
|
| 831 |
+
|
| 832 |
+
private string BuildDeltaText(int delta)
|
| 833 |
+
{
|
| 834 |
+
if (delta > 0)
|
| 835 |
+
{
|
| 836 |
+
return $"+{delta} this month";
|
| 837 |
+
}
|
| 838 |
+
|
| 839 |
+
if (delta < 0)
|
| 840 |
+
{
|
| 841 |
+
return $"{delta} this month";
|
| 842 |
+
}
|
| 843 |
+
|
| 844 |
+
return "0 this month";
|
| 845 |
+
}
|
| 846 |
+
|
| 847 |
+
private string BuildCompletionDeltaText()
|
| 848 |
+
{
|
| 849 |
+
var current = GetCompletionRateForMonth(DateTime.Today);
|
| 850 |
+
var previous = GetCompletionRateForMonth(DateTime.Today.AddMonths(-1));
|
| 851 |
+
var delta = current - previous;
|
| 852 |
+
|
| 853 |
+
if (delta > 0)
|
| 854 |
+
{
|
| 855 |
+
return $"+{delta}% vs last mo";
|
| 856 |
+
}
|
| 857 |
+
|
| 858 |
+
if (delta < 0)
|
| 859 |
+
{
|
| 860 |
+
return $"{delta}% vs last mo";
|
| 861 |
+
}
|
| 862 |
+
|
| 863 |
+
return "0% vs last mo";
|
| 864 |
+
}
|
| 865 |
+
|
| 866 |
+
private int GetUsersThisMonth() =>
|
| 867 |
+
usersList.Count(user => user.CreatedAt.Year == DateTime.Today.Year && user.CreatedAt.Month == DateTime.Today.Month);
|
| 868 |
+
|
| 869 |
+
private int GetUsersPreviousMonth()
|
| 870 |
+
{
|
| 871 |
+
var previous = DateTime.Today.AddMonths(-1);
|
| 872 |
+
return usersList.Count(user => user.CreatedAt.Year == previous.Year && user.CreatedAt.Month == previous.Month);
|
| 873 |
+
}
|
| 874 |
+
|
| 875 |
+
private int GetProjectsThisMonth() =>
|
| 876 |
+
projectsList.Count(project => project.CreatedAt.Year == DateTime.Today.Year && project.CreatedAt.Month == DateTime.Today.Month);
|
| 877 |
+
|
| 878 |
+
private int GetProjectsPreviousMonth()
|
| 879 |
+
{
|
| 880 |
+
var previous = DateTime.Today.AddMonths(-1);
|
| 881 |
+
return projectsList.Count(project => project.CreatedAt.Year == previous.Year && project.CreatedAt.Month == previous.Month);
|
| 882 |
+
}
|
| 883 |
+
|
| 884 |
+
private int GetActiveTasksThisMonth() =>
|
| 885 |
+
taskList.Count(task => task.CreatedAt.Year == DateTime.Today.Year && task.CreatedAt.Month == DateTime.Today.Month && task.StatusId != AppTaskStatus.Done);
|
| 886 |
+
|
| 887 |
+
private int GetActiveTasksPreviousMonth()
|
| 888 |
+
{
|
| 889 |
+
var previous = DateTime.Today.AddMonths(-1);
|
| 890 |
+
return taskList.Count(task => task.CreatedAt.Year == previous.Year && task.CreatedAt.Month == previous.Month && task.StatusId != AppTaskStatus.Done);
|
| 891 |
+
}
|
| 892 |
+
|
| 893 |
+
private int GetCompletionRateForMonth(DateTime date)
|
| 894 |
+
{
|
| 895 |
+
var monthTasks = taskList.Where(task => task.CreatedAt.Year == date.Year && task.CreatedAt.Month == date.Month).ToList();
|
| 896 |
+
if (monthTasks.Count == 0)
|
| 897 |
+
{
|
| 898 |
+
return 0;
|
| 899 |
+
}
|
| 900 |
+
|
| 901 |
+
return (int)Math.Round(monthTasks.Count(task => task.StatusId == AppTaskStatus.Done) / (double)monthTasks.Count * 100);
|
| 902 |
}
|
| 903 |
|
| 904 |
+
private string GetMonthLabel(DateTime date) => date.ToString("MMM");
|
|
|
|
| 905 |
|
| 906 |
+
private readonly record struct ActivityMonthStat(string Label, int Active, int Completed);
|
| 907 |
+
private readonly record struct ActivityPoint(string Label, double X, double Y, int Active, int Completed);
|
| 908 |
+
private readonly record struct DistributionSlice(string Label, int Count, string Color, string DashArray, double DashOffset);
|
| 909 |
+
private readonly record struct DistributionLegendItem(string Label, int Count, string Color);
|
| 910 |
+
private readonly record struct CalendarCell(DateTime Date, bool IsCurrentMonth, bool IsToday, List<string> ProjectDots);
|
| 911 |
+
private readonly record struct CalendarEventItem(string Name, string DateLabel, string Color);
|
| 912 |
+
private readonly record struct TimelineRow(string Name, string DateLabel, string Color, double Left, double Width, double CompletionPercentage);
|
| 913 |
}
|
TaskTrackingSystem.WebApp/Components/Pages/Login.razor
CHANGED
|
@@ -4,6 +4,7 @@
|
|
| 4 |
@attribute [Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
| 5 |
@inject NavigationManager Navigation
|
| 6 |
@inject AuthenticationStateProvider AuthStateProvider
|
|
|
|
| 7 |
@inject IJSRuntime JS
|
| 8 |
|
| 9 |
<PageTitle>Sign In — TaskFlow</PageTitle>
|
|
@@ -114,7 +115,7 @@
|
|
| 114 |
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
| 115 |
if (authState.User.Identity?.IsAuthenticated == true)
|
| 116 |
{
|
| 117 |
-
Navigation.NavigateTo(
|
| 118 |
}
|
| 119 |
}
|
| 120 |
}
|
|
@@ -126,4 +127,56 @@
|
|
| 126 |
showPassword = !showPassword;
|
| 127 |
await JS.InvokeVoidAsync("initIcons");
|
| 128 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
}
|
|
|
|
| 4 |
@attribute [Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
| 5 |
@inject NavigationManager Navigation
|
| 6 |
@inject AuthenticationStateProvider AuthStateProvider
|
| 7 |
+
@inject MenuAuthorizationService MenuAuthorization
|
| 8 |
@inject IJSRuntime JS
|
| 9 |
|
| 10 |
<PageTitle>Sign In — TaskFlow</PageTitle>
|
|
|
|
| 115 |
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
| 116 |
if (authState.User.Identity?.IsAuthenticated == true)
|
| 117 |
{
|
| 118 |
+
Navigation.NavigateTo(await ResolveLandingPageAsync(authState.User), forceLoad: true);
|
| 119 |
}
|
| 120 |
}
|
| 121 |
}
|
|
|
|
| 127 |
showPassword = !showPassword;
|
| 128 |
await JS.InvokeVoidAsync("initIcons");
|
| 129 |
}
|
| 130 |
+
|
| 131 |
+
private async Task<string> ResolveLandingPageAsync(System.Security.Claims.ClaimsPrincipal user)
|
| 132 |
+
{
|
| 133 |
+
try
|
| 134 |
+
{
|
| 135 |
+
var menus = await MenuAuthorization.GetUserMenusAsync(user);
|
| 136 |
+
var landing = FindFirstDashboardMenu(menus);
|
| 137 |
+
if (!string.IsNullOrWhiteSpace(landing))
|
| 138 |
+
{
|
| 139 |
+
return landing;
|
| 140 |
+
}
|
| 141 |
+
}
|
| 142 |
+
catch
|
| 143 |
+
{
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
return "/dashboard";
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
private static string? FindFirstDashboardMenu(IEnumerable<TaskTrackingSystem.Shared.Models.Menu.MenuDto> menus)
|
| 150 |
+
{
|
| 151 |
+
foreach (var menu in menus)
|
| 152 |
+
{
|
| 153 |
+
var found = FindFirstDashboardMenu(menu);
|
| 154 |
+
if (!string.IsNullOrWhiteSpace(found))
|
| 155 |
+
{
|
| 156 |
+
return found;
|
| 157 |
+
}
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
return null;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
private static string? FindFirstDashboardMenu(TaskTrackingSystem.Shared.Models.Menu.MenuDto menu)
|
| 164 |
+
{
|
| 165 |
+
if (!string.IsNullOrWhiteSpace(menu.MenuUrl) &&
|
| 166 |
+
menu.MenuUrl.StartsWith("/dashboard", StringComparison.OrdinalIgnoreCase))
|
| 167 |
+
{
|
| 168 |
+
return menu.MenuUrl;
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
foreach (var subMenu in menu.SubMenus)
|
| 172 |
+
{
|
| 173 |
+
var found = FindFirstDashboardMenu(subMenu);
|
| 174 |
+
if (!string.IsNullOrWhiteSpace(found))
|
| 175 |
+
{
|
| 176 |
+
return found;
|
| 177 |
+
}
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
return null;
|
| 181 |
+
}
|
| 182 |
}
|
TaskTrackingSystem.WebApp/Components/Pages/ManagerDashboard.razor
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@page "/dashboard/manager"
|
| 2 |
+
@using Microsoft.AspNetCore.Authorization
|
| 3 |
+
@rendermode @(new InteractiveServerRenderMode(prerender: false))
|
| 4 |
+
@attribute [Authorize(Roles = "Manager")]
|
| 5 |
+
|
| 6 |
+
<PageTitle>Manager Dashboard</PageTitle>
|
| 7 |
+
|
| 8 |
+
<div class="space-y-6 text-slate-900">
|
| 9 |
+
<div class="rounded-[32px] border border-slate-200 bg-white px-8 py-8 shadow-sm shadow-slate-200/60">
|
| 10 |
+
<p class="text-sm font-semibold uppercase tracking-[0.22em] text-sky-600">Team View</p>
|
| 11 |
+
<h2 class="mt-2 text-4xl font-bold tracking-tight text-slate-900">Welcome back, manager</h2>
|
| 12 |
+
<p class="mt-3 max-w-2xl text-sm text-slate-600">
|
| 13 |
+
This dashboard is reserved for project tracking, team health, and workload planning.
|
| 14 |
+
</p>
|
| 15 |
+
</div>
|
| 16 |
+
|
| 17 |
+
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
| 18 |
+
<div class="rounded-[24px] border border-slate-200 bg-white p-5 shadow-sm shadow-slate-200/50">
|
| 19 |
+
<p class="text-sm font-semibold text-slate-500">Projects</p>
|
| 20 |
+
<p class="mt-4 text-4xl font-bold text-slate-900">--</p>
|
| 21 |
+
<p class="mt-2 text-sm text-slate-500">Placeholder metric</p>
|
| 22 |
+
</div>
|
| 23 |
+
<div class="rounded-[24px] border border-slate-200 bg-white p-5 shadow-sm shadow-slate-200/50">
|
| 24 |
+
<p class="text-sm font-semibold text-slate-500">Team Members</p>
|
| 25 |
+
<p class="mt-4 text-4xl font-bold text-slate-900">--</p>
|
| 26 |
+
<p class="mt-2 text-sm text-slate-500">Placeholder metric</p>
|
| 27 |
+
</div>
|
| 28 |
+
<div class="rounded-[24px] border border-slate-200 bg-white p-5 shadow-sm shadow-slate-200/50">
|
| 29 |
+
<p class="text-sm font-semibold text-slate-500">Pending Tasks</p>
|
| 30 |
+
<p class="mt-4 text-4xl font-bold text-slate-900">--</p>
|
| 31 |
+
<p class="mt-2 text-sm text-slate-500">Placeholder metric</p>
|
| 32 |
+
</div>
|
| 33 |
+
<div class="rounded-[24px] border border-slate-200 bg-white p-5 shadow-sm shadow-slate-200/50">
|
| 34 |
+
<p class="text-sm font-semibold text-slate-500">Risk Items</p>
|
| 35 |
+
<p class="mt-4 text-4xl font-bold text-slate-900">--</p>
|
| 36 |
+
<p class="mt-2 text-sm text-slate-500">Placeholder metric</p>
|
| 37 |
+
</div>
|
| 38 |
+
</div>
|
| 39 |
+
|
| 40 |
+
<div class="rounded-[28px] border border-slate-200 bg-white p-6 shadow-sm shadow-slate-200/60">
|
| 41 |
+
<h3 class="text-2xl font-bold text-slate-900">Manager dashboard shell</h3>
|
| 42 |
+
<p class="mt-2 text-sm text-slate-500">
|
| 43 |
+
We’ll plug the real charts, team views, and project blocks into this page next.
|
| 44 |
+
</p>
|
| 45 |
+
</div>
|
| 46 |
+
</div>
|