hlaingnandarhtet commited on
Commit
47e77e5
·
1 Parent(s): 49c7853

Merge remote changes and apply TaskStatus/TaskPriority enum refactoring

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +4 -1
  2. TaskTrackingSystem.Database/AppDbContextModels/AppDbContext.cs +55 -2
  3. TaskTrackingSystem.Database/AppDbContextModels/Notification.cs +32 -0
  4. TaskTrackingSystem.Database/AppDbContextModels/Task.cs +4 -3
  5. TaskTrackingSystem.Database/AppDbContextModels/TaskHistory.cs +6 -5
  6. TaskTrackingSystem.Database/AppDbContextModels/UserDevice.cs +18 -0
  7. TaskTrackingSystem.Database/TaskTrackingSystem.Database.csproj +3 -0
  8. TaskTrackingSystem.Shared/Enums/EnumAdminMenuCode.cs +0 -37
  9. TaskTrackingSystem.Shared/Enums/TaskPriority.cs +11 -0
  10. TaskTrackingSystem.Shared/Enums/TaskStatus.cs +11 -0
  11. TaskTrackingSystem.Shared/Models/Dashboard/TaskStatusOverviewDto.cs +4 -1
  12. TaskTrackingSystem.Shared/Models/Notification/NotificationDto.cs +17 -0
  13. TaskTrackingSystem.Shared/Models/Notification/NotificationType.cs +14 -0
  14. TaskTrackingSystem.Shared/Models/Notification/RegisterDeviceTokenDto.cs +6 -0
  15. TaskTrackingSystem.Shared/Models/Report/TaskReportDto.cs +4 -2
  16. TaskTrackingSystem.Shared/Models/Report/TimeTrackingReportDto.cs +3 -1
  17. TaskTrackingSystem.Shared/Models/Task/CreateTaskDto.cs +6 -4
  18. TaskTrackingSystem.Shared/Models/Task/TaskDto.cs +4 -3
  19. TaskTrackingSystem.Shared/Models/Task/UpdateTaskDto.cs +6 -4
  20. TaskTrackingSystem.WebApi/Features/Dashboard/DashboardService.cs +7 -6
  21. TaskTrackingSystem.WebApi/Features/Notification/FirebaseNotificationService.cs +333 -0
  22. TaskTrackingSystem.WebApi/Features/Notification/NotificationController.cs +58 -0
  23. TaskTrackingSystem.WebApi/Features/Notification/NotificationService.cs +67 -0
  24. TaskTrackingSystem.WebApi/Features/Project/ProjectService.cs +18 -1
  25. TaskTrackingSystem.WebApi/Features/Report/ReportController.cs +9 -8
  26. TaskTrackingSystem.WebApi/Features/Report/ReportService.cs +21 -20
  27. TaskTrackingSystem.WebApi/Features/Role/RoleController.cs +4 -4
  28. TaskTrackingSystem.WebApi/Features/Role/RoleService.cs +81 -24
  29. TaskTrackingSystem.WebApi/Features/Task/TaskController.cs +2 -1
  30. TaskTrackingSystem.WebApi/Features/Task/TaskDetailsController.cs +5 -1
  31. TaskTrackingSystem.WebApi/Features/Task/TaskService.cs +49 -5
  32. TaskTrackingSystem.WebApi/Features/UserDevice/UserDeviceService.cs +66 -0
  33. TaskTrackingSystem.WebApi/Features/UserDevice/UserDevicesController.cs +46 -0
  34. TaskTrackingSystem.WebApi/Program.cs +18 -0
  35. TaskTrackingSystem.WebApi/TaskTrackingSystem.WebApi.csproj +6 -0
  36. TaskTrackingSystem.WebApi/appsettings.Development.json +3 -0
  37. TaskTrackingSystem.WebApi/appsettings.json +3 -0
  38. TaskTrackingSystem.WebApp/Components/App.razor +197 -18
  39. TaskTrackingSystem.WebApp/Components/Layout/MainLayout.razor +14 -12
  40. TaskTrackingSystem.WebApp/Components/Layout/MainLayout.razor.css +6 -7
  41. TaskTrackingSystem.WebApp/Components/Layout/NavMenu.razor.css +26 -12
  42. TaskTrackingSystem.WebApp/Components/Pages/Backlog.razor +31 -31
  43. TaskTrackingSystem.WebApp/Components/Pages/Home.razor +19 -19
  44. TaskTrackingSystem.WebApp/Components/Pages/KanbanBoard.razor +22 -22
  45. TaskTrackingSystem.WebApp/Components/Pages/OverdueTasksReport.razor +28 -28
  46. TaskTrackingSystem.WebApp/Components/Pages/Register.razor +1 -1
  47. TaskTrackingSystem.WebApp/Components/Pages/Roles.razor +450 -187
  48. TaskTrackingSystem.WebApp/Components/Pages/TaskAssign.razor +17 -17
  49. TaskTrackingSystem.WebApp/Components/Pages/TaskDetails.razor +33 -33
  50. TaskTrackingSystem.WebApp/Components/Pages/TaskReport.razor +15 -15
.gitignore CHANGED
@@ -266,6 +266,9 @@ ServiceFabricBackup/
266
  *.ldf
267
  *.ndf
268
 
 
 
 
269
  # Business Intelligence projects
270
  *.rdl.data
271
  *.bim.layout
@@ -360,4 +363,4 @@ MigrationBackup/
360
  .ionide/
361
 
362
  # Fody - auto-generated XML schema
363
- FodyWeavers.xsd
 
266
  *.ldf
267
  *.ndf
268
 
269
+ # Firebase service account keys
270
+ TaskTrackingSystem.WebApi/Firebase/*.json
271
+
272
  # Business Intelligence projects
273
  *.rdl.data
274
  *.bim.layout
 
363
  .ionide/
364
 
365
  # Fody - auto-generated XML schema
366
+ FodyWeavers.xsd
TaskTrackingSystem.Database/AppDbContextModels/AppDbContext.cs CHANGED
@@ -1,6 +1,7 @@
1
  using System;
2
  using System.Collections.Generic;
3
  using Microsoft.EntityFrameworkCore;
 
4
 
5
  namespace TaskTrackingSystem.Database.AppDbContextModels;
6
 
@@ -23,6 +24,8 @@ public partial class AppDbContext : DbContext
23
 
24
  public virtual DbSet<Menu> Menus { get; set; }
25
 
 
 
26
  public virtual DbSet<Permission> Permissions { get; set; }
27
 
28
  public virtual DbSet<Project> Projects { get; set; }
@@ -41,6 +44,8 @@ public partial class AppDbContext : DbContext
41
 
42
  public virtual DbSet<TimeLog> TimeLogs { get; set; }
43
 
 
 
44
  public virtual DbSet<User> Users { get; set; }
45
 
46
  protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
@@ -131,6 +136,35 @@ public partial class AppDbContext : DbContext
131
  .HasConstraintName("FK_Menus_ParentMenu");
132
  });
133
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  modelBuilder.Entity<Permission>(entity =>
135
  {
136
  entity.HasIndex(e => e.MenuId, "IX_Permissions_MenuId");
@@ -263,8 +297,8 @@ public partial class AppDbContext : DbContext
263
  .HasColumnType("datetime");
264
  entity.Property(e => e.DueDate).HasColumnType("datetime");
265
  entity.Property(e => e.EstimatedHours).HasColumnType("decimal(5, 2)");
266
- entity.Property(e => e.PriorityId).HasDefaultValue(2L);
267
- entity.Property(e => e.StatusId).HasDefaultValue(1L);
268
  entity.Property(e => e.Title).HasMaxLength(200);
269
  entity.Property(e => e.UpdatedAt).HasColumnType("datetime");
270
 
@@ -350,6 +384,25 @@ public partial class AppDbContext : DbContext
350
  .HasConstraintName("FK_Users_Roles");
351
  });
352
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
353
  OnModelCreatingPartial(modelBuilder);
354
  }
355
 
 
1
  using System;
2
  using System.Collections.Generic;
3
  using Microsoft.EntityFrameworkCore;
4
+ using TaskTrackingSystem.Shared.Enums;
5
 
6
  namespace TaskTrackingSystem.Database.AppDbContextModels;
7
 
 
24
 
25
  public virtual DbSet<Menu> Menus { get; set; }
26
 
27
+ public virtual DbSet<Notification> Notifications { get; set; }
28
+
29
  public virtual DbSet<Permission> Permissions { get; set; }
30
 
31
  public virtual DbSet<Project> Projects { get; set; }
 
44
 
45
  public virtual DbSet<TimeLog> TimeLogs { get; set; }
46
 
47
+ public virtual DbSet<UserDevice> UserDevices { get; set; }
48
+
49
  public virtual DbSet<User> Users { get; set; }
50
 
51
  protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
 
136
  .HasConstraintName("FK_Menus_ParentMenu");
137
  });
138
 
139
+ modelBuilder.Entity<Notification>(entity =>
140
+ {
141
+ entity.ToTable("notifications");
142
+
143
+ entity.HasIndex(e => e.CreatedAt, "IX_notifications_CreatedAt");
144
+ entity.HasIndex(e => new { e.RecipientId, e.IsRead }, "IX_notifications_Recipient_IsRead");
145
+
146
+ entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasColumnType("datetime2");
147
+ entity.Property(e => e.IsRead).HasColumnName("is_read").HasDefaultValue(false);
148
+ entity.Property(e => e.NotificationType).HasColumnName("notification_type");
149
+ entity.Property(e => e.Title).HasMaxLength(255);
150
+ entity.Property(e => e.Body).HasColumnType("nvarchar(max)");
151
+ entity.Property(e => e.ReadAt).HasColumnName("read_at").HasColumnType("datetime2");
152
+ entity.Property(e => e.RecipientId).HasColumnName("recipient_id");
153
+ entity.Property(e => e.SenderId).HasColumnName("sender_id");
154
+ entity.Property(e => e.SourceId).HasColumnName("source_id");
155
+ entity.Property(e => e.SourceType).HasColumnName("source_type").HasMaxLength(20);
156
+
157
+ entity.HasOne(d => d.Recipient).WithMany()
158
+ .HasForeignKey(d => d.RecipientId)
159
+ .OnDelete(DeleteBehavior.Cascade)
160
+ .HasConstraintName("FK_notifications_Users_Recipient");
161
+
162
+ entity.HasOne(d => d.Sender).WithMany()
163
+ .HasForeignKey(d => d.SenderId)
164
+ .OnDelete(DeleteBehavior.SetNull)
165
+ .HasConstraintName("FK_notifications_Users_Sender");
166
+ });
167
+
168
  modelBuilder.Entity<Permission>(entity =>
169
  {
170
  entity.HasIndex(e => e.MenuId, "IX_Permissions_MenuId");
 
297
  .HasColumnType("datetime");
298
  entity.Property(e => e.DueDate).HasColumnType("datetime");
299
  entity.Property(e => e.EstimatedHours).HasColumnType("decimal(5, 2)");
300
+ entity.Property(e => e.PriorityId).HasDefaultValue(TaskPriority.Medium);
301
+ entity.Property(e => e.StatusId).HasDefaultValue(AppTaskStatus.Todo);
302
  entity.Property(e => e.Title).HasMaxLength(200);
303
  entity.Property(e => e.UpdatedAt).HasColumnType("datetime");
304
 
 
384
  .HasConstraintName("FK_Users_Roles");
385
  });
386
 
387
+ modelBuilder.Entity<UserDevice>(entity =>
388
+ {
389
+ entity.ToTable("user_devices");
390
+
391
+ entity.HasIndex(e => e.UserId, "IX_user_devices_UserId");
392
+
393
+ entity.HasIndex(e => e.FcmToken, "UQ_user_devices_FcmToken").IsUnique();
394
+
395
+ entity.Property(e => e.UserId).HasColumnName("user_id");
396
+ entity.Property(e => e.FcmToken).HasColumnName("fcm_token").HasMaxLength(255);
397
+ entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasColumnType("datetime2");
398
+ entity.Property(e => e.UpdatedAt).HasColumnName("updated_at").HasColumnType("datetime2");
399
+
400
+ entity.HasOne(d => d.User).WithMany()
401
+ .HasForeignKey(d => d.UserId)
402
+ .OnDelete(DeleteBehavior.Cascade)
403
+ .HasConstraintName("FK_user_devices_Users");
404
+ });
405
+
406
  OnModelCreatingPartial(modelBuilder);
407
  }
408
 
TaskTrackingSystem.Database/AppDbContextModels/Notification.cs ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ using System;
2
+
3
+ namespace TaskTrackingSystem.Database.AppDbContextModels;
4
+
5
+ public partial class Notification
6
+ {
7
+ public long Id { get; set; }
8
+
9
+ public long RecipientId { get; set; }
10
+
11
+ public long? SenderId { get; set; }
12
+
13
+ public byte NotificationType { get; set; }
14
+
15
+ public string Title { get; set; } = null!;
16
+
17
+ public string Body { get; set; } = null!;
18
+
19
+ public string SourceType { get; set; } = null!;
20
+
21
+ public long SourceId { get; set; }
22
+
23
+ public bool IsRead { get; set; }
24
+
25
+ public DateTime? ReadAt { get; set; }
26
+
27
+ public DateTime? CreatedAt { get; set; }
28
+
29
+ public virtual User Recipient { get; set; } = null!;
30
+
31
+ public virtual User? Sender { get; set; }
32
+ }
TaskTrackingSystem.Database/AppDbContextModels/Task.cs CHANGED
@@ -1,5 +1,6 @@
1
- using System;
2
  using System.Collections.Generic;
 
3
 
4
  namespace TaskTrackingSystem.Database.AppDbContextModels;
5
 
@@ -13,9 +14,9 @@ public partial class Task
13
 
14
  public long ProjectId { get; set; }
15
 
16
- public long StatusId { get; set; }
17
 
18
- public long PriorityId { get; set; }
19
 
20
  public long? AssignedTo { get; set; }
21
 
 
1
+ using System;
2
  using System.Collections.Generic;
3
+ using TaskTrackingSystem.Shared.Enums;
4
 
5
  namespace TaskTrackingSystem.Database.AppDbContextModels;
6
 
 
14
 
15
  public long ProjectId { get; set; }
16
 
17
+ public AppTaskStatus StatusId { get; set; }
18
 
19
+ public TaskPriority PriorityId { get; set; }
20
 
21
  public long? AssignedTo { get; set; }
22
 
TaskTrackingSystem.Database/AppDbContextModels/TaskHistory.cs CHANGED
@@ -1,5 +1,6 @@
1
- using System;
2
  using System.Collections.Generic;
 
3
 
4
  namespace TaskTrackingSystem.Database.AppDbContextModels;
5
 
@@ -11,13 +12,13 @@ public partial class TaskHistory
11
 
12
  public long ModifiedById { get; set; }
13
 
14
- public long? OldStatusId { get; set; }
15
 
16
- public long? NewStatusId { get; set; }
17
 
18
- public long? OldPriorityId { get; set; }
19
 
20
- public long? NewPriorityId { get; set; }
21
 
22
  public string? Remarks { get; set; }
23
 
 
1
+ using System;
2
  using System.Collections.Generic;
3
+ using TaskTrackingSystem.Shared.Enums;
4
 
5
  namespace TaskTrackingSystem.Database.AppDbContextModels;
6
 
 
12
 
13
  public long ModifiedById { get; set; }
14
 
15
+ public AppTaskStatus? OldStatusId { get; set; }
16
 
17
+ public AppTaskStatus? NewStatusId { get; set; }
18
 
19
+ public TaskPriority? OldPriorityId { get; set; }
20
 
21
+ public TaskPriority? NewPriorityId { get; set; }
22
 
23
  public string? Remarks { get; set; }
24
 
TaskTrackingSystem.Database/AppDbContextModels/UserDevice.cs ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ using System;
2
+
3
+ namespace TaskTrackingSystem.Database.AppDbContextModels;
4
+
5
+ public partial class UserDevice
6
+ {
7
+ public long Id { get; set; }
8
+
9
+ public long UserId { get; set; }
10
+
11
+ public string FcmToken { get; set; } = null!;
12
+
13
+ public DateTime? CreatedAt { get; set; }
14
+
15
+ public DateTime? UpdatedAt { get; set; }
16
+
17
+ public virtual User User { get; set; } = null!;
18
+ }
TaskTrackingSystem.Database/TaskTrackingSystem.Database.csproj CHANGED
@@ -20,5 +20,8 @@
20
  </PackageReference>
21
  <PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
22
  </ItemGroup>
 
 
 
23
 
24
  </Project>
 
20
  </PackageReference>
21
  <PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
22
  </ItemGroup>
23
+ <ItemGroup>
24
+ <ProjectReference Include="..\TaskTrackingSystem.Shared\TaskTrackingSystem.Shared.csproj" />
25
+ </ItemGroup>
26
 
27
  </Project>
TaskTrackingSystem.Shared/Enums/EnumAdminMenuCode.cs DELETED
@@ -1,37 +0,0 @@
1
- using System;
2
- using System.ComponentModel;
3
-
4
- namespace TaskTrackingSystem.Shared.Enums
5
- {
6
- public enum EnumAdminAction
7
- {
8
- Approve,
9
- Reject,
10
- Create,
11
- Edit,
12
- Update,
13
- Delete,
14
- Detail,
15
- }
16
-
17
- public enum EnumAdminMenuCode
18
- {
19
- Dashboard,
20
- [Description("CMC001")] ProjectList,
21
- [Description("CMC002")] TaskList,
22
- [Description("CMC003")] UserList,
23
- [Description("CMC004")] RoleList,
24
- [Description("CMC005")] ReportList,
25
- }
26
-
27
- public enum EnumAdminActionCode
28
- {
29
- Approve,
30
- Reject,
31
- Create,
32
- Edit,
33
- Update,
34
- Delete,
35
- Detail,
36
- }
37
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
TaskTrackingSystem.Shared/Enums/TaskPriority.cs ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ using System;
2
+
3
+ namespace TaskTrackingSystem.Shared.Enums
4
+ {
5
+ public enum TaskPriority : long
6
+ {
7
+ Low = 1,
8
+ Medium = 2,
9
+ High = 3
10
+ }
11
+ }
TaskTrackingSystem.Shared/Enums/TaskStatus.cs ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ using System;
2
+
3
+ namespace TaskTrackingSystem.Shared.Enums
4
+ {
5
+ public enum AppTaskStatus : long
6
+ {
7
+ Todo = 1,
8
+ InProgress = 2,
9
+ Done = 3
10
+ }
11
+ }
TaskTrackingSystem.Shared/Models/Dashboard/TaskStatusOverviewDto.cs CHANGED
@@ -1,9 +1,12 @@
 
 
 
1
  namespace TaskTrackingSystem.Shared.Models.Dashboard
2
  {
3
  public class TaskStatusOverviewDto
4
  {
5
  public string StatusName { get; set; } = string.Empty;
6
- public long StatusId { get; set; }
7
  public int TaskCount { get; set; }
8
  }
9
  }
 
1
+ using TaskTrackingSystem.Shared.Enums;
2
+ using AppTaskStatus = TaskTrackingSystem.Shared.Enums.AppTaskStatus;
3
+
4
  namespace TaskTrackingSystem.Shared.Models.Dashboard
5
  {
6
  public class TaskStatusOverviewDto
7
  {
8
  public string StatusName { get; set; } = string.Empty;
9
+ public AppTaskStatus StatusId { get; set; }
10
  public int TaskCount { get; set; }
11
  }
12
  }
TaskTrackingSystem.Shared/Models/Notification/NotificationDto.cs ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ using System;
2
+
3
+ namespace TaskTrackingSystem.Shared.Models.Notification;
4
+
5
+ public class NotificationDto
6
+ {
7
+ public long Id { get; set; }
8
+ public string Title { get; set; } = string.Empty;
9
+ public string Body { get; set; } = string.Empty;
10
+ public byte NotificationType { get; set; }
11
+ public string SourceType { get; set; } = string.Empty;
12
+ public long SourceId { get; set; }
13
+ public bool IsRead { get; set; }
14
+ public DateTime? CreatedAt { get; set; }
15
+ public DateTime? ReadAt { get; set; }
16
+ public string? SenderName { get; set; }
17
+ }
TaskTrackingSystem.Shared/Models/Notification/NotificationType.cs ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ namespace TaskTrackingSystem.Shared.Models.Notification;
2
+
3
+ public enum NotificationType : byte
4
+ {
5
+ TaskAssigned = 1,
6
+ StatusChanged = 2,
7
+ DueDateReminder = 3,
8
+ OverdueAlert = 4,
9
+ CommentAdded = 5,
10
+ Mention = 6,
11
+ PriorityChanged = 7,
12
+ ProjectUpdated = 8,
13
+ System = 9
14
+ }
TaskTrackingSystem.Shared/Models/Notification/RegisterDeviceTokenDto.cs ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ namespace TaskTrackingSystem.Shared.Models.Notification;
2
+
3
+ public class RegisterDeviceTokenDto
4
+ {
5
+ public string FcmToken { get; set; } = string.Empty;
6
+ }
TaskTrackingSystem.Shared/Models/Report/TaskReportDto.cs CHANGED
@@ -1,4 +1,6 @@
1
  using System;
 
 
2
 
3
  namespace TaskTrackingSystem.Shared.Models.Report
4
  {
@@ -9,9 +11,9 @@ namespace TaskTrackingSystem.Shared.Models.Report
9
  public string? Description { get; set; }
10
  public string ProjectName { get; set; } = string.Empty;
11
  public long ProjectId { get; set; }
12
- public long StatusId { get; set; }
13
  public string StatusName { get; set; } = string.Empty;
14
- public long PriorityId { get; set; }
15
  public string PriorityName { get; set; } = string.Empty;
16
  public string? AssignedToUser { get; set; }
17
  public long? AssignedToUserId { get; set; }
 
1
  using System;
2
+ using TaskTrackingSystem.Shared.Enums;
3
+ using AppTaskStatus = TaskTrackingSystem.Shared.Enums.AppTaskStatus;
4
 
5
  namespace TaskTrackingSystem.Shared.Models.Report
6
  {
 
11
  public string? Description { get; set; }
12
  public string ProjectName { get; set; } = string.Empty;
13
  public long ProjectId { get; set; }
14
+ public AppTaskStatus StatusId { get; set; }
15
  public string StatusName { get; set; } = string.Empty;
16
+ public TaskPriority PriorityId { get; set; }
17
  public string PriorityName { get; set; } = string.Empty;
18
  public string? AssignedToUser { get; set; }
19
  public long? AssignedToUserId { get; set; }
TaskTrackingSystem.Shared/Models/Report/TimeTrackingReportDto.cs CHANGED
@@ -1,5 +1,7 @@
1
  using System;
2
  using System.Collections.Generic;
 
 
3
 
4
  namespace TaskTrackingSystem.Shared.Models.Report
5
  {
@@ -54,6 +56,6 @@ namespace TaskTrackingSystem.Shared.Models.Report
54
  public decimal? EstimatedHours { get; set; }
55
  public decimal CompletedHours { get; set; }
56
  public DateTime DueDate { get; set; }
57
- public long StatusId { get; set; }
58
  }
59
  }
 
1
  using System;
2
  using System.Collections.Generic;
3
+ using TaskTrackingSystem.Shared.Enums;
4
+ using AppTaskStatus = TaskTrackingSystem.Shared.Enums.AppTaskStatus;
5
 
6
  namespace TaskTrackingSystem.Shared.Models.Report
7
  {
 
56
  public decimal? EstimatedHours { get; set; }
57
  public decimal CompletedHours { get; set; }
58
  public DateTime DueDate { get; set; }
59
+ public AppTaskStatus StatusId { get; set; }
60
  }
61
  }
TaskTrackingSystem.Shared/Models/Task/CreateTaskDto.cs CHANGED
@@ -1,6 +1,8 @@
1
  using System;
2
  using System.Collections.Generic;
3
  using System.ComponentModel.DataAnnotations;
 
 
4
 
5
  namespace TaskTrackingSystem.Shared.Models.Task
6
  {
@@ -16,11 +18,11 @@ namespace TaskTrackingSystem.Shared.Models.Task
16
  [Range(1, long.MaxValue)]
17
  public long ProjectId { get; set; }
18
 
19
- [Range(0, 3)]
20
- public long StatusId { get; set; }
21
 
22
- [Range(0, 3)]
23
- public long PriorityId { get; set; }
24
 
25
  [Range(0, long.MaxValue)]
26
  public long? AssignedTo { get; set; }
 
1
  using System;
2
  using System.Collections.Generic;
3
  using System.ComponentModel.DataAnnotations;
4
+ using TaskTrackingSystem.Shared.Enums;
5
+ using AppTaskStatus = TaskTrackingSystem.Shared.Enums.AppTaskStatus;
6
 
7
  namespace TaskTrackingSystem.Shared.Models.Task
8
  {
 
18
  [Range(1, long.MaxValue)]
19
  public long ProjectId { get; set; }
20
 
21
+ [EnumDataType(typeof(AppTaskStatus))]
22
+ public AppTaskStatus StatusId { get; set; }
23
 
24
+ [EnumDataType(typeof(TaskPriority))]
25
+ public TaskPriority PriorityId { get; set; }
26
 
27
  [Range(0, long.MaxValue)]
28
  public long? AssignedTo { get; set; }
TaskTrackingSystem.Shared/Models/Task/TaskDto.cs CHANGED
@@ -2,7 +2,8 @@ using System;
2
  using System.Collections.Generic;
3
  using System.Linq;
4
  using System.Text;
5
- using System.Threading.Tasks;
 
6
 
7
  namespace TaskTrackingSystem.Shared.Models.Task
8
  {
@@ -12,8 +13,8 @@ namespace TaskTrackingSystem.Shared.Models.Task
12
  public string Title { get; set; } = string.Empty;
13
  public string? Description { get; set; }
14
  public long ProjectId { get; set; }
15
- public long StatusId { get; set; }
16
- public long PriorityId { get; set; }
17
  public long? AssignedTo { get; set; }
18
  public long? AssignedBy { get; set; }
19
  public decimal? EstimatedHours { get; set; }
 
2
  using System.Collections.Generic;
3
  using System.Linq;
4
  using System.Text;
5
+ using TaskTrackingSystem.Shared.Enums;
6
+ using AppTaskStatus = TaskTrackingSystem.Shared.Enums.AppTaskStatus;
7
 
8
  namespace TaskTrackingSystem.Shared.Models.Task
9
  {
 
13
  public string Title { get; set; } = string.Empty;
14
  public string? Description { get; set; }
15
  public long ProjectId { get; set; }
16
+ public AppTaskStatus StatusId { get; set; }
17
+ public TaskPriority PriorityId { get; set; }
18
  public long? AssignedTo { get; set; }
19
  public long? AssignedBy { get; set; }
20
  public decimal? EstimatedHours { get; set; }
TaskTrackingSystem.Shared/Models/Task/UpdateTaskDto.cs CHANGED
@@ -1,6 +1,8 @@
1
  using System;
2
  using System.Collections.Generic;
3
  using System.ComponentModel.DataAnnotations;
 
 
4
 
5
  namespace TaskTrackingSystem.Shared.Models.Task
6
  {
@@ -13,12 +15,12 @@ namespace TaskTrackingSystem.Shared.Models.Task
13
  public string? Description { get; set; }
14
 
15
  [Required]
16
- [Range(0, 3)]
17
- public long StatusId { get; set; }
18
 
19
  [Required]
20
- [Range(0, 3)]
21
- public long PriorityId { get; set; }
22
 
23
  [Range(0, long.MaxValue)]
24
  public long? AssignedTo { get; set; }
 
1
  using System;
2
  using System.Collections.Generic;
3
  using System.ComponentModel.DataAnnotations;
4
+ using TaskTrackingSystem.Shared.Enums;
5
+ using AppTaskStatus = TaskTrackingSystem.Shared.Enums.AppTaskStatus;
6
 
7
  namespace TaskTrackingSystem.Shared.Models.Task
8
  {
 
15
  public string? Description { get; set; }
16
 
17
  [Required]
18
+ [EnumDataType(typeof(AppTaskStatus))]
19
+ public AppTaskStatus StatusId { get; set; }
20
 
21
  [Required]
22
+ [EnumDataType(typeof(TaskPriority))]
23
+ public TaskPriority PriorityId { get; set; }
24
 
25
  [Range(0, long.MaxValue)]
26
  public long? AssignedTo { get; set; }
TaskTrackingSystem.WebApi/Features/Dashboard/DashboardService.cs CHANGED
@@ -6,6 +6,7 @@ using System.Threading.Tasks;
6
  using TaskTrackingSystem.Database.AppDbContextModels;
7
  using TaskTrackingSystem.Shared;
8
  using TaskTrackingSystem.Shared.Models.Dashboard;
 
9
 
10
  namespace TaskTrackingSystem.WebApi.Features.Dashboard
11
  {
@@ -33,7 +34,7 @@ namespace TaskTrackingSystem.WebApi.Features.Dashboard
33
  .CountAsync();
34
 
35
  var activeProjectsCount = await projects.CountAsync();
36
- var pendingTasksCount = await tasks.CountAsync(t => t.StatusId != 3);
37
 
38
  var summary = new DashboardSummaryDto
39
  {
@@ -57,11 +58,11 @@ namespace TaskTrackingSystem.WebApi.Features.Dashboard
57
  .ToListAsync();
58
 
59
  // Status mappings (StatusId 1 = To Do, 2 = In Progress, 3 = Done etc.)
60
- var statusMap = new Dictionary<long, string>
61
  {
62
- { 1, "To Do" },
63
- { 2, "In Progress" },
64
- { 3, "Done" }
65
  };
66
 
67
  var overview = groupedTasks.Select(gt => new TaskStatusOverviewDto
@@ -102,7 +103,7 @@ namespace TaskTrackingSystem.WebApi.Features.Dashboard
102
  .ToListAsync();
103
 
104
  int totalTasks = tasks.Count;
105
- int completedTasks = tasks.Count(t => t.StatusId == 3); // StatusId 3 = Done
106
 
107
  double percentage = totalTasks > 0 ? Math.Round(((double)completedTasks / totalTasks) * 100, 2) : 0;
108
 
 
6
  using TaskTrackingSystem.Database.AppDbContextModels;
7
  using TaskTrackingSystem.Shared;
8
  using TaskTrackingSystem.Shared.Models.Dashboard;
9
+ using TaskTrackingSystem.Shared.Enums;
10
 
11
  namespace TaskTrackingSystem.WebApi.Features.Dashboard
12
  {
 
34
  .CountAsync();
35
 
36
  var activeProjectsCount = await projects.CountAsync();
37
+ var pendingTasksCount = await tasks.CountAsync(t => t.StatusId != AppTaskStatus.Done);
38
 
39
  var summary = new DashboardSummaryDto
40
  {
 
58
  .ToListAsync();
59
 
60
  // Status mappings (StatusId 1 = To Do, 2 = In Progress, 3 = Done etc.)
61
+ var statusMap = new Dictionary<AppTaskStatus, string>
62
  {
63
+ { AppTaskStatus.Todo, "To Do" },
64
+ { AppTaskStatus.InProgress, "In Progress" },
65
+ { AppTaskStatus.Done, "Done" }
66
  };
67
 
68
  var overview = groupedTasks.Select(gt => new TaskStatusOverviewDto
 
103
  .ToListAsync();
104
 
105
  int totalTasks = tasks.Count;
106
+ int completedTasks = tasks.Count(t => t.StatusId == AppTaskStatus.Done); // StatusId 3 = Done
107
 
108
  double percentage = totalTasks > 0 ? Math.Round(((double)completedTasks / totalTasks) * 100, 2) : 0;
109
 
TaskTrackingSystem.WebApi/Features/Notification/FirebaseNotificationService.cs ADDED
@@ -0,0 +1,333 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ using System.Security.Claims;
2
+ using System.Security.Cryptography;
3
+ using System.Text;
4
+ using System.Text.Json;
5
+ using System.Text.Json.Serialization;
6
+ using Microsoft.EntityFrameworkCore;
7
+ using Microsoft.Extensions.Configuration;
8
+ using Microsoft.Extensions.Hosting;
9
+ using TaskTrackingSystem.Database.AppDbContextModels;
10
+ using TaskTrackingSystem.Shared.Models.Notification;
11
+ using TaskTrackingSystem.Shared.Enums;
12
+ using DbNotification = TaskTrackingSystem.Database.AppDbContextModels.Notification;
13
+
14
+ namespace TaskTrackingSystem.WebApi.Features.Notification;
15
+
16
+ public class FirebaseNotificationService
17
+ {
18
+ private static readonly SemaphoreSlim AccessTokenLock = new(1, 1);
19
+ private static string? CachedAccessToken;
20
+ private static DateTimeOffset CachedAccessTokenExpiresAt;
21
+
22
+ private readonly AppDbContext _db;
23
+ private readonly IHttpClientFactory _httpClientFactory;
24
+ private readonly string _serviceAccountPath;
25
+
26
+ public FirebaseNotificationService(
27
+ AppDbContext db,
28
+ IHttpClientFactory httpClientFactory,
29
+ IHostEnvironment environment,
30
+ IConfiguration configuration)
31
+ {
32
+ _db = db;
33
+ _httpClientFactory = httpClientFactory;
34
+
35
+ var configuredPath = configuration["Firebase:ServiceAccountPath"] ?? "Firebase/ttsfirebasekey.json";
36
+ _serviceAccountPath = Path.IsPathRooted(configuredPath)
37
+ ? configuredPath
38
+ : Path.Combine(environment.ContentRootPath, configuredPath);
39
+ }
40
+
41
+ public global::System.Threading.Tasks.Task NotifyTaskAssignedAsync(Database.AppDbContextModels.Task task, long senderId)
42
+ {
43
+ if (!task.AssignedTo.HasValue)
44
+ {
45
+ return global::System.Threading.Tasks.Task.CompletedTask;
46
+ }
47
+
48
+ var title = "Task assigned";
49
+ var body = $"You have been assigned a new task: {task.Title}";
50
+
51
+ return SendAsync(
52
+ recipientIds: new[] { task.AssignedTo.Value },
53
+ senderId: senderId,
54
+ notificationType: NotificationType.TaskAssigned,
55
+ sourceType: "task",
56
+ sourceId: task.Id,
57
+ title: title,
58
+ body: body);
59
+ }
60
+
61
+ public async global::System.Threading.Tasks.Task NotifyTaskStatusChangedAsync(Database.AppDbContextModels.Task task, long senderId, AppTaskStatus oldStatusId, AppTaskStatus newStatusId)
62
+ {
63
+ var recipientIds = BuildTaskAudience(task, senderId);
64
+ if (recipientIds.Count == 0)
65
+ {
66
+ return;
67
+ }
68
+
69
+ var title = "Task status changed";
70
+ var body = $"Task '{task.Title}' changed from {GetStatusLabel(oldStatusId)} to {GetStatusLabel(newStatusId)}";
71
+
72
+ await SendAsync(
73
+ recipientIds,
74
+ senderId,
75
+ NotificationType.StatusChanged,
76
+ "task",
77
+ task.Id,
78
+ title,
79
+ body);
80
+ }
81
+
82
+ public async global::System.Threading.Tasks.Task NotifyCommentAddedAsync(Database.AppDbContextModels.Task task, long senderId, string actorName)
83
+ {
84
+ var recipientIds = BuildTaskAudience(task, senderId);
85
+ if (recipientIds.Count == 0)
86
+ {
87
+ return;
88
+ }
89
+
90
+ var title = "New comment";
91
+ var body = $"{actorName} commented on task '{task.Title}'";
92
+
93
+ await SendAsync(
94
+ recipientIds,
95
+ senderId,
96
+ NotificationType.CommentAdded,
97
+ "task",
98
+ task.Id,
99
+ title,
100
+ body);
101
+ }
102
+
103
+ private List<long> BuildTaskAudience(Database.AppDbContextModels.Task task, long senderId)
104
+ {
105
+ var audience = new HashSet<long>();
106
+
107
+ if (task.AssignedTo.HasValue && task.AssignedTo.Value != senderId)
108
+ {
109
+ audience.Add(task.AssignedTo.Value);
110
+ }
111
+
112
+ if (task.AssignedBy.HasValue && task.AssignedBy.Value != senderId)
113
+ {
114
+ audience.Add(task.AssignedBy.Value);
115
+ }
116
+
117
+ if (task.CreatedBy.HasValue && task.CreatedBy.Value != senderId)
118
+ {
119
+ audience.Add(task.CreatedBy.Value);
120
+ }
121
+
122
+ return audience.ToList();
123
+ }
124
+
125
+ private async global::System.Threading.Tasks.Task SendAsync(
126
+ IEnumerable<long> recipientIds,
127
+ long senderId,
128
+ NotificationType notificationType,
129
+ string sourceType,
130
+ long sourceId,
131
+ string title,
132
+ string body)
133
+ {
134
+ var uniqueRecipientIds = recipientIds.Distinct().ToList();
135
+ if (uniqueRecipientIds.Count == 0)
136
+ {
137
+ return;
138
+ }
139
+
140
+ var notifications = uniqueRecipientIds.Select(recipientId => new DbNotification
141
+ {
142
+ RecipientId = recipientId,
143
+ SenderId = senderId > 0 ? senderId : null,
144
+ NotificationType = (byte)notificationType,
145
+ Title = title,
146
+ Body = body,
147
+ SourceType = sourceType,
148
+ SourceId = sourceId,
149
+ IsRead = false,
150
+ CreatedAt = DateTime.UtcNow
151
+ }).ToList();
152
+
153
+ _db.Notifications.AddRange(notifications);
154
+ await _db.SaveChangesAsync();
155
+
156
+ var tokens = await _db.UserDevices
157
+ .Where(d => uniqueRecipientIds.Contains(d.UserId))
158
+ .Select(d => d.FcmToken)
159
+ .Distinct()
160
+ .ToListAsync();
161
+
162
+ foreach (var token in tokens)
163
+ {
164
+ await SendPushAsync(token, title, body, sourceType, sourceId, notificationType);
165
+ }
166
+ }
167
+
168
+ private async global::System.Threading.Tasks.Task SendPushAsync(
169
+ string token,
170
+ string title,
171
+ string body,
172
+ string sourceType,
173
+ long sourceId,
174
+ NotificationType notificationType)
175
+ {
176
+ var serviceAccount = await LoadServiceAccountAsync();
177
+ var accessToken = await GetAccessTokenAsync(serviceAccount);
178
+
179
+ var client = _httpClientFactory.CreateClient();
180
+ var request = new HttpRequestMessage(HttpMethod.Post, $"https://fcm.googleapis.com/v1/projects/{serviceAccount.ProjectId}/messages:send");
181
+ request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken);
182
+
183
+ var payload = new
184
+ {
185
+ message = new
186
+ {
187
+ token,
188
+ notification = new
189
+ {
190
+ title,
191
+ body
192
+ },
193
+ data = new Dictionary<string, string>
194
+ {
195
+ ["sourceType"] = sourceType,
196
+ ["sourceId"] = sourceId.ToString(),
197
+ ["notificationType"] = ((byte)notificationType).ToString()
198
+ }
199
+ }
200
+ };
201
+
202
+ request.Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
203
+ using var response = await client.SendAsync(request);
204
+ if (!response.IsSuccessStatusCode)
205
+ {
206
+ var error = await response.Content.ReadAsStringAsync();
207
+ Console.WriteLine($"[Firebase] Failed to send push: {(int)response.StatusCode} {response.ReasonPhrase}. {error}");
208
+ }
209
+ }
210
+
211
+ private async global::System.Threading.Tasks.Task<string> GetAccessTokenAsync(FirebaseServiceAccount serviceAccount)
212
+ {
213
+ if (!string.IsNullOrWhiteSpace(CachedAccessToken) && CachedAccessTokenExpiresAt > DateTimeOffset.UtcNow.AddMinutes(1))
214
+ {
215
+ return CachedAccessToken!;
216
+ }
217
+
218
+ await AccessTokenLock.WaitAsync();
219
+ try
220
+ {
221
+ if (!string.IsNullOrWhiteSpace(CachedAccessToken) && CachedAccessTokenExpiresAt > DateTimeOffset.UtcNow.AddMinutes(1))
222
+ {
223
+ return CachedAccessToken!;
224
+ }
225
+
226
+ var iat = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
227
+ var exp = iat + 3600;
228
+
229
+ var headerJson = JsonSerializer.Serialize(new { alg = "RS256", typ = "JWT" });
230
+ var claimJson = JsonSerializer.Serialize(new
231
+ {
232
+ iss = serviceAccount.ClientEmail,
233
+ scope = "https://www.googleapis.com/auth/firebase.messaging",
234
+ aud = "https://oauth2.googleapis.com/token",
235
+ iat,
236
+ exp
237
+ });
238
+
239
+ var unsignedJwt = $"{Base64UrlEncode(Encoding.UTF8.GetBytes(headerJson))}.{Base64UrlEncode(Encoding.UTF8.GetBytes(claimJson))}";
240
+ var signature = SignJwt(unsignedJwt, serviceAccount.PrivateKey);
241
+ var assertion = $"{unsignedJwt}.{Base64UrlEncode(signature)}";
242
+
243
+ var client = _httpClientFactory.CreateClient();
244
+ var form = new FormUrlEncodedContent(new[]
245
+ {
246
+ new KeyValuePair<string, string>("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"),
247
+ new KeyValuePair<string, string>("assertion", assertion)
248
+ });
249
+
250
+ using var response = await client.PostAsync("https://oauth2.googleapis.com/token", form);
251
+ response.EnsureSuccessStatusCode();
252
+
253
+ var json = await response.Content.ReadAsStringAsync();
254
+ using var doc = JsonDocument.Parse(json);
255
+ var token = doc.RootElement.GetProperty("access_token").GetString();
256
+ var expiresIn = doc.RootElement.GetProperty("expires_in").GetInt32();
257
+
258
+ if (string.IsNullOrWhiteSpace(token))
259
+ {
260
+ throw new InvalidOperationException("Firebase access token response did not contain an access_token.");
261
+ }
262
+
263
+ CachedAccessToken = token;
264
+ CachedAccessTokenExpiresAt = DateTimeOffset.UtcNow.AddSeconds(expiresIn);
265
+
266
+ return token;
267
+ }
268
+ finally
269
+ {
270
+ AccessTokenLock.Release();
271
+ }
272
+ }
273
+
274
+ private async global::System.Threading.Tasks.Task<FirebaseServiceAccount> LoadServiceAccountAsync()
275
+ {
276
+ var json = await File.ReadAllTextAsync(_serviceAccountPath);
277
+ var serviceAccount = JsonSerializer.Deserialize<FirebaseServiceAccount>(json);
278
+ if (serviceAccount == null ||
279
+ string.IsNullOrWhiteSpace(serviceAccount.ProjectId) ||
280
+ string.IsNullOrWhiteSpace(serviceAccount.ClientEmail) ||
281
+ string.IsNullOrWhiteSpace(serviceAccount.PrivateKey))
282
+ {
283
+ throw new InvalidOperationException("Firebase service account file is missing required fields.");
284
+ }
285
+
286
+ return serviceAccount;
287
+ }
288
+
289
+ private static byte[] SignJwt(string unsignedJwt, string privateKey)
290
+ {
291
+ var pem = privateKey
292
+ .Replace("\\n", "\n", StringComparison.Ordinal)
293
+ .Replace("-----BEGIN PRIVATE KEY-----", string.Empty, StringComparison.Ordinal)
294
+ .Replace("-----END PRIVATE KEY-----", string.Empty, StringComparison.Ordinal)
295
+ .Replace("\r", string.Empty, StringComparison.Ordinal)
296
+ .Replace("\n", string.Empty, StringComparison.Ordinal)
297
+ .Trim();
298
+
299
+ var keyBytes = Convert.FromBase64String(pem);
300
+ using var rsa = RSA.Create();
301
+ rsa.ImportPkcs8PrivateKey(keyBytes, out _);
302
+
303
+ return rsa.SignData(Encoding.ASCII.GetBytes(unsignedJwt), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
304
+ }
305
+
306
+ private static string Base64UrlEncode(byte[] input)
307
+ {
308
+ return Convert.ToBase64String(input)
309
+ .TrimEnd('=')
310
+ .Replace('+', '-')
311
+ .Replace('/', '_');
312
+ }
313
+
314
+ private static string GetStatusLabel(AppTaskStatus statusId) => statusId switch
315
+ {
316
+ AppTaskStatus.Todo => "To Do",
317
+ AppTaskStatus.InProgress => "In Progress",
318
+ AppTaskStatus.Done => "Done",
319
+ _ => $"Status {statusId}"
320
+ };
321
+
322
+ private sealed class FirebaseServiceAccount
323
+ {
324
+ [JsonPropertyName("project_id")]
325
+ public string ProjectId { get; set; } = string.Empty;
326
+
327
+ [JsonPropertyName("client_email")]
328
+ public string ClientEmail { get; set; } = string.Empty;
329
+
330
+ [JsonPropertyName("private_key")]
331
+ public string PrivateKey { get; set; } = string.Empty;
332
+ }
333
+ }
TaskTrackingSystem.WebApi/Features/Notification/NotificationController.cs ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ using Microsoft.AspNetCore.Authorization;
2
+ using Microsoft.AspNetCore.Mvc;
3
+ using TaskTrackingSystem.Shared;
4
+ using TaskTrackingSystem.WebApi.Infrastructure;
5
+
6
+ namespace TaskTrackingSystem.WebApi.Features.Notification;
7
+
8
+ [ApiController]
9
+ [Authorize]
10
+ [Route("api/[controller]")]
11
+ public class NotificationController : ControllerBase
12
+ {
13
+ private readonly NotificationService _notificationService;
14
+
15
+ public NotificationController(NotificationService notificationService)
16
+ {
17
+ _notificationService = notificationService;
18
+ }
19
+
20
+ [HttpGet("mine")]
21
+ public async Task<ActionResult<IEnumerable<TaskTrackingSystem.Shared.Models.Notification.NotificationDto>>> GetMine([FromQuery] int take = 10)
22
+ {
23
+ var userId = User.GetUserId();
24
+ if (userId <= 0)
25
+ {
26
+ return Unauthorized();
27
+ }
28
+
29
+ var items = await _notificationService.GetRecentAsync(userId, take);
30
+ return Ok(items);
31
+ }
32
+
33
+ [HttpGet("unread-count")]
34
+ public async Task<ActionResult<int>> GetUnreadCount()
35
+ {
36
+ var userId = User.GetUserId();
37
+ if (userId <= 0)
38
+ {
39
+ return Unauthorized();
40
+ }
41
+
42
+ var count = await _notificationService.GetUnreadCountAsync(userId);
43
+ return Ok(count);
44
+ }
45
+
46
+ [HttpPost("{id}/read")]
47
+ public async Task<ActionResult<Result>> MarkRead(long id)
48
+ {
49
+ var userId = User.GetUserId();
50
+ if (userId <= 0)
51
+ {
52
+ return Unauthorized(Result.Failure("User is not authenticated.", 401));
53
+ }
54
+
55
+ var result = await _notificationService.MarkReadAsync(userId, id);
56
+ return StatusCode(result.StatusCode, result);
57
+ }
58
+ }
TaskTrackingSystem.WebApi/Features/Notification/NotificationService.cs ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ using Microsoft.EntityFrameworkCore;
2
+ using TaskTrackingSystem.Database.AppDbContextModels;
3
+ using TaskTrackingSystem.Shared;
4
+ using TaskTrackingSystem.Shared.Models.Notification;
5
+ using DbNotification = TaskTrackingSystem.Database.AppDbContextModels.Notification;
6
+
7
+ namespace TaskTrackingSystem.WebApi.Features.Notification;
8
+
9
+ public class NotificationService
10
+ {
11
+ private readonly AppDbContext _db;
12
+
13
+ public NotificationService(AppDbContext db)
14
+ {
15
+ _db = db;
16
+ }
17
+
18
+ public async Task<IReadOnlyList<NotificationDto>> GetRecentAsync(long userId, int take = 10)
19
+ {
20
+ return await (
21
+ from n in _db.Notifications
22
+ where n.RecipientId == userId
23
+ join sender in _db.Users on n.SenderId equals sender.Id into senderGroup
24
+ from sender in senderGroup.DefaultIfEmpty()
25
+ orderby n.CreatedAt descending
26
+ select new NotificationDto
27
+ {
28
+ Id = n.Id,
29
+ Title = n.Title,
30
+ Body = n.Body,
31
+ NotificationType = n.NotificationType,
32
+ SourceType = n.SourceType,
33
+ SourceId = n.SourceId,
34
+ IsRead = n.IsRead,
35
+ CreatedAt = n.CreatedAt,
36
+ ReadAt = n.ReadAt,
37
+ SenderName = sender == null ? null : $"{sender.FirstName} {sender.LastName}"
38
+ })
39
+ .Take(take)
40
+ .ToListAsync();
41
+ }
42
+
43
+ public async Task<int> GetUnreadCountAsync(long userId)
44
+ {
45
+ return await _db.Notifications.CountAsync(n => n.RecipientId == userId && !n.IsRead);
46
+ }
47
+
48
+ public async Task<Result> MarkReadAsync(long userId, long notificationId)
49
+ {
50
+ var notification = await _db.Notifications
51
+ .FirstOrDefaultAsync(n => n.Id == notificationId && n.RecipientId == userId);
52
+
53
+ if (notification == null)
54
+ {
55
+ return Result.Failure("Notification not found.", 404);
56
+ }
57
+
58
+ if (!notification.IsRead)
59
+ {
60
+ notification.IsRead = true;
61
+ notification.ReadAt = DateTime.UtcNow;
62
+ await _db.SaveChangesAsync();
63
+ }
64
+
65
+ return Result.Success();
66
+ }
67
+ }
TaskTrackingSystem.WebApi/Features/Project/ProjectService.cs CHANGED
@@ -8,6 +8,7 @@ using TaskTrackingSystem.Shared;
8
  using TaskTrackingSystem.Shared.Models.User;
9
  using TaskTrackingSystem.Shared.Models.Task;
10
  using TaskTrackingSystem.Shared.Models.Project;
 
11
 
12
  namespace TaskTrackingSystem.WebApi.Features.Project
13
  {
@@ -136,6 +137,22 @@ namespace TaskTrackingSystem.WebApi.Features.Project
136
 
137
  project.IsDeleted = true;
138
  _db.Projects.Update(project);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
  await _db.SaveChangesAsync();
140
  return Result.Success(200);
141
  }
@@ -277,7 +294,7 @@ namespace TaskTrackingSystem.WebApi.Features.Project
277
  DueDate = t.DueDate,
278
  CreatedAt = t.CreatedAt ?? DateTime.UtcNow,
279
  CompletedAt = t.TaskHistories
280
- .Where(th => th.NewStatusId == 3)
281
  .OrderBy(th => th.CreatedAt)
282
  .Select(th => th.CreatedAt)
283
  .FirstOrDefault()
 
8
  using TaskTrackingSystem.Shared.Models.User;
9
  using TaskTrackingSystem.Shared.Models.Task;
10
  using TaskTrackingSystem.Shared.Models.Project;
11
+ using TaskTrackingSystem.Shared.Enums;
12
 
13
  namespace TaskTrackingSystem.WebApi.Features.Project
14
  {
 
137
 
138
  project.IsDeleted = true;
139
  _db.Projects.Update(project);
140
+
141
+ // Soft-delete all tasks associated with this project
142
+ var tasksToUpdate = await _db.Tasks
143
+ .Where(t => t.ProjectId == id && t.IsDeleted != true)
144
+ .ToListAsync();
145
+ foreach (var task in tasksToUpdate)
146
+ {
147
+ task.IsDeleted = true;
148
+ task.UpdatedAt = DateTime.UtcNow;
149
+ task.UpdatedBy = currentUserId;
150
+ }
151
+ if (tasksToUpdate.Any())
152
+ {
153
+ _db.Tasks.UpdateRange(tasksToUpdate);
154
+ }
155
+
156
  await _db.SaveChangesAsync();
157
  return Result.Success(200);
158
  }
 
294
  DueDate = t.DueDate,
295
  CreatedAt = t.CreatedAt ?? DateTime.UtcNow,
296
  CompletedAt = t.TaskHistories
297
+ .Where(th => th.NewStatusId == AppTaskStatus.Done)
298
  .OrderBy(th => th.CreatedAt)
299
  .Select(th => th.CreatedAt)
300
  .FirstOrDefault()
TaskTrackingSystem.WebApi/Features/Report/ReportController.cs CHANGED
@@ -5,6 +5,7 @@ using System.Collections.Generic;
5
  using System.Threading.Tasks;
6
  using TaskTrackingSystem.Shared;
7
  using TaskTrackingSystem.Shared.Models.Report;
 
8
  using TaskTrackingSystem.WebApi.Infrastructure;
9
 
10
  namespace TaskTrackingSystem.WebApi.Features.Report
@@ -21,7 +22,7 @@ namespace TaskTrackingSystem.WebApi.Features.Report
21
  _reportService = reportService;
22
  }
23
 
24
- // ─── Legacy ───────────────────────────────────────────────────────────────
25
 
26
  [HttpGet("tasks")]
27
  public async Task<ActionResult<Result<IEnumerable<TaskReportDto>>>> GetTasksReport(
@@ -41,12 +42,12 @@ namespace TaskTrackingSystem.WebApi.Features.Report
41
  return StatusCode(result.StatusCode, result);
42
  }
43
 
44
- // ─── Report 1: Task Status Summary ────────────────────────────────────────
45
 
46
  [HttpGet("task-status-summary")]
47
  public async Task<ActionResult<Result<IEnumerable<TaskStatusSummaryDto>>>> GetTaskStatusSummary(
48
  [FromQuery] string? search,
49
- [FromQuery] long? statusId,
50
  [FromQuery] long? projectId)
51
  {
52
  var result = await _reportService.GetTaskStatusSummaryAsync(search, statusId, projectId, User.GetRoleName(), User.GetUserId());
@@ -56,7 +57,7 @@ namespace TaskTrackingSystem.WebApi.Features.Report
56
  [HttpGet("task-status-summary/excel")]
57
  public async Task<IActionResult> DownloadTaskStatusSummaryExcel(
58
  [FromQuery] string? search,
59
- [FromQuery] long? statusId,
60
  [FromQuery] long? projectId)
61
  {
62
  var result = await _reportService.GetTaskStatusSummaryAsync(search, statusId, projectId, User.GetRoleName(), User.GetUserId());
@@ -67,7 +68,7 @@ namespace TaskTrackingSystem.WebApi.Features.Report
67
  $"TaskStatusSummary_{DateTime.Today:yyyyMMdd}.xlsx");
68
  }
69
 
70
- // ─── Report 2: Team Productivity ──────────────────────────────────────────
71
 
72
  [HttpGet("team-productivity")]
73
  public async Task<ActionResult<Result<IEnumerable<TeamProductivityReportDto>>>> GetTeamProductivity(
@@ -88,7 +89,7 @@ namespace TaskTrackingSystem.WebApi.Features.Report
88
  $"TeamProductivity_{DateTime.Today:yyyyMMdd}.xlsx");
89
  }
90
 
91
- // ─── Report 3: Overdue & Critical Tasks ───────────────────────────────────
92
 
93
  [HttpGet("overdue-critical")]
94
  public async Task<ActionResult<Result<IEnumerable<OverdueCriticalTaskDto>>>> GetOverdueCritical(
@@ -112,13 +113,13 @@ namespace TaskTrackingSystem.WebApi.Features.Report
112
  $"OverdueCriticalTasks_{DateTime.Today:yyyyMMdd}.xlsx");
113
  }
114
 
115
- // ─── Report 4: Time Tracking ──────────────────────────────────────────────
116
 
117
  [HttpGet("time-tracking")]
118
  public async Task<ActionResult<Result<TimeTrackingReportDto>>> GetTimeTracking(
119
  [FromQuery] string? search,
120
  [FromQuery] long? projectId,
121
- [FromQuery] long? statusId)
122
  {
123
  var result = await _reportService.GetTimeTrackingReportAsync(search, projectId, statusId, User.GetRoleName(), User.GetUserId());
124
  return StatusCode(result.StatusCode, result);
 
5
  using System.Threading.Tasks;
6
  using TaskTrackingSystem.Shared;
7
  using TaskTrackingSystem.Shared.Models.Report;
8
+ using TaskTrackingSystem.Shared.Enums;
9
  using TaskTrackingSystem.WebApi.Infrastructure;
10
 
11
  namespace TaskTrackingSystem.WebApi.Features.Report
 
22
  _reportService = reportService;
23
  }
24
 
25
+ // ─── Legacy ───────────────────────────────────────────────────────────────
26
 
27
  [HttpGet("tasks")]
28
  public async Task<ActionResult<Result<IEnumerable<TaskReportDto>>>> GetTasksReport(
 
42
  return StatusCode(result.StatusCode, result);
43
  }
44
 
45
+ // ─── Report 1: Task Status Summary ────────────────────────────────────────
46
 
47
  [HttpGet("task-status-summary")]
48
  public async Task<ActionResult<Result<IEnumerable<TaskStatusSummaryDto>>>> GetTaskStatusSummary(
49
  [FromQuery] string? search,
50
+ [FromQuery] AppTaskStatus? statusId,
51
  [FromQuery] long? projectId)
52
  {
53
  var result = await _reportService.GetTaskStatusSummaryAsync(search, statusId, projectId, User.GetRoleName(), User.GetUserId());
 
57
  [HttpGet("task-status-summary/excel")]
58
  public async Task<IActionResult> DownloadTaskStatusSummaryExcel(
59
  [FromQuery] string? search,
60
+ [FromQuery] AppTaskStatus? statusId,
61
  [FromQuery] long? projectId)
62
  {
63
  var result = await _reportService.GetTaskStatusSummaryAsync(search, statusId, projectId, User.GetRoleName(), User.GetUserId());
 
68
  $"TaskStatusSummary_{DateTime.Today:yyyyMMdd}.xlsx");
69
  }
70
 
71
+ // ─── Report 2: Team Productivity ──────────────────────────────────────────
72
 
73
  [HttpGet("team-productivity")]
74
  public async Task<ActionResult<Result<IEnumerable<TeamProductivityReportDto>>>> GetTeamProductivity(
 
89
  $"TeamProductivity_{DateTime.Today:yyyyMMdd}.xlsx");
90
  }
91
 
92
+ // ─── Report 3: Overdue & Critical Tasks ───────────────────────────────────
93
 
94
  [HttpGet("overdue-critical")]
95
  public async Task<ActionResult<Result<IEnumerable<OverdueCriticalTaskDto>>>> GetOverdueCritical(
 
113
  $"OverdueCriticalTasks_{DateTime.Today:yyyyMMdd}.xlsx");
114
  }
115
 
116
+ // ─â��€â”€ Report 4: Time Tracking ──────────────────────────────────────────────
117
 
118
  [HttpGet("time-tracking")]
119
  public async Task<ActionResult<Result<TimeTrackingReportDto>>> GetTimeTracking(
120
  [FromQuery] string? search,
121
  [FromQuery] long? projectId,
122
+ [FromQuery] AppTaskStatus? statusId)
123
  {
124
  var result = await _reportService.GetTimeTrackingReportAsync(search, projectId, statusId, User.GetRoleName(), User.GetUserId());
125
  return StatusCode(result.StatusCode, result);
TaskTrackingSystem.WebApi/Features/Report/ReportService.cs CHANGED
@@ -8,14 +8,15 @@ using System.Threading.Tasks;
8
  using TaskTrackingSystem.Database.AppDbContextModels;
9
  using TaskTrackingSystem.Shared;
10
  using TaskTrackingSystem.Shared.Models.Report;
 
11
 
12
  namespace TaskTrackingSystem.WebApi.Features.Report
13
  {
14
  public class ReportService
15
  {
16
  private readonly AppDbContext _db;
17
- private static readonly Dictionary<long, string> StatusMap = new() { { 1, "To Do" }, { 2, "In Progress" }, { 3, "Done" } };
18
- private static readonly Dictionary<long, string> PriorityMap = new() { { 1, "Low" }, { 2, "Medium" }, { 3, "High" } };
19
 
20
  public ReportService(AppDbContext db)
21
  {
@@ -78,7 +79,7 @@ namespace TaskTrackingSystem.WebApi.Features.Report
78
 
79
  return users.Where(u => u.Id == currentUserId || accessibleUserIds.Contains(u.Id));
80
  }
81
- // ─── Legacy endpoints (kept for backward compatibility) ───────────────────
82
 
83
  public async Task<Result<IEnumerable<TaskReportDto>>> GetTasksReportAsync(
84
  DateTime? startDate, DateTime? endDate, string? status, int? projectId, string roleName, long currentUserId)
@@ -97,13 +98,13 @@ namespace TaskTrackingSystem.WebApi.Features.Report
97
  var sl = status.Trim().ToLower();
98
  if (sl == "uncompleted")
99
  {
100
- query = query.Where(t => t.StatusId != 3);
101
  }
102
  else
103
  {
104
- long? sid = sl switch { "to do" => 1, "in progress" => 2, "done" => 3, _ => null };
105
  if (sid.HasValue) query = query.Where(t => t.StatusId == sid.Value);
106
- else if (long.TryParse(status, out var pid)) query = query.Where(t => t.StatusId == pid);
107
  }
108
  }
109
 
@@ -167,17 +168,17 @@ namespace TaskTrackingSystem.WebApi.Features.Report
167
  return Result<IEnumerable<UserProductivityDto>>.Success(list);
168
  }
169
 
170
- // ─── Report 1: Task Status Summary ────────────────────────────────────────
171
 
172
  public async Task<Result<IEnumerable<TaskStatusSummaryDto>>> GetTaskStatusSummaryAsync(
173
- string? search, long? statusId, long? projectId, string roleName, long currentUserId)
174
  {
175
  var query = BuildAccessibleTaskQuery(roleName, currentUserId)
176
  .Include(t => t.Project)
177
  .Include(t => t.AssignedToNavigation)
178
  .Where(t => t.IsDeleted != true);
179
 
180
- if (statusId.HasValue && statusId > 0)
181
  query = query.Where(t => t.StatusId == statusId.Value);
182
  if (projectId.HasValue && projectId > 0)
183
  query = query.Where(t => t.ProjectId == projectId.Value);
@@ -243,7 +244,7 @@ namespace TaskTrackingSystem.WebApi.Features.Report
243
  return ms.ToArray();
244
  }
245
 
246
- // ─── Report 2: Team Productivity ──────────────────────────────────────────
247
 
248
  public async Task<Result<IEnumerable<TeamProductivityReportDto>>> GetTeamProductivityAsync(string? search, string roleName, long currentUserId)
249
  {
@@ -262,11 +263,11 @@ namespace TaskTrackingSystem.WebApi.Features.Report
262
  FullName = $"{u.FirstName} {u.LastName}".Trim(),
263
  Username = u.Username,
264
  TotalAssigned = uTasks.Count,
265
- Completed = uTasks.Count(t => t.StatusId == 3),
266
- InProgress = uTasks.Count(t => t.StatusId == 2),
267
- ToDo = uTasks.Count(t => t.StatusId == 1),
268
- Overdue = uTasks.Count(t => t.DueDate < now && t.StatusId != 3),
269
- CompletionRate = uTasks.Count > 0 ? Math.Round((double)uTasks.Count(t => t.StatusId == 3) / uTasks.Count * 100, 1) : 0
270
  };
271
  }).ToList();
272
 
@@ -312,7 +313,7 @@ namespace TaskTrackingSystem.WebApi.Features.Report
312
  return ms.ToArray();
313
  }
314
 
315
- // ─── Report 3: Overdue & Critical Tasks ───────────────────────────────────
316
 
317
  public async Task<Result<IEnumerable<OverdueCriticalTaskDto>>> GetOverdueCriticalTasksAsync(string? search, long? projectId, string roleName, long currentUserId)
318
  {
@@ -320,8 +321,8 @@ namespace TaskTrackingSystem.WebApi.Features.Report
320
  var query = BuildAccessibleTaskQuery(roleName, currentUserId)
321
  .Include(t => t.Project)
322
  .Include(t => t.AssignedToNavigation)
323
- .Where(t => t.IsDeleted != true && t.StatusId != 3 &&
324
- (t.DueDate < now || t.PriorityId == 3)); // overdue OR high priority
325
 
326
  if (projectId.HasValue && projectId > 0)
327
  query = query.Where(t => t.ProjectId == projectId.Value);
@@ -388,7 +389,7 @@ namespace TaskTrackingSystem.WebApi.Features.Report
388
  }
389
 
390
  public async Task<Result<TimeTrackingReportDto>> GetTimeTrackingReportAsync(
391
- string? search, long? projectId, long? statusId, string roleName, long currentUserId)
392
  {
393
  var query = BuildAccessibleTaskQuery(roleName, currentUserId)
394
  .Include(t => t.Project)
@@ -398,7 +399,7 @@ namespace TaskTrackingSystem.WebApi.Features.Report
398
 
399
  if (projectId.HasValue && projectId > 0)
400
  query = query.Where(t => t.ProjectId == projectId.Value);
401
- if (statusId.HasValue && statusId > 0)
402
  query = query.Where(t => t.StatusId == statusId.Value);
403
 
404
  var tasks = await query.ToListAsync();
 
8
  using TaskTrackingSystem.Database.AppDbContextModels;
9
  using TaskTrackingSystem.Shared;
10
  using TaskTrackingSystem.Shared.Models.Report;
11
+ using TaskTrackingSystem.Shared.Enums;
12
 
13
  namespace TaskTrackingSystem.WebApi.Features.Report
14
  {
15
  public class ReportService
16
  {
17
  private readonly AppDbContext _db;
18
+ private static readonly Dictionary<AppTaskStatus, string> StatusMap = new() { { AppTaskStatus.Todo, "To Do" }, { AppTaskStatus.InProgress, "In Progress" }, { AppTaskStatus.Done, "Done" } };
19
+ private static readonly Dictionary<TaskPriority, string> PriorityMap = new() { { TaskPriority.Low, "Low" }, { TaskPriority.Medium, "Medium" }, { TaskPriority.High, "High" } };
20
 
21
  public ReportService(AppDbContext db)
22
  {
 
79
 
80
  return users.Where(u => u.Id == currentUserId || accessibleUserIds.Contains(u.Id));
81
  }
82
+ // ─── Legacy endpoints (kept for backward compatibility) ───────────────────
83
 
84
  public async Task<Result<IEnumerable<TaskReportDto>>> GetTasksReportAsync(
85
  DateTime? startDate, DateTime? endDate, string? status, int? projectId, string roleName, long currentUserId)
 
98
  var sl = status.Trim().ToLower();
99
  if (sl == "uncompleted")
100
  {
101
+ query = query.Where(t => t.StatusId != AppTaskStatus.Done);
102
  }
103
  else
104
  {
105
+ AppTaskStatus? sid = sl switch { "to do" => AppTaskStatus.Todo, "in progress" => AppTaskStatus.InProgress, "done" => AppTaskStatus.Done, _ => null };
106
  if (sid.HasValue) query = query.Where(t => t.StatusId == sid.Value);
107
+ else if (Enum.TryParse<AppTaskStatus>(status, true, out var parsedStatus)) query = query.Where(t => t.StatusId == parsedStatus);
108
  }
109
  }
110
 
 
168
  return Result<IEnumerable<UserProductivityDto>>.Success(list);
169
  }
170
 
171
+ // ─── Report 1: Task Status Summary ────────────────────────────────────────
172
 
173
  public async Task<Result<IEnumerable<TaskStatusSummaryDto>>> GetTaskStatusSummaryAsync(
174
+ string? search, AppTaskStatus? statusId, long? projectId, string roleName, long currentUserId)
175
  {
176
  var query = BuildAccessibleTaskQuery(roleName, currentUserId)
177
  .Include(t => t.Project)
178
  .Include(t => t.AssignedToNavigation)
179
  .Where(t => t.IsDeleted != true);
180
 
181
+ if (statusId.HasValue)
182
  query = query.Where(t => t.StatusId == statusId.Value);
183
  if (projectId.HasValue && projectId > 0)
184
  query = query.Where(t => t.ProjectId == projectId.Value);
 
244
  return ms.ToArray();
245
  }
246
 
247
+ // ─── Report 2: Team Productivity ──────────────────────────────────────────
248
 
249
  public async Task<Result<IEnumerable<TeamProductivityReportDto>>> GetTeamProductivityAsync(string? search, string roleName, long currentUserId)
250
  {
 
263
  FullName = $"{u.FirstName} {u.LastName}".Trim(),
264
  Username = u.Username,
265
  TotalAssigned = uTasks.Count,
266
+ Completed = uTasks.Count(t => t.StatusId == AppTaskStatus.Done),
267
+ InProgress = uTasks.Count(t => t.StatusId == AppTaskStatus.InProgress),
268
+ ToDo = uTasks.Count(t => t.StatusId == AppTaskStatus.Todo),
269
+ Overdue = uTasks.Count(t => t.DueDate < now && t.StatusId != AppTaskStatus.Done),
270
+ CompletionRate = uTasks.Count > 0 ? Math.Round((double)uTasks.Count(t => t.StatusId == AppTaskStatus.Done) / uTasks.Count * 100, 1) : 0
271
  };
272
  }).ToList();
273
 
 
313
  return ms.ToArray();
314
  }
315
 
316
+ // ─── Report 3: Overdue & Critical Tasks ───────────────────────────────────
317
 
318
  public async Task<Result<IEnumerable<OverdueCriticalTaskDto>>> GetOverdueCriticalTasksAsync(string? search, long? projectId, string roleName, long currentUserId)
319
  {
 
321
  var query = BuildAccessibleTaskQuery(roleName, currentUserId)
322
  .Include(t => t.Project)
323
  .Include(t => t.AssignedToNavigation)
324
+ .Where(t => t.IsDeleted != true && t.StatusId != AppTaskStatus.Done &&
325
+ (t.DueDate < now || t.PriorityId == TaskPriority.High)); // overdue OR high priority
326
 
327
  if (projectId.HasValue && projectId > 0)
328
  query = query.Where(t => t.ProjectId == projectId.Value);
 
389
  }
390
 
391
  public async Task<Result<TimeTrackingReportDto>> GetTimeTrackingReportAsync(
392
+ string? search, long? projectId, AppTaskStatus? statusId, string roleName, long currentUserId)
393
  {
394
  var query = BuildAccessibleTaskQuery(roleName, currentUserId)
395
  .Include(t => t.Project)
 
399
 
400
  if (projectId.HasValue && projectId > 0)
401
  query = query.Where(t => t.ProjectId == projectId.Value);
402
+ if (statusId.HasValue)
403
  query = query.Where(t => t.StatusId == statusId.Value);
404
 
405
  var tasks = await query.ToListAsync();
TaskTrackingSystem.WebApi/Features/Role/RoleController.cs CHANGED
@@ -46,7 +46,7 @@ namespace TaskTrackingSystem.WebApi.Features.Role
46
  return Forbid();
47
  }
48
 
49
- long? currentUserId = null;
50
  var result = await _roleService.CreateRoleAsync(createRoleDto, currentUserId);
51
  return StatusCode(result.StatusCode, result);
52
  }
@@ -59,7 +59,7 @@ namespace TaskTrackingSystem.WebApi.Features.Role
59
  return Forbid();
60
  }
61
 
62
- long? currentUserId = null;
63
  var result = await _roleService.UpdateRoleAsync(id, updateRoleDto, currentUserId);
64
  return StatusCode(result.StatusCode, result);
65
  }
@@ -72,7 +72,7 @@ namespace TaskTrackingSystem.WebApi.Features.Role
72
  return Forbid();
73
  }
74
 
75
- var result = await _roleService.SoftDeleteRoleAsync(id);
76
  return StatusCode(result.StatusCode, result);
77
  }
78
 
@@ -103,7 +103,7 @@ namespace TaskTrackingSystem.WebApi.Features.Role
103
  return Forbid();
104
  }
105
 
106
- var result = await _roleService.AssignAccessToRoleAsync(id, dto);
107
  if (!result.IsSuccess)
108
  {
109
  return StatusCode(result.StatusCode, new { message = result.ErrorMessage });
 
46
  return Forbid();
47
  }
48
 
49
+ long? currentUserId = User.GetUserId();
50
  var result = await _roleService.CreateRoleAsync(createRoleDto, currentUserId);
51
  return StatusCode(result.StatusCode, result);
52
  }
 
59
  return Forbid();
60
  }
61
 
62
+ long? currentUserId = User.GetUserId();
63
  var result = await _roleService.UpdateRoleAsync(id, updateRoleDto, currentUserId);
64
  return StatusCode(result.StatusCode, result);
65
  }
 
72
  return Forbid();
73
  }
74
 
75
+ var result = await _roleService.SoftDeleteRoleAsync(id, User.GetUserId());
76
  return StatusCode(result.StatusCode, result);
77
  }
78
 
 
103
  return Forbid();
104
  }
105
 
106
+ var result = await _roleService.AssignAccessToRoleAsync(id, dto, User.GetUserId());
107
  if (!result.IsSuccess)
108
  {
109
  return StatusCode(result.StatusCode, new { message = result.ErrorMessage });
TaskTrackingSystem.WebApi/Features/Role/RoleService.cs CHANGED
@@ -87,7 +87,10 @@ namespace TaskTrackingSystem.WebApi.Features.Role
87
 
88
  await _auditLog.LogAsync("Create", "Role", $"Created new role '{role.Name}'");
89
 
90
- var assignResult = await AssignAccessToRoleAsync(role.Id, new AssignAccessDto { AccessCodes = dto.AccessCodes ?? new List<string>() });
 
 
 
91
  if (!assignResult.IsSuccess)
92
  {
93
  return Result<RoleDto>.Failure(assignResult.ErrorMessage ?? ResultMessages.FailedToCreateRole, assignResult.StatusCode);
@@ -132,7 +135,7 @@ namespace TaskTrackingSystem.WebApi.Features.Role
132
  return Result.Success(200);
133
  }
134
 
135
- public async Task<Result> SoftDeleteRoleAsync(long id)
136
  {
137
  var role = await _db.Roles.FirstOrDefaultAsync(r => r.Id == id && r.IsDeleted != true);
138
  if (role == null)
@@ -141,6 +144,8 @@ namespace TaskTrackingSystem.WebApi.Features.Role
141
  }
142
 
143
  role.IsDeleted = true;
 
 
144
  _db.Roles.Update(role);
145
  await _db.SaveChangesAsync();
146
  await _auditLog.LogAsync("Delete", "Role", $"Deleted role '{role.Name}'");
@@ -173,7 +178,7 @@ namespace TaskTrackingSystem.WebApi.Features.Role
173
  return Result<List<string>>.Success(assignedCodes);
174
  }
175
 
176
- public async Task<Result> AssignAccessToRoleAsync(long roleId, AssignAccessDto dto)
177
  {
178
  var role = await _db.Roles.FirstOrDefaultAsync(r => r.Id == roleId && r.IsDeleted != true);
179
  if (role == null)
@@ -231,51 +236,103 @@ namespace TaskTrackingSystem.WebApi.Features.Role
231
  .Distinct()
232
  .ToList();
233
 
234
- var existingRoleMenus = await _db.RoleMenus.Where(rm => rm.RoleId == role.Id).ToListAsync();
235
- var existingRolePermissions = await _db.RolePermissions.Where(rp => rp.RoleId == role.Id).ToListAsync();
 
 
 
 
 
236
 
237
- if (existingRoleMenus.Any())
238
- {
239
- _db.RoleMenus.RemoveRange(existingRoleMenus);
240
- }
 
 
 
 
 
 
 
241
 
242
- if (existingRolePermissions.Any())
243
  {
244
- _db.RolePermissions.RemoveRange(existingRolePermissions);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
  }
246
 
247
  foreach (var menuId in menuIdsToPersist)
248
  {
 
 
 
 
 
249
  _db.RoleMenus.Add(new RoleMenu
250
  {
251
  RoleId = role.Id,
252
  MenuId = menuId,
253
  IsDeleted = false,
254
- CreatedAt = DateTime.UtcNow
 
255
  });
256
  }
257
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
258
  foreach (var permissionId in permissionIdsToPersist)
259
  {
 
 
 
 
 
260
  _db.RolePermissions.Add(new RolePermission
261
  {
262
  RoleId = role.Id,
263
  PermissionId = permissionId,
264
  IsDeleted = false,
265
- CreatedAt = DateTime.UtcNow
 
266
  });
267
  }
268
 
269
  // Build a human-readable diff of what changed
270
- var oldMenuCodes = existingRoleMenus
271
- .Where(rm => !rm.IsDeleted)
272
- .Select(rm => rm.MenuId)
273
- .ToHashSet();
274
- var oldPermissionIds = existingRolePermissions
275
- .Where(rp => !rp.IsDeleted)
276
- .Select(rp => rp.PermissionId)
277
- .ToHashSet();
278
-
279
  // Load all menu and permission names for diff display
280
  var allMenuNames = await _db.Menus
281
  .Where(m => !m.IsDeleted)
@@ -292,12 +349,12 @@ namespace TaskTrackingSystem.WebApi.Features.Role
292
  p => $"{p.MenuName} — {p.ActionName}");
293
 
294
  var addedMenuNames = menuIdsToPersist
295
- .Except(oldMenuCodes)
296
  .Select(id => menuNameLookup.TryGetValue(id, out var n) ? n : id.ToString())
297
  .OrderBy(n => n)
298
  .ToList();
299
 
300
- var removedMenuNames = oldMenuCodes
301
  .Except(menuIdsToPersist)
302
  .Select(id => menuNameLookup.TryGetValue(id, out var n) ? n : id.ToString())
303
  .OrderBy(n => n)
 
87
 
88
  await _auditLog.LogAsync("Create", "Role", $"Created new role '{role.Name}'");
89
 
90
+ var assignResult = await AssignAccessToRoleAsync(
91
+ role.Id,
92
+ new AssignAccessDto { AccessCodes = dto.AccessCodes ?? new List<string>() },
93
+ currentUserId);
94
  if (!assignResult.IsSuccess)
95
  {
96
  return Result<RoleDto>.Failure(assignResult.ErrorMessage ?? ResultMessages.FailedToCreateRole, assignResult.StatusCode);
 
135
  return Result.Success(200);
136
  }
137
 
138
+ public async Task<Result> SoftDeleteRoleAsync(long id, long? currentUserId = null)
139
  {
140
  var role = await _db.Roles.FirstOrDefaultAsync(r => r.Id == id && r.IsDeleted != true);
141
  if (role == null)
 
144
  }
145
 
146
  role.IsDeleted = true;
147
+ role.UpdatedAt = DateTime.UtcNow;
148
+ role.UpdatedBy = currentUserId;
149
  _db.Roles.Update(role);
150
  await _db.SaveChangesAsync();
151
  await _auditLog.LogAsync("Delete", "Role", $"Deleted role '{role.Name}'");
 
178
  return Result<List<string>>.Success(assignedCodes);
179
  }
180
 
181
+ public async Task<Result> AssignAccessToRoleAsync(long roleId, AssignAccessDto dto, long? currentUserId = null)
182
  {
183
  var role = await _db.Roles.FirstOrDefaultAsync(r => r.Id == roleId && r.IsDeleted != true);
184
  if (role == null)
 
236
  .Distinct()
237
  .ToList();
238
 
239
+ var now = DateTime.UtcNow;
240
+ var existingRoleMenus = await _db.RoleMenus
241
+ .Where(rm => rm.RoleId == role.Id)
242
+ .ToListAsync();
243
+ var existingRolePermissions = await _db.RolePermissions
244
+ .Where(rp => rp.RoleId == role.Id)
245
+ .ToListAsync();
246
 
247
+ var oldMenuIds = existingRoleMenus
248
+ .Where(rm => !rm.IsDeleted)
249
+ .Select(rm => rm.MenuId)
250
+ .ToHashSet();
251
+ var oldPermissionIds = existingRolePermissions
252
+ .Where(rp => !rp.IsDeleted)
253
+ .Select(rp => rp.PermissionId)
254
+ .ToHashSet();
255
+
256
+ var existingRoleMenusByMenuId = existingRoleMenus.ToDictionary(rm => rm.MenuId);
257
+ var existingRolePermissionsByPermissionId = existingRolePermissions.ToDictionary(rp => rp.PermissionId);
258
 
259
+ foreach (var existingMenu in existingRoleMenus)
260
  {
261
+ var shouldBeActive = menuIdsToPersist.Contains(existingMenu.MenuId);
262
+
263
+ if (shouldBeActive)
264
+ {
265
+ if (existingMenu.IsDeleted)
266
+ {
267
+ existingMenu.IsDeleted = false;
268
+ existingMenu.UpdatedAt = now;
269
+ existingMenu.UpdatedById = currentUserId;
270
+ }
271
+ }
272
+ else if (!existingMenu.IsDeleted)
273
+ {
274
+ existingMenu.IsDeleted = true;
275
+ existingMenu.UpdatedAt = now;
276
+ existingMenu.UpdatedById = currentUserId;
277
+ }
278
  }
279
 
280
  foreach (var menuId in menuIdsToPersist)
281
  {
282
+ if (existingRoleMenusByMenuId.ContainsKey(menuId))
283
+ {
284
+ continue;
285
+ }
286
+
287
  _db.RoleMenus.Add(new RoleMenu
288
  {
289
  RoleId = role.Id,
290
  MenuId = menuId,
291
  IsDeleted = false,
292
+ CreatedAt = now,
293
+ CreatedById = currentUserId
294
  });
295
  }
296
 
297
+ foreach (var existingPermission in existingRolePermissions)
298
+ {
299
+ var shouldBeActive = permissionIdsToPersist.Contains(existingPermission.PermissionId);
300
+
301
+ if (shouldBeActive)
302
+ {
303
+ if (existingPermission.IsDeleted)
304
+ {
305
+ existingPermission.IsDeleted = false;
306
+ existingPermission.UpdatedAt = now;
307
+ existingPermission.UpdatedById = currentUserId;
308
+ }
309
+ }
310
+ else if (!existingPermission.IsDeleted)
311
+ {
312
+ existingPermission.IsDeleted = true;
313
+ existingPermission.UpdatedAt = now;
314
+ existingPermission.UpdatedById = currentUserId;
315
+ }
316
+ }
317
+
318
  foreach (var permissionId in permissionIdsToPersist)
319
  {
320
+ if (existingRolePermissionsByPermissionId.ContainsKey(permissionId))
321
+ {
322
+ continue;
323
+ }
324
+
325
  _db.RolePermissions.Add(new RolePermission
326
  {
327
  RoleId = role.Id,
328
  PermissionId = permissionId,
329
  IsDeleted = false,
330
+ CreatedAt = now,
331
+ CreatedById = currentUserId
332
  });
333
  }
334
 
335
  // Build a human-readable diff of what changed
 
 
 
 
 
 
 
 
 
336
  // Load all menu and permission names for diff display
337
  var allMenuNames = await _db.Menus
338
  .Where(m => !m.IsDeleted)
 
349
  p => $"{p.MenuName} — {p.ActionName}");
350
 
351
  var addedMenuNames = menuIdsToPersist
352
+ .Except(oldMenuIds)
353
  .Select(id => menuNameLookup.TryGetValue(id, out var n) ? n : id.ToString())
354
  .OrderBy(n => n)
355
  .ToList();
356
 
357
+ var removedMenuNames = oldMenuIds
358
  .Except(menuIdsToPersist)
359
  .Select(id => menuNameLookup.TryGetValue(id, out var n) ? n : id.ToString())
360
  .OrderBy(n => n)
TaskTrackingSystem.WebApi/Features/Task/TaskController.cs CHANGED
@@ -5,6 +5,7 @@ using TaskTrackingSystem.Shared;
5
  using TaskTrackingSystem.Shared.Models.Task;
6
  using TaskTrackingSystem.WebApi.Features.Task;
7
  using TaskTrackingSystem.WebApi.Infrastructure;
 
8
 
9
  namespace TaskTrackingSystem.WebApi.Features.Task
10
  {
@@ -86,7 +87,7 @@ namespace TaskTrackingSystem.WebApi.Features.Task
86
  }
87
 
88
  [HttpPatch("{id}/status")]
89
- public async Task<IActionResult> UpdateTaskStatus(long id, [FromQuery] long statusId)
90
  {
91
  var result = await _taskService.UpdateTaskStatusAsync(id, statusId, User.GetRoleName(), User.GetUserId());
92
  if (!result.IsSuccess)
 
5
  using TaskTrackingSystem.Shared.Models.Task;
6
  using TaskTrackingSystem.WebApi.Features.Task;
7
  using TaskTrackingSystem.WebApi.Infrastructure;
8
+ using TaskTrackingSystem.Shared.Enums;
9
 
10
  namespace TaskTrackingSystem.WebApi.Features.Task
11
  {
 
87
  }
88
 
89
  [HttpPatch("{id}/status")]
90
+ public async Task<IActionResult> UpdateTaskStatus(long id, [FromQuery] AppTaskStatus statusId)
91
  {
92
  var result = await _taskService.UpdateTaskStatusAsync(id, statusId, User.GetRoleName(), User.GetUserId());
93
  if (!result.IsSuccess)
TaskTrackingSystem.WebApi/Features/Task/TaskDetailsController.cs CHANGED
@@ -21,10 +21,12 @@ namespace TaskTrackingSystem.WebApi.Features.Task
21
  public class TaskDetailsController : ControllerBase
22
  {
23
  private readonly AppDbContext _db;
 
24
 
25
- public TaskDetailsController(AppDbContext db)
26
  {
27
  _db = db;
 
28
  }
29
 
30
  // ─── COMMENTS ────────────────────────────────────────────────────────
@@ -96,6 +98,8 @@ namespace TaskTrackingSystem.WebApi.Features.Task
96
  _db.Comments.Add(comment);
97
  await _db.SaveChangesAsync();
98
 
 
 
99
  var resultDto = new CommentDto
100
  {
101
  Id = comment.Id,
 
21
  public class TaskDetailsController : ControllerBase
22
  {
23
  private readonly AppDbContext _db;
24
+ private readonly TaskTrackingSystem.WebApi.Features.Notification.FirebaseNotificationService _notificationService;
25
 
26
+ public TaskDetailsController(AppDbContext db, TaskTrackingSystem.WebApi.Features.Notification.FirebaseNotificationService notificationService)
27
  {
28
  _db = db;
29
+ _notificationService = notificationService;
30
  }
31
 
32
  // ─── COMMENTS ────────────────────────────────────────────────────────
 
98
  _db.Comments.Add(comment);
99
  await _db.SaveChangesAsync();
100
 
101
+ await _notificationService.NotifyCommentAddedAsync(task, userId, $"{user.FirstName} {user.LastName}");
102
+
103
  var resultDto = new CommentDto
104
  {
105
  Id = comment.Id,
TaskTrackingSystem.WebApi/Features/Task/TaskService.cs CHANGED
@@ -6,18 +6,20 @@ using System.Threading.Tasks;
6
  using TaskTrackingSystem.Database;
7
  using TaskTrackingSystem.Database.AppDbContextModels;
8
  using TaskTrackingSystem.Shared.Models.Task;
9
-
10
  using TaskTrackingSystem.Shared;
 
11
 
12
  namespace TaskTrackingSystem.WebApi.Features.Task
13
  {
14
  public class TaskService
15
  {
16
  private readonly AppDbContext _db;
 
17
 
18
- public TaskService(AppDbContext db)
19
  {
20
  _db = db;
 
21
  }
22
 
23
  public async Task<IEnumerable<TaskDto>> GetAllTasksAsync(string roleName, long currentUserId)
@@ -107,8 +109,8 @@ namespace TaskTrackingSystem.WebApi.Features.Task
107
  Title = dto.Title,
108
  Description = dto.Description,
109
  ProjectId = dto.ProjectId,
110
- StatusId = dto.StatusId == 0 ? 1 : dto.StatusId,
111
- PriorityId = dto.PriorityId == 0 ? 2 : dto.PriorityId,
112
  AssignedTo = dto.AssignedTo,
113
  AssignedBy = dto.AssignedBy,
114
  EstimatedHours = dto.EstimatedHours,
@@ -121,6 +123,11 @@ namespace TaskTrackingSystem.WebApi.Features.Task
121
  _db.Tasks.Add(task);
122
  await _db.SaveChangesAsync();
123
 
 
 
 
 
 
124
  var resultDto = new TaskDto
125
  {
126
  Id = task.Id,
@@ -150,6 +157,8 @@ namespace TaskTrackingSystem.WebApi.Features.Task
150
  var task = await _db.Tasks.FirstOrDefaultAsync(t => t.Id == id && t.IsDeleted != true);
151
  if (task == null) return Result.Failure(ResultMessages.TaskNotFound(id), 404);
152
 
 
 
153
  if (dto.AssignedTo.HasValue && !await IsProjectMemberAsync(task.ProjectId, dto.AssignedTo.Value))
154
  {
155
  return Result.Failure("Task assignee must belong to the selected project.", 400);
@@ -166,8 +175,26 @@ namespace TaskTrackingSystem.WebApi.Features.Task
166
  task.UpdatedAt = DateTime.UtcNow;
167
  task.UpdatedBy = currentUserId;
168
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  _db.Tasks.Update(task);
170
  await _db.SaveChangesAsync();
 
 
 
 
 
171
  return Result.Success(200);
172
  }
173
 
@@ -218,7 +245,7 @@ namespace TaskTrackingSystem.WebApi.Features.Task
218
  return Result<IEnumerable<TaskDto>>.Success(tasks);
219
  }
220
 
221
- public async Task<Result> UpdateTaskStatusAsync(long id, long statusId, string roleName, long currentUserId)
222
  {
223
  var task = await _db.Tasks.FirstOrDefaultAsync(t => t.Id == id && t.IsDeleted != true);
224
  if (task == null)
@@ -226,13 +253,30 @@ namespace TaskTrackingSystem.WebApi.Features.Task
226
  return Result.Failure(ResultMessages.TaskNotFound(id), 404);
227
  }
228
 
 
 
229
  task.StatusId = statusId;
230
  task.UpdatedAt = DateTime.UtcNow;
231
  task.UpdatedBy = currentUserId;
232
 
 
 
 
 
 
 
 
 
 
 
233
  _db.Tasks.Update(task);
234
  await _db.SaveChangesAsync();
235
 
 
 
 
 
 
236
  return Result.Success(200);
237
  }
238
 
 
6
  using TaskTrackingSystem.Database;
7
  using TaskTrackingSystem.Database.AppDbContextModels;
8
  using TaskTrackingSystem.Shared.Models.Task;
 
9
  using TaskTrackingSystem.Shared;
10
+ using TaskTrackingSystem.Shared.Enums;
11
 
12
  namespace TaskTrackingSystem.WebApi.Features.Task
13
  {
14
  public class TaskService
15
  {
16
  private readonly AppDbContext _db;
17
+ private readonly TaskTrackingSystem.WebApi.Features.Notification.FirebaseNotificationService _notificationService;
18
 
19
+ public TaskService(AppDbContext db, TaskTrackingSystem.WebApi.Features.Notification.FirebaseNotificationService notificationService)
20
  {
21
  _db = db;
22
+ _notificationService = notificationService;
23
  }
24
 
25
  public async Task<IEnumerable<TaskDto>> GetAllTasksAsync(string roleName, long currentUserId)
 
109
  Title = dto.Title,
110
  Description = dto.Description,
111
  ProjectId = dto.ProjectId,
112
+ StatusId = dto.StatusId == 0 ? AppTaskStatus.Todo : dto.StatusId,
113
+ PriorityId = dto.PriorityId == 0 ? TaskPriority.Medium : dto.PriorityId,
114
  AssignedTo = dto.AssignedTo,
115
  AssignedBy = dto.AssignedBy,
116
  EstimatedHours = dto.EstimatedHours,
 
123
  _db.Tasks.Add(task);
124
  await _db.SaveChangesAsync();
125
 
126
+ if (task.AssignedTo.HasValue)
127
+ {
128
+ await _notificationService.NotifyTaskAssignedAsync(task, currentUserId);
129
+ }
130
+
131
  var resultDto = new TaskDto
132
  {
133
  Id = task.Id,
 
157
  var task = await _db.Tasks.FirstOrDefaultAsync(t => t.Id == id && t.IsDeleted != true);
158
  if (task == null) return Result.Failure(ResultMessages.TaskNotFound(id), 404);
159
 
160
+ var previousStatusId = task.StatusId;
161
+
162
  if (dto.AssignedTo.HasValue && !await IsProjectMemberAsync(task.ProjectId, dto.AssignedTo.Value))
163
  {
164
  return Result.Failure("Task assignee must belong to the selected project.", 400);
 
175
  task.UpdatedAt = DateTime.UtcNow;
176
  task.UpdatedBy = currentUserId;
177
 
178
+ if (previousStatusId != dto.StatusId)
179
+ {
180
+ _db.TaskHistories.Add(new TaskHistory
181
+ {
182
+ TaskId = task.Id,
183
+ ModifiedById = currentUserId ?? task.UpdatedBy ?? task.CreatedBy ?? 0,
184
+ OldStatusId = previousStatusId,
185
+ NewStatusId = dto.StatusId,
186
+ CreatedBy = currentUserId ?? task.CreatedBy ?? 0,
187
+ CreatedAt = DateTime.UtcNow
188
+ });
189
+ }
190
+
191
  _db.Tasks.Update(task);
192
  await _db.SaveChangesAsync();
193
+
194
+ if (previousStatusId != dto.StatusId && currentUserId.HasValue)
195
+ {
196
+ await _notificationService.NotifyTaskStatusChangedAsync(task, currentUserId.Value, previousStatusId, dto.StatusId);
197
+ }
198
  return Result.Success(200);
199
  }
200
 
 
245
  return Result<IEnumerable<TaskDto>>.Success(tasks);
246
  }
247
 
248
+ public async Task<Result> UpdateTaskStatusAsync(long id, AppTaskStatus statusId, string roleName, long currentUserId)
249
  {
250
  var task = await _db.Tasks.FirstOrDefaultAsync(t => t.Id == id && t.IsDeleted != true);
251
  if (task == null)
 
253
  return Result.Failure(ResultMessages.TaskNotFound(id), 404);
254
  }
255
 
256
+ var previousStatusId = task.StatusId;
257
+
258
  task.StatusId = statusId;
259
  task.UpdatedAt = DateTime.UtcNow;
260
  task.UpdatedBy = currentUserId;
261
 
262
+ _db.TaskHistories.Add(new TaskHistory
263
+ {
264
+ TaskId = task.Id,
265
+ ModifiedById = currentUserId,
266
+ OldStatusId = previousStatusId,
267
+ NewStatusId = statusId,
268
+ CreatedBy = currentUserId,
269
+ CreatedAt = DateTime.UtcNow
270
+ });
271
+
272
  _db.Tasks.Update(task);
273
  await _db.SaveChangesAsync();
274
 
275
+ if (previousStatusId != statusId)
276
+ {
277
+ await _notificationService.NotifyTaskStatusChangedAsync(task, currentUserId, previousStatusId, statusId);
278
+ }
279
+
280
  return Result.Success(200);
281
  }
282
 
TaskTrackingSystem.WebApi/Features/UserDevice/UserDeviceService.cs ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ using Microsoft.EntityFrameworkCore;
2
+ using TaskTrackingSystem.Database.AppDbContextModels;
3
+ using TaskTrackingSystem.Shared;
4
+ using DbUserDevice = TaskTrackingSystem.Database.AppDbContextModels.UserDevice;
5
+
6
+ namespace TaskTrackingSystem.WebApi.Features.UserDevice;
7
+
8
+ public class UserDeviceService
9
+ {
10
+ private readonly AppDbContext _db;
11
+
12
+ public UserDeviceService(AppDbContext db)
13
+ {
14
+ _db = db;
15
+ }
16
+
17
+ public async Task<Result> RegisterTokenAsync(long userId, string fcmToken)
18
+ {
19
+ var token = fcmToken.Trim();
20
+ if (string.IsNullOrWhiteSpace(token))
21
+ {
22
+ return Result.Failure("FCM token is required.", 400);
23
+ }
24
+
25
+ var existing = await _db.UserDevices.FirstOrDefaultAsync(x => x.FcmToken == token);
26
+ var now = DateTime.UtcNow;
27
+
28
+ if (existing == null)
29
+ {
30
+ _db.UserDevices.Add(new DbUserDevice
31
+ {
32
+ UserId = userId,
33
+ FcmToken = token,
34
+ CreatedAt = now,
35
+ UpdatedAt = now
36
+ });
37
+ }
38
+ else
39
+ {
40
+ existing.UserId = userId;
41
+ existing.UpdatedAt = now;
42
+ }
43
+
44
+ await _db.SaveChangesAsync();
45
+ return Result.Success();
46
+ }
47
+
48
+ public async Task<Result> RemoveTokenAsync(long userId, string fcmToken)
49
+ {
50
+ var token = fcmToken.Trim();
51
+ if (string.IsNullOrWhiteSpace(token))
52
+ {
53
+ return Result.Failure("FCM token is required.", 400);
54
+ }
55
+
56
+ var device = await _db.UserDevices.FirstOrDefaultAsync(x => x.UserId == userId && x.FcmToken == token);
57
+ if (device == null)
58
+ {
59
+ return Result.Success(204);
60
+ }
61
+
62
+ _db.UserDevices.Remove(device);
63
+ await _db.SaveChangesAsync();
64
+ return Result.Success(204);
65
+ }
66
+ }
TaskTrackingSystem.WebApi/Features/UserDevice/UserDevicesController.cs ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ using Microsoft.AspNetCore.Authorization;
2
+ using Microsoft.AspNetCore.Mvc;
3
+ using TaskTrackingSystem.Shared;
4
+ using TaskTrackingSystem.Shared.Models.Notification;
5
+ using TaskTrackingSystem.WebApi.Infrastructure;
6
+
7
+ namespace TaskTrackingSystem.WebApi.Features.UserDevice;
8
+
9
+ [ApiController]
10
+ [Authorize]
11
+ [Route("api/[controller]")]
12
+ public class UserDevicesController : ControllerBase
13
+ {
14
+ private readonly UserDeviceService _userDeviceService;
15
+
16
+ public UserDevicesController(UserDeviceService userDeviceService)
17
+ {
18
+ _userDeviceService = userDeviceService;
19
+ }
20
+
21
+ [HttpPost("register")]
22
+ public async Task<ActionResult<Result>> Register([FromBody] RegisterDeviceTokenDto dto)
23
+ {
24
+ var userId = User.GetUserId();
25
+ if (userId <= 0)
26
+ {
27
+ return Unauthorized(Result.Failure("User is not authenticated.", 401));
28
+ }
29
+
30
+ var result = await _userDeviceService.RegisterTokenAsync(userId, dto.FcmToken);
31
+ return StatusCode(result.StatusCode, result);
32
+ }
33
+
34
+ [HttpPost("unregister")]
35
+ public async Task<ActionResult<Result>> Unregister([FromBody] RegisterDeviceTokenDto dto)
36
+ {
37
+ var userId = User.GetUserId();
38
+ if (userId <= 0)
39
+ {
40
+ return Unauthorized(Result.Failure("User is not authenticated.", 401));
41
+ }
42
+
43
+ var result = await _userDeviceService.RemoveTokenAsync(userId, dto.FcmToken);
44
+ return StatusCode(result.StatusCode, result);
45
+ }
46
+ }
TaskTrackingSystem.WebApi/Program.cs CHANGED
@@ -11,6 +11,7 @@ var builder = WebApplication.CreateBuilder(args);
11
  // Add services to the container.
12
 
13
  builder.Services.AddControllers();
 
14
 
15
  // CORS policy for WebApp
16
  builder.Services.AddCors(options =>
@@ -32,6 +33,9 @@ builder.Services.AddScoped<TaskTrackingSystem.WebApi.Features.Task.TaskService>(
32
  builder.Services.AddScoped<TaskTrackingSystem.WebApi.Features.Dashboard.DashboardService>();
33
  builder.Services.AddScoped<TaskTrackingSystem.WebApi.Features.Report.ReportService>();
34
  builder.Services.AddScoped<TaskTrackingSystem.WebApi.Features.Menu.MenuService>();
 
 
 
35
  builder.Services.AddScoped<TaskTrackingSystem.WebApi.Infrastructure.PermissionAuthorizationService>();
36
  builder.Services.AddScoped<IPasswordHasher<TaskTrackingSystem.Database.AppDbContextModels.User>, PasswordHasher<TaskTrackingSystem.Database.AppDbContextModels.User>>();
37
  // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
@@ -120,5 +124,19 @@ static async System.Threading.Tasks.Task EnsureSeedDataAsync(WebApplication app)
120
  backlogMenu.UpdatedAt = DateTime.UtcNow;
121
  }
122
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  await db.SaveChangesAsync();
124
  }
 
11
  // Add services to the container.
12
 
13
  builder.Services.AddControllers();
14
+ builder.Services.AddHttpClient();
15
 
16
  // CORS policy for WebApp
17
  builder.Services.AddCors(options =>
 
33
  builder.Services.AddScoped<TaskTrackingSystem.WebApi.Features.Dashboard.DashboardService>();
34
  builder.Services.AddScoped<TaskTrackingSystem.WebApi.Features.Report.ReportService>();
35
  builder.Services.AddScoped<TaskTrackingSystem.WebApi.Features.Menu.MenuService>();
36
+ builder.Services.AddScoped<TaskTrackingSystem.WebApi.Features.UserDevice.UserDeviceService>();
37
+ builder.Services.AddScoped<TaskTrackingSystem.WebApi.Features.Notification.FirebaseNotificationService>();
38
+ builder.Services.AddScoped<TaskTrackingSystem.WebApi.Features.Notification.NotificationService>();
39
  builder.Services.AddScoped<TaskTrackingSystem.WebApi.Infrastructure.PermissionAuthorizationService>();
40
  builder.Services.AddScoped<IPasswordHasher<TaskTrackingSystem.Database.AppDbContextModels.User>, PasswordHasher<TaskTrackingSystem.Database.AppDbContextModels.User>>();
41
  // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
 
124
  backlogMenu.UpdatedAt = DateTime.UtcNow;
125
  }
126
 
127
+ // Soft-delete existing tasks that belong to deleted projects
128
+ var orphanedTasks = await db.Tasks
129
+ .Where(t => t.IsDeleted != true && t.Project.IsDeleted == true)
130
+ .ToListAsync();
131
+ foreach (var task in orphanedTasks)
132
+ {
133
+ task.IsDeleted = true;
134
+ task.UpdatedAt = DateTime.UtcNow;
135
+ }
136
+ if (orphanedTasks.Any())
137
+ {
138
+ db.Tasks.UpdateRange(orphanedTasks);
139
+ }
140
+
141
  await db.SaveChangesAsync();
142
  }
TaskTrackingSystem.WebApi/TaskTrackingSystem.WebApi.csproj CHANGED
@@ -21,6 +21,12 @@
21
  <ProjectReference Include="..\TaskTrackingSystem.Shared\TaskTrackingSystem.Shared.csproj" />
22
  </ItemGroup>
23
 
 
 
 
 
 
 
24
  <ItemGroup>
25
  <Folder Include="Features\Dashboard\" />
26
  <Folder Include="Features\Report\" />
 
21
  <ProjectReference Include="..\TaskTrackingSystem.Shared\TaskTrackingSystem.Shared.csproj" />
22
  </ItemGroup>
23
 
24
+ <ItemGroup>
25
+ <None Update="Firebase\ttsfirebasekey.json">
26
+ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
27
+ </None>
28
+ </ItemGroup>
29
+
30
  <ItemGroup>
31
  <Folder Include="Features\Dashboard\" />
32
  <Folder Include="Features\Report\" />
TaskTrackingSystem.WebApi/appsettings.Development.json CHANGED
@@ -4,5 +4,8 @@
4
  "Default": "Information",
5
  "Microsoft.AspNetCore": "Warning"
6
  }
 
 
 
7
  }
8
  }
 
4
  "Default": "Information",
5
  "Microsoft.AspNetCore": "Warning"
6
  }
7
+ },
8
+ "Firebase": {
9
+ "ServiceAccountPath": "Firebase/ttsfirebasekey.json"
10
  }
11
  }
TaskTrackingSystem.WebApi/appsettings.json CHANGED
@@ -14,6 +14,9 @@
14
  "PasswordReset": {
15
  "RecoveryCode": "TASKIFY-RESET-2026"
16
  },
 
 
 
17
  "ConnectionStrings": {
18
  "DefaultConnection": "Server=.;Database=TTS;Trusted_Connection=True;TrustServerCertificate=True;Connect Timeout=5;"
19
  }
 
14
  "PasswordReset": {
15
  "RecoveryCode": "TASKIFY-RESET-2026"
16
  },
17
+ "Firebase": {
18
+ "ServiceAccountPath": "Firebase/ttsfirebasekey.json"
19
+ },
20
  "ConnectionStrings": {
21
  "DefaultConnection": "Server=.;Database=TTS;Trusted_Connection=True;TrustServerCertificate=True;Connect Timeout=5;"
22
  }
TaskTrackingSystem.WebApp/Components/App.razor CHANGED
@@ -12,33 +12,212 @@
12
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
13
  <script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
14
  <script src="https://cdn.tailwindcss.com"></script>
 
 
 
 
15
  <script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  tailwind.config = {
17
  darkMode: 'class',
18
  theme: {
19
- extend: {
20
- colors: {
21
- background: "#F4F3FA",
22
- foreground: "#1E1B4B",
23
- card: "#FFFFFF",
24
- "card-foreground": "#1E1B4B",
25
- popover: "#FFFFFF",
26
- "popover-foreground": "#1E1B4B",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
- primary: "#7C3AED",
29
- "primary-foreground": "#FFFFFF",
30
 
31
- secondary: "#EEF2F6",
32
- "secondary-foreground": "#1E1B4B",
33
 
34
- muted: "#F3F4F6",
35
- "muted-foreground": "#6B7280",
36
 
37
- accent: "#8B5CF6",
38
- "accent-foreground": "#FFFFFF",
39
 
40
- border: "#E5E7EB",
41
- }
42
  }
43
  }
44
  }
 
12
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
13
  <script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
14
  <script src="https://cdn.tailwindcss.com"></script>
15
+ <script src="firebase-app-compat.js"></script>
16
+ <script src="firebase-messaging-compat.js"></script>
17
+ <script src="firebase-config.js"></script>
18
+ <script src="firebase-messaging.js"></script>
19
  <script>
20
+ const brandDark = "#10002B";
21
+ const brandDarkAlt = "#240046";
22
+ const brandPrimary = "#5A189A";
23
+ const brandAccent = "#7B2CBF";
24
+ const brandLight = "#E0AAFF";
25
+ const brandMuted = "#9D4EDD";
26
+
27
+ const semanticRedBg = "#FFE4E6";
28
+ const semanticRedText = "#991B1B";
29
+
30
+ const semanticGreenBg = "#D1FAE5";
31
+ const semanticGreenText = "#065F46";
32
+
33
+ const semanticPurpleBg = "#E0AAFF";
34
+ const semanticPurpleText = "#3C096C";
35
+
36
+ const neutralBg = "#F8F9FA";
37
+ const neutralBorder = "#E5E7EB";
38
+ const neutralMuted = "#6B7280";
39
+ const neutralDark = "#10002B";
40
+
41
+ const customGray = {
42
+ 50: neutralBg,
43
+ 100: "#F3F4F6",
44
+ 200: neutralBorder,
45
+ 300: "#D1D5DB",
46
+ 400: "#9CA3AF",
47
+ 500: neutralMuted,
48
+ 600: "#4B5563",
49
+ 700: "#374151",
50
+ 800: "#1F2937",
51
+ 900: neutralDark,
52
+ 950: neutralDark
53
+ };
54
+
55
  tailwind.config = {
56
  darkMode: 'class',
57
  theme: {
58
+ colors: {
59
+ transparent: 'transparent',
60
+ current: 'currentColor',
61
+ white: '#ffffff',
62
+ black: '#000000',
63
+
64
+ slate: customGray,
65
+ gray: customGray,
66
+ zinc: customGray,
67
+ neutral: customGray,
68
+ stone: customGray,
69
+
70
+ red: {
71
+ 50: semanticRedBg,
72
+ 100: semanticRedBg,
73
+ 200: "#FECDD3",
74
+ 300: "#FDA4AF",
75
+ 400: "#FB7185",
76
+ 500: semanticRedText,
77
+ 600: semanticRedText,
78
+ 700: semanticRedText,
79
+ 800: semanticRedText,
80
+ 900: semanticRedText
81
+ },
82
+ rose: {
83
+ 50: semanticRedBg,
84
+ 100: semanticRedBg,
85
+ 200: "#FECDD3",
86
+ 300: "#FDA4AF",
87
+ 400: "#FB7185",
88
+ 500: semanticRedText,
89
+ 600: semanticRedText,
90
+ 700: semanticRedText,
91
+ 800: semanticRedText,
92
+ 900: semanticRedText
93
+ },
94
+ green: {
95
+ 50: semanticGreenBg,
96
+ 100: semanticGreenBg,
97
+ 200: "#A7F3D0",
98
+ 300: "#6EE7B7",
99
+ 400: "#34D399",
100
+ 500: semanticGreenText,
101
+ 600: semanticGreenText,
102
+ 700: semanticGreenText,
103
+ 800: semanticGreenText,
104
+ 900: semanticGreenText
105
+ },
106
+ emerald: {
107
+ 50: semanticGreenBg,
108
+ 100: semanticGreenBg,
109
+ 200: "#A7F3D0",
110
+ 300: "#6EE7B7",
111
+ 400: "#34D399",
112
+ 500: semanticGreenText,
113
+ 600: semanticGreenText,
114
+ 700: semanticGreenText,
115
+ 800: semanticGreenText,
116
+ 900: semanticGreenText
117
+ },
118
+ blue: {
119
+ 50: semanticPurpleBg,
120
+ 100: semanticPurpleBg,
121
+ 200: "#C77DFF",
122
+ 300: "#9D4EDD",
123
+ 400: "#7B2CBF",
124
+ 500: semanticPurpleText,
125
+ 600: semanticPurpleText,
126
+ 700: semanticPurpleText,
127
+ 800: semanticPurpleText,
128
+ 900: semanticPurpleText
129
+ },
130
+ amber: {
131
+ 50: semanticPurpleBg,
132
+ 100: semanticPurpleBg,
133
+ 200: "#C77DFF",
134
+ 300: "#9D4EDD",
135
+ 400: "#7B2CBF",
136
+ 500: semanticPurpleText,
137
+ 600: semanticPurpleText,
138
+ 700: semanticPurpleText,
139
+ 800: semanticPurpleText,
140
+ 900: semanticPurpleText
141
+ },
142
+ yellow: {
143
+ 50: semanticPurpleBg,
144
+ 100: semanticPurpleBg,
145
+ 200: "#C77DFF",
146
+ 300: "#9D4EDD",
147
+ 400: "#7B2CBF",
148
+ 500: semanticPurpleText,
149
+ 600: semanticPurpleText,
150
+ 700: semanticPurpleText,
151
+ 800: semanticPurpleText,
152
+ 900: semanticPurpleText
153
+ },
154
+ violet: {
155
+ 50: brandLight,
156
+ 100: "#E8E8FF",
157
+ 200: brandMuted,
158
+ 300: brandMuted,
159
+ 400: brandAccent,
160
+ 500: brandPrimary,
161
+ 600: brandPrimary,
162
+ 700: brandDarkAlt,
163
+ 800: brandDark,
164
+ 900: brandDark,
165
+ 950: brandDark
166
+ },
167
+ purple: {
168
+ 50: brandLight,
169
+ 100: "#E8E8FF",
170
+ 200: brandMuted,
171
+ 300: brandMuted,
172
+ 400: brandAccent,
173
+ 500: brandPrimary,
174
+ 600: brandPrimary,
175
+ 700: brandDarkAlt,
176
+ 800: brandDark,
177
+ 900: brandDark,
178
+ 950: brandDark
179
+ },
180
+ indigo: {
181
+ 50: brandLight,
182
+ 100: "#E8E8FF",
183
+ 200: brandMuted,
184
+ 300: brandMuted,
185
+ 400: brandAccent,
186
+ 500: brandPrimary,
187
+ 600: brandPrimary,
188
+ 700: brandDarkAlt,
189
+ 800: brandDark,
190
+ 900: brandDark,
191
+ 950: brandDark
192
+ },
193
+
194
+ chart: {
195
+ purple: "#5A189A",
196
+ teal: "#06B6D4",
197
+ green: "#10B981",
198
+ neutral: "#E5E7EB"
199
+ },
200
+
201
+ background: neutralBg,
202
+ foreground: neutralDark,
203
+ card: "#FFFFFF",
204
+ "card-foreground": neutralDark,
205
+ popover: "#FFFFFF",
206
+ "popover-foreground": neutralDark,
207
 
208
+ primary: brandPrimary,
209
+ "primary-foreground": "#FFFFFF",
210
 
211
+ secondary: brandLight,
212
+ "secondary-foreground": neutralDark,
213
 
214
+ muted: "#F3F4F6",
215
+ "muted-foreground": neutralMuted,
216
 
217
+ accent: brandAccent,
218
+ "accent-foreground": "#FFFFFF",
219
 
220
+ border: neutralBorder,
 
221
  }
222
  }
223
  }
TaskTrackingSystem.WebApp/Components/Layout/MainLayout.razor CHANGED
@@ -2,29 +2,29 @@
2
  @implements IDisposable
3
  @using System.Security.Claims
4
 
5
- <div class="flex h-screen bg-[#F4F3FA] font-sans text-slate-800 antialiased overflow-hidden">
6
- <aside class="flex w-64 flex-col border-r border-slate-100 bg-white shrink-0 overflow-hidden">
7
  <div class="flex h-20 items-center px-6">
8
- <span class="flex h-9 w-9 items-center justify-center rounded-xl bg-violet-100 text-violet-600">
9
  <i data-lucide="flame" class="h-5 w-5 fill-current"></i>
10
  </span>
11
- <span class="ml-3 font-bold text-xl tracking-tight text-slate-800">Taskify</span>
12
  </div>
13
 
14
  <NavMenu @rendermode="new InteractiveServerRenderMode(prerender: false)" />
15
 
16
- <div class="border-t border-slate-100 p-4 shrink-0">
17
  <AuthorizeView>
18
  <Authorized>
19
- <div class="flex items-center rounded-xl p-2 hover:bg-slate-50 transition-all">
20
  <span class="flex h-10 w-10 items-center justify-center rounded-full bg-violet-600 text-white font-semibold text-sm shrink-0">
21
  @(context.User.Identity?.Name?.Length > 0 ? context.User.Identity.Name[0].ToString().ToUpper() : "U")
22
  </span>
23
  <div class="ml-3 overflow-hidden flex flex-col">
24
- <span class="text-sm font-semibold truncate max-w-[130px]" style="color: var(--text-primary);">@context.User.Identity?.Name</span>
25
  <form id="logout-form" method="post" action="/account/logout" class="mt-0.5">
26
  <AntiforgeryToken />
27
- <button type="button" onclick="document.getElementById('logout-confirm-dialog').style.display='flex';" class="text-xs hover:opacity-80 font-semibold text-left flex items-center gap-1 transition-all" style="color: var(--brand-accent);">
28
  <i data-lucide="log-out" class="h-3 w-3"></i>
29
  Sign Out
30
  </button>
@@ -65,10 +65,12 @@
65
  <header class="flex h-16 items-center justify-between border-b border-slate-200 bg-white px-8">
66
  <h1 class="text-lg font-semibold text-slate-900">Workspace</h1>
67
  <div class="flex items-center space-x-4">
68
- <button class="relative rounded-full p-1.5 text-slate-400 hover:bg-slate-50 hover:text-blue-600 transition-all">
69
- <i data-lucide="bell" class="h-5 w-5"></i>
70
- <span class="absolute right-1.5 top-1.5 h-2 w-2 rounded-full bg-blue-500"></span>
71
- </button>
 
 
72
  </div>
73
  </header>
74
 
 
2
  @implements IDisposable
3
  @using System.Security.Claims
4
 
5
+ <div class="flex h-screen bg-background font-sans text-slate-800 antialiased overflow-hidden">
6
+ <aside class="flex w-64 flex-col border-r border-slate-900 bg-[#10002B] text-white shrink-0 overflow-hidden">
7
  <div class="flex h-20 items-center px-6">
8
+ <span class="flex h-9 w-9 items-center justify-center rounded-xl bg-white/10 text-white">
9
  <i data-lucide="flame" class="h-5 w-5 fill-current"></i>
10
  </span>
11
+ <span class="ml-3 font-bold text-xl tracking-tight text-white">Taskify</span>
12
  </div>
13
 
14
  <NavMenu @rendermode="new InteractiveServerRenderMode(prerender: false)" />
15
 
16
+ <div class="border-t border-slate-800 p-4 shrink-0">
17
  <AuthorizeView>
18
  <Authorized>
19
+ <div class="flex items-center rounded-xl p-2 hover:bg-white/10 transition-all">
20
  <span class="flex h-10 w-10 items-center justify-center rounded-full bg-violet-600 text-white font-semibold text-sm shrink-0">
21
  @(context.User.Identity?.Name?.Length > 0 ? context.User.Identity.Name[0].ToString().ToUpper() : "U")
22
  </span>
23
  <div class="ml-3 overflow-hidden flex flex-col">
24
+ <span class="text-sm font-semibold truncate max-w-[130px] text-white">@context.User.Identity?.Name</span>
25
  <form id="logout-form" method="post" action="/account/logout" class="mt-0.5">
26
  <AntiforgeryToken />
27
+ <button type="button" onclick="document.getElementById('logout-confirm-dialog').style.display='flex';" class="text-xs hover:opacity-80 font-semibold text-left flex items-center gap-1 transition-all text-[#C77DFF]">
28
  <i data-lucide="log-out" class="h-3 w-3"></i>
29
  Sign Out
30
  </button>
 
65
  <header class="flex h-16 items-center justify-between border-b border-slate-200 bg-white px-8">
66
  <h1 class="text-lg font-semibold text-slate-900">Workspace</h1>
67
  <div class="flex items-center space-x-4">
68
+ <AuthorizeView>
69
+ <Authorized>
70
+ <FirebaseTokenSync />
71
+ </Authorized>
72
+ </AuthorizeView>
73
+ <NotificationBell />
74
  </div>
75
  </header>
76
 
TaskTrackingSystem.WebApp/Components/Layout/MainLayout.razor.css CHANGED
@@ -10,12 +10,12 @@ main {
10
  }
11
 
12
  .sidebar {
13
- background-color: #ffffff;
14
  }
15
 
16
  .top-row {
17
  background-color: #ffffff;
18
- border-bottom: 1px solid #e2e8f0;
19
  justify-content: flex-end;
20
  height: 3.5rem;
21
  display: flex;
@@ -27,14 +27,13 @@ main {
27
  white-space: nowrap;
28
  margin-left: 1.5rem;
29
  text-decoration: none;
30
- color: #e6e094;
31
- /* Soft yellow text */
32
  }
33
 
34
  .top-row ::deep a:hover,
35
  .top-row ::deep .btn-link:hover {
36
  text-decoration: underline;
37
- color: white;
38
  }
39
 
40
  @media (min-width: 641px) {
@@ -51,8 +50,8 @@ main {
51
  }
52
 
53
  #blazor-error-ui {
54
- background: #e6e094;
55
- color: #2d2d2d;
56
  bottom: 0;
57
  position: fixed;
58
  width: 100%;
 
10
  }
11
 
12
  .sidebar {
13
+ background-color: #10002B;
14
  }
15
 
16
  .top-row {
17
  background-color: #ffffff;
18
+ border-bottom: 1px solid #E5E7EB;
19
  justify-content: flex-end;
20
  height: 3.5rem;
21
  display: flex;
 
27
  white-space: nowrap;
28
  margin-left: 1.5rem;
29
  text-decoration: none;
30
+ color: #6B7280;
 
31
  }
32
 
33
  .top-row ::deep a:hover,
34
  .top-row ::deep .btn-link:hover {
35
  text-decoration: underline;
36
+ color: #10002B;
37
  }
38
 
39
  @media (min-width: 641px) {
 
50
  }
51
 
52
  #blazor-error-ui {
53
+ background: #FFE4E6;
54
+ color: #991B1B;
55
  bottom: 0;
56
  position: fixed;
57
  width: 100%;
TaskTrackingSystem.WebApp/Components/Layout/NavMenu.razor.css CHANGED
@@ -6,31 +6,36 @@
6
  padding: 0.625rem 1rem;
7
  font-size: 0.875rem;
8
  font-weight: 500;
9
- color: #4B5563; /* text-slate-600 */
10
  transition: all 0.2s ease;
11
  border-left: 3px solid transparent;
12
  }
13
 
14
  ::deep a.nav-link-item:hover {
15
- background-color: #F9FAFB; /* bg-slate-50 */
16
- color: #111827; /* text-slate-900 */
17
  }
18
 
19
  ::deep a.nav-link-item.active {
20
- background-color: #F5F3FF !important; /* bg-violet-50 */
21
- color: #7C3AED !important; /* text-violet-600 */
22
- border-left: 3px solid #7C3AED !important;
23
  font-weight: 600;
24
  }
25
 
26
  ::deep a.nav-link-item.active i {
27
- color: #7C3AED !important;
28
  }
29
 
30
  ::deep a.nav-link-item i {
 
31
  transition: color 0.2s ease;
32
  }
33
 
 
 
 
 
34
  /* Dropdown toggle button (parent item) */
35
  ::deep button.nav-dropdown-toggle {
36
  display: flex;
@@ -40,7 +45,7 @@
40
  padding: 0.625rem 1rem;
41
  font-size: 0.875rem;
42
  font-weight: 500;
43
- color: #4B5563;
44
  background: none;
45
  border: none;
46
  border-left: 3px solid transparent;
@@ -49,15 +54,24 @@
49
  }
50
 
51
  ::deep button.nav-dropdown-toggle:hover {
52
- background-color: #F9FAFB;
53
- color: #111827;
54
  }
55
 
56
  ::deep button.nav-dropdown-toggle.expanded {
57
- color: #111827;
58
  font-weight: 600;
59
  }
60
 
 
 
 
 
 
 
 
 
 
61
  /* Chevron rotation on expand */
62
  ::deep button.nav-dropdown-toggle .nav-chevron {
63
  transition: transform 0.25s ease;
@@ -71,7 +85,7 @@
71
  ::deep .nav-dropdown-children {
72
  margin-left: 0.75rem;
73
  padding-left: 0.75rem;
74
- border-left: 1.5px solid #E2E8F0; /* slate-200 */
75
  margin-top: 0.125rem;
76
  margin-bottom: 0.25rem;
77
  }
 
6
  padding: 0.625rem 1rem;
7
  font-size: 0.875rem;
8
  font-weight: 500;
9
+ color: #C77DFF; /* Soft muted lavender for inactive states */
10
  transition: all 0.2s ease;
11
  border-left: 3px solid transparent;
12
  }
13
 
14
  ::deep a.nav-link-item:hover {
15
+ background-color: rgba(255, 255, 255, 0.08); /* crisp highlight on hover */
16
+ color: #FFFFFF;
17
  }
18
 
19
  ::deep a.nav-link-item.active {
20
+ background-color: rgba(255, 255, 255, 0.15) !important;
21
+ color: #FFFFFF !important;
22
+ border-left: 3px solid #E0AAFF !important;
23
  font-weight: 600;
24
  }
25
 
26
  ::deep a.nav-link-item.active i {
27
+ color: #FFFFFF !important;
28
  }
29
 
30
  ::deep a.nav-link-item i {
31
+ color: #C77DFF;
32
  transition: color 0.2s ease;
33
  }
34
 
35
+ ::deep a.nav-link-item:hover i {
36
+ color: #FFFFFF;
37
+ }
38
+
39
  /* Dropdown toggle button (parent item) */
40
  ::deep button.nav-dropdown-toggle {
41
  display: flex;
 
45
  padding: 0.625rem 1rem;
46
  font-size: 0.875rem;
47
  font-weight: 500;
48
+ color: #C77DFF;
49
  background: none;
50
  border: none;
51
  border-left: 3px solid transparent;
 
54
  }
55
 
56
  ::deep button.nav-dropdown-toggle:hover {
57
+ background-color: rgba(255, 255, 255, 0.08);
58
+ color: #FFFFFF;
59
  }
60
 
61
  ::deep button.nav-dropdown-toggle.expanded {
62
+ color: #FFFFFF;
63
  font-weight: 600;
64
  }
65
 
66
+ ::deep button.nav-dropdown-toggle:hover i,
67
+ ::deep button.nav-dropdown-toggle.expanded i {
68
+ color: #FFFFFF;
69
+ }
70
+
71
+ ::deep button.nav-dropdown-toggle i {
72
+ color: #C77DFF;
73
+ }
74
+
75
  /* Chevron rotation on expand */
76
  ::deep button.nav-dropdown-toggle .nav-chevron {
77
  transition: transform 0.25s ease;
 
85
  ::deep .nav-dropdown-children {
86
  margin-left: 0.75rem;
87
  padding-left: 0.75rem;
88
+ border-left: 1.5px solid rgba(255, 255, 255, 0.15);
89
  margin-top: 0.125rem;
90
  margin-bottom: 0.25rem;
91
  }
TaskTrackingSystem.WebApp/Components/Pages/Backlog.razor CHANGED
@@ -47,17 +47,17 @@
47
 
48
  <div class="grid grid-cols-2 gap-2">
49
  <select @bind="filterStatusId" class="rounded-lg border border-slate-200 px-2 py-1.5 text-[11px] text-slate-700 bg-white focus:outline-none focus:ring-1 focus:ring-violet-600 transition-all">
50
- <option value="0">All Status</option>
51
- <option value="1">To Do</option>
52
- <option value="2">In Progress</option>
53
- <option value="3">Done</option>
54
  </select>
55
 
56
  <select @bind="filterPriorityId" class="rounded-lg border border-slate-200 px-2 py-1.5 text-[11px] text-slate-700 bg-white focus:outline-none focus:ring-1 focus:ring-violet-600 transition-all">
57
- <option value="0">All Priority</option>
58
- <option value="1">Low</option>
59
- <option value="2">Medium</option>
60
- <option value="3">High</option>
61
  </select>
62
  </div>
63
 
@@ -98,7 +98,7 @@
98
  @GetPriorityLabel(task.PriorityId)
99
  </span>
100
  </div>
101
- <h4 class="text-xs font-bold text-slate-800 line-clamp-2 @(task.StatusId == 3 ? "line-through text-slate-400" : "")">
102
  @task.Title
103
  </h4>
104
  <div class="flex items-center justify-between gap-2 mt-0.5">
@@ -169,8 +169,8 @@
169
  private string searchInput = "";
170
  private string searchQuery => searchInput.Trim().ToLower();
171
  private long filterProjectId = 0;
172
- private long filterStatusId = 0;
173
- private long filterPriorityId = 0;
174
 
175
  protected override async Task OnInitializedAsync()
176
  {
@@ -260,47 +260,47 @@
260
  {
261
  result = result.Where(t => t.ProjectId == filterProjectId);
262
  }
263
- if (filterStatusId > 0)
264
  {
265
- result = result.Where(t => t.StatusId == filterStatusId);
266
  }
267
- if (filterPriorityId > 0)
268
  {
269
- result = result.Where(t => t.PriorityId == filterPriorityId);
270
  }
271
  return result;
272
  }
273
  }
274
 
275
- private string GetStatusLabel(long statusId) => statusId switch
276
  {
277
- 1 => "To Do",
278
- 2 => "In Progress",
279
- 3 => "Done",
280
  _ => "Unknown"
281
  };
282
 
283
- private string GetStatusBadge(long statusId) => statusId switch
284
  {
285
- 1 => "bg-slate-100 text-slate-800 border border-slate-200",
286
- 2 => "bg-blue-50 text-blue-700 border border-blue-100",
287
- 3 => "bg-emerald-50 text-emerald-700 border border-emerald-100",
288
  _ => "bg-slate-50 text-slate-600"
289
  };
290
 
291
- private string GetPriorityLabel(long priorityId) => priorityId switch
292
  {
293
- 1 => "Low",
294
- 2 => "Medium",
295
- 3 => "High",
296
  _ => "Unknown"
297
  };
298
 
299
- private string GetPriorityBadge(long priorityId) => priorityId switch
300
  {
301
- 1 => "bg-slate-100 text-slate-600",
302
- 2 => "bg-amber-50 text-amber-700 border border-amber-100",
303
- 3 => "bg-rose-50 text-rose-700 border border-rose-100",
304
  _ => "bg-slate-50 text-slate-600"
305
  };
306
  }
 
47
 
48
  <div class="grid grid-cols-2 gap-2">
49
  <select @bind="filterStatusId" class="rounded-lg border border-slate-200 px-2 py-1.5 text-[11px] text-slate-700 bg-white focus:outline-none focus:ring-1 focus:ring-violet-600 transition-all">
50
+ <option value="">All Status</option>
51
+ <option value="@AppTaskStatus.Todo">To Do</option>
52
+ <option value="@AppTaskStatus.InProgress">In Progress</option>
53
+ <option value="@AppTaskStatus.Done">Done</option>
54
  </select>
55
 
56
  <select @bind="filterPriorityId" class="rounded-lg border border-slate-200 px-2 py-1.5 text-[11px] text-slate-700 bg-white focus:outline-none focus:ring-1 focus:ring-violet-600 transition-all">
57
+ <option value="">All Priority</option>
58
+ <option value="@TaskPriority.Low">Low</option>
59
+ <option value="@TaskPriority.Medium">Medium</option>
60
+ <option value="@TaskPriority.High">High</option>
61
  </select>
62
  </div>
63
 
 
98
  @GetPriorityLabel(task.PriorityId)
99
  </span>
100
  </div>
101
+ <h4 class="text-xs font-bold text-slate-800 line-clamp-2 @(task.StatusId == AppTaskStatus.Done ? "line-through text-slate-400" : "")">
102
  @task.Title
103
  </h4>
104
  <div class="flex items-center justify-between gap-2 mt-0.5">
 
169
  private string searchInput = "";
170
  private string searchQuery => searchInput.Trim().ToLower();
171
  private long filterProjectId = 0;
172
+ private AppTaskStatus? filterStatusId = null;
173
+ private TaskPriority? filterPriorityId = null;
174
 
175
  protected override async Task OnInitializedAsync()
176
  {
 
260
  {
261
  result = result.Where(t => t.ProjectId == filterProjectId);
262
  }
263
+ if (filterStatusId.HasValue)
264
  {
265
+ result = result.Where(t => t.StatusId == filterStatusId.Value);
266
  }
267
+ if (filterPriorityId.HasValue)
268
  {
269
+ result = result.Where(t => t.PriorityId == filterPriorityId.Value);
270
  }
271
  return result;
272
  }
273
  }
274
 
275
+ private string GetStatusLabel(AppTaskStatus statusId) => statusId switch
276
  {
277
+ AppTaskStatus.Todo => "To Do",
278
+ AppTaskStatus.InProgress => "In Progress",
279
+ AppTaskStatus.Done => "Done",
280
  _ => "Unknown"
281
  };
282
 
283
+ private string GetStatusBadge(AppTaskStatus statusId) => statusId switch
284
  {
285
+ AppTaskStatus.Todo => "bg-slate-100 text-slate-800 border border-slate-200",
286
+ AppTaskStatus.InProgress => "bg-blue-50 text-blue-700 border border-blue-100",
287
+ AppTaskStatus.Done => "bg-emerald-50 text-emerald-700 border border-emerald-100",
288
  _ => "bg-slate-50 text-slate-600"
289
  };
290
 
291
+ private string GetPriorityLabel(TaskPriority priorityId) => priorityId switch
292
  {
293
+ TaskPriority.Low => "Low",
294
+ TaskPriority.Medium => "Medium",
295
+ TaskPriority.High => "High",
296
  _ => "Unknown"
297
  };
298
 
299
+ private string GetPriorityBadge(TaskPriority priorityId) => priorityId switch
300
  {
301
+ TaskPriority.Low => "bg-slate-100 text-slate-600",
302
+ TaskPriority.Medium => "bg-amber-50 text-amber-700 border border-amber-100",
303
+ TaskPriority.High => "bg-rose-50 text-rose-700 border border-rose-100",
304
  _ => "bg-slate-50 text-slate-600"
305
  };
306
  }
TaskTrackingSystem.WebApp/Components/Pages/Home.razor CHANGED
@@ -112,7 +112,7 @@ else
112
  <span class="font-semibold text-slate-700">@progress%</span>
113
  </div>
114
  <div class="h-2 rounded-full bg-slate-100 overflow-hidden">
115
- <div class="h-full rounded-full bg-emerald-500 transition-all" style="width: @(progress)%"></div>
116
  </div>
117
  </div>
118
  <span class="inline-block mt-3 rounded-full px-2 py-0.5 text-[10px] font-semibold @(daysLeft < 0 ? "bg-rose-50 text-rose-600" : daysLeft < 3 ? "bg-amber-50 text-amber-600" : "bg-emerald-50 text-emerald-600")">
@@ -145,7 +145,7 @@ else
145
  <span class="text-slate-500">@pp.CompletedTasksCount / @pp.TotalTasksCount</span>
146
  </div>
147
  <div class="h-2.5 rounded-full bg-slate-100 overflow-hidden">
148
- <div class="h-full rounded-full bg-emerald-500 transition-all" style="width: @pp.CompletionPercentage%"></div>
149
  </div>
150
  </div>
151
  }
@@ -167,7 +167,7 @@ else
167
  {
168
  <div class="flex flex-col items-center space-y-2 flex-1">
169
  <div class="w-full max-w-[24px] rounded-t-lg bg-slate-100 h-32 flex items-end">
170
- <div class="w-full rounded-t-lg bg-emerald-500 transition-all duration-700" style="height: @(chartBar.Height)%"></div>
171
  </div>
172
  <span class="text-[10px] font-medium text-slate-400 uppercase">@chartBar.Day</span>
173
  </div>
@@ -224,9 +224,9 @@ else
224
  @foreach (var task in ChecklistTasks)
225
  {
226
  <div class="flex items-start gap-3 p-2 rounded-lg hover:bg-slate-50 transition-colors">
227
- <input type="checkbox" checked="@(task.StatusId == 3)" @onchange="() => ToggleTaskStatus(task)" disabled="@(!canUpdateTask)" class="h-4 w-4 rounded border-slate-300 text-emerald-600 accent-emerald-600 mt-0.5 disabled:opacity-50 disabled:cursor-not-allowed" />
228
  <div class="min-w-0 flex-1">
229
- <p class="text-xs font-semibold text-slate-800 truncate @(task.StatusId == 3 ? "line-through text-slate-400" : "")">@task.Title</p>
230
  <p class="text-[10px] text-slate-400">Due @DisplayFormats.Date(task.DueDate)</p>
231
  </div>
232
  </div>
@@ -336,25 +336,25 @@ else
336
  await JS.InvokeVoidAsync("initIcons");
337
  }
338
 
339
- private IEnumerable<TaskDto> ActiveTasks => taskList.Where(t => t.StatusId == 2).Take(3);
340
- private IEnumerable<TaskDto> ChecklistTasks => taskList.Where(t => t.StatusId != 3).Take(4);
341
- private int ChecklistCompletedCount => taskList.Count(t => t.StatusId == 3);
342
 
343
  private int TotalTasksCount => taskStatuses.Sum(s => s.TaskCount);
344
- private int CompletedTasksCount => taskStatuses.FirstOrDefault(s => s.StatusId == 3)?.TaskCount ?? 0;
345
  private int TaskCompletionRate => TotalTasksCount > 0 ? (int)((double)CompletedTasksCount / TotalTasksCount * 100) : 0;
346
- private int OverdueTasksCount => taskList.Count(t => t.DueDate.Date < DateTime.Today && t.StatusId != 3);
347
 
348
  private IEnumerable<UserDto> GetTeamMembers()
349
  {
350
  return usersList.Where(u => !u.Username.Equals(currentUserName, StringComparison.OrdinalIgnoreCase)).Take(3);
351
  }
352
 
353
- private int GetTaskProgress(long statusId) => statusId switch
354
  {
355
- 1 => 10,
356
- 2 => 50,
357
- 3 => 100,
358
  _ => 0
359
  };
360
 
@@ -367,8 +367,8 @@ else
367
  for (int i = 0; i < 7; i++)
368
  {
369
  var day = startOfWeek.AddDays(i);
370
- var completedCount = taskList.Count(t => t.StatusId == 3 && t.DueDate.Date == day);
371
- var maxCount = Math.Max(taskList.Count(t => t.StatusId == 3), 1);
372
  var height = (int)Math.Max(10, (double)completedCount / maxCount * 100);
373
  bars.Add(new ChartBar(day.ToString("ddd")[..1], height));
374
  }
@@ -383,14 +383,14 @@ else
383
  return;
384
  }
385
 
386
- var newStatusId = task.StatusId == 3 ? 1 : 3;
387
  var client = ApiClient.CreateClient();
388
  try
389
  {
390
- var response = await client.PatchAsync($"Task/{task.Id}/status?statusId={newStatusId}", null);
391
  if (response.IsSuccessStatusCode)
392
  {
393
- task.StatusId = newStatusId;
394
  await LoadDashboardData();
395
  }
396
  }
 
112
  <span class="font-semibold text-slate-700">@progress%</span>
113
  </div>
114
  <div class="h-2 rounded-full bg-slate-100 overflow-hidden">
115
+ <div class="h-full rounded-full bg-violet-600 transition-all" style="width: @(progress)%"></div>
116
  </div>
117
  </div>
118
  <span class="inline-block mt-3 rounded-full px-2 py-0.5 text-[10px] font-semibold @(daysLeft < 0 ? "bg-rose-50 text-rose-600" : daysLeft < 3 ? "bg-amber-50 text-amber-600" : "bg-emerald-50 text-emerald-600")">
 
145
  <span class="text-slate-500">@pp.CompletedTasksCount / @pp.TotalTasksCount</span>
146
  </div>
147
  <div class="h-2.5 rounded-full bg-slate-100 overflow-hidden">
148
+ <div class="h-full rounded-full bg-violet-600 transition-all" style="width: @pp.CompletionPercentage%"></div>
149
  </div>
150
  </div>
151
  }
 
167
  {
168
  <div class="flex flex-col items-center space-y-2 flex-1">
169
  <div class="w-full max-w-[24px] rounded-t-lg bg-slate-100 h-32 flex items-end">
170
+ <div class="w-full rounded-t-lg bg-violet-600 transition-all duration-700" style="height: @(chartBar.Height)%"></div>
171
  </div>
172
  <span class="text-[10px] font-medium text-slate-400 uppercase">@chartBar.Day</span>
173
  </div>
 
224
  @foreach (var task in ChecklistTasks)
225
  {
226
  <div class="flex items-start gap-3 p-2 rounded-lg hover:bg-slate-50 transition-colors">
227
+ <input type="checkbox" checked="@(task.StatusId == AppTaskStatus.Done)" @onchange="() => ToggleTaskStatus(task)" disabled="@(!canUpdateTask)" class="h-4 w-4 rounded border-slate-300 text-emerald-600 accent-emerald-600 mt-0.5 disabled:opacity-50 disabled:cursor-not-allowed" />
228
  <div class="min-w-0 flex-1">
229
+ <p class="text-xs font-semibold text-slate-800 truncate @(task.StatusId == AppTaskStatus.Done ? "line-through text-slate-400" : "")">@task.Title</p>
230
  <p class="text-[10px] text-slate-400">Due @DisplayFormats.Date(task.DueDate)</p>
231
  </div>
232
  </div>
 
336
  await JS.InvokeVoidAsync("initIcons");
337
  }
338
 
339
+ private IEnumerable<TaskDto> ActiveTasks => taskList.Where(t => t.StatusId == AppTaskStatus.InProgress).Take(3);
340
+ private IEnumerable<TaskDto> ChecklistTasks => taskList.Where(t => t.StatusId != AppTaskStatus.Done).Take(4);
341
+ private int ChecklistCompletedCount => taskList.Count(t => t.StatusId == AppTaskStatus.Done);
342
 
343
  private int TotalTasksCount => taskStatuses.Sum(s => s.TaskCount);
344
+ private int CompletedTasksCount => taskStatuses.FirstOrDefault(s => s.StatusId == (int)AppTaskStatus.Done)?.TaskCount ?? 0;
345
  private int TaskCompletionRate => TotalTasksCount > 0 ? (int)((double)CompletedTasksCount / TotalTasksCount * 100) : 0;
346
+ private int OverdueTasksCount => taskList.Count(t => t.DueDate.Date < DateTime.Today && t.StatusId != AppTaskStatus.Done);
347
 
348
  private IEnumerable<UserDto> GetTeamMembers()
349
  {
350
  return usersList.Where(u => !u.Username.Equals(currentUserName, StringComparison.OrdinalIgnoreCase)).Take(3);
351
  }
352
 
353
+ private int GetTaskProgress(AppTaskStatus statusId) => statusId switch
354
  {
355
+ AppTaskStatus.Todo => 10,
356
+ AppTaskStatus.InProgress => 50,
357
+ AppTaskStatus.Done => 100,
358
  _ => 0
359
  };
360
 
 
367
  for (int i = 0; i < 7; i++)
368
  {
369
  var day = startOfWeek.AddDays(i);
370
+ var completedCount = taskList.Count(t => t.StatusId == AppTaskStatus.Done && t.DueDate.Date == day);
371
+ var maxCount = Math.Max(taskList.Count(t => t.StatusId == AppTaskStatus.Done), 1);
372
  var height = (int)Math.Max(10, (double)completedCount / maxCount * 100);
373
  bars.Add(new ChartBar(day.ToString("ddd")[..1], height));
374
  }
 
383
  return;
384
  }
385
 
386
+ var newStatus = task.StatusId == AppTaskStatus.Done ? AppTaskStatus.Todo : AppTaskStatus.Done;
387
  var client = ApiClient.CreateClient();
388
  try
389
  {
390
+ var response = await client.PatchAsync($"Task/{task.Id}/status?statusId={(int)newStatus}", null);
391
  if (response.IsSuccessStatusCode)
392
  {
393
+ task.StatusId = newStatus;
394
  await LoadDashboardData();
395
  }
396
  }
TaskTrackingSystem.WebApp/Components/Pages/KanbanBoard.razor CHANGED
@@ -24,7 +24,7 @@
24
  }
25
  <div>
26
  <h2 class="text-3xl font-bold tracking-tight text-slate-900">Kanban Board</h2>
27
- <p class="text-slate-500 mt-1">@(ProjectId > 0 ? $"Project #{ProjectId} " : "Global View ")Drag tasks between columns.</p>
28
  </div>
29
  </div>
30
  @if (canCreateTask)
@@ -125,9 +125,9 @@
125
  <div>
126
  <label class="block text-sm font-medium text-slate-700 mb-1.5">Priority</label>
127
  <select @bind="taskPriorityId" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent transition-all">
128
- <option value="1">Low</option>
129
- <option value="2">Medium</option>
130
- <option value="3">High</option>
131
  </select>
132
  </div>
133
  <div>
@@ -194,14 +194,14 @@
194
  // Task form
195
  private string taskTitle = "";
196
  private string? taskDescription;
197
- private long taskPriorityId = 2;
198
  private DateTime taskDueDate = DateTime.Today.AddDays(7);
199
 
200
  private List<KanbanColumn> columns = new()
201
  {
202
- new(1, "To Do", "bg-slate-400"),
203
- new(2, "In Progress", "bg-blue-500"),
204
- new(3, "Done", "bg-emerald-500")
205
  };
206
 
207
  private TaskDto? draggedTask;
@@ -216,7 +216,7 @@
216
  // Allow dropping
217
  }
218
 
219
- private async Task HandleDrop(long newStatusId)
220
  {
221
  if (draggedTask != null && draggedTask.StatusId != newStatusId)
222
  {
@@ -282,7 +282,7 @@
282
  }
283
  }
284
 
285
- private async Task MoveTask(long taskId, long newStatusId)
286
  {
287
  if (!canUpdateTask)
288
  {
@@ -312,7 +312,7 @@
312
  {
313
  taskTitle = "";
314
  taskDescription = null;
315
- taskPriorityId = 2;
316
  taskDueDate = DateTime.Today.AddDays(7);
317
  taskError = null;
318
  showTaskModal = true;
@@ -374,7 +374,7 @@
374
  Title = taskTitle,
375
  Description = taskDescription,
376
  ProjectId = ProjectId > 0 ? ProjectId : 1, // Default fallback if global board
377
- StatusId = 1,
378
  PriorityId = taskPriorityId,
379
  DueDate = taskDueDate
380
  };
@@ -399,21 +399,21 @@
399
  }
400
  }
401
 
402
- private string GetPriorityBadge(long priorityId) => priorityId switch
403
  {
404
- 1 => "bg-slate-100 text-slate-600",
405
- 2 => "bg-amber-100 text-amber-700",
406
- 3 => "bg-red-100 text-red-700",
407
  _ => "bg-slate-100 text-slate-600"
408
  };
409
 
410
- private string GetPriorityLabel(long priorityId) => priorityId switch
411
  {
412
- 1 => "Low",
413
- 2 => "Medium",
414
- 3 => "High",
415
- _ => ""
416
  };
417
 
418
- private record KanbanColumn(long StatusId, string Name, string DotColor);
419
  }
 
24
  }
25
  <div>
26
  <h2 class="text-3xl font-bold tracking-tight text-slate-900">Kanban Board</h2>
27
+ <p class="text-slate-500 mt-1">@(ProjectId > 0 ? $"Project #{ProjectId} — " : "Global View — ")Drag tasks between columns.</p>
28
  </div>
29
  </div>
30
  @if (canCreateTask)
 
125
  <div>
126
  <label class="block text-sm font-medium text-slate-700 mb-1.5">Priority</label>
127
  <select @bind="taskPriorityId" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent transition-all">
128
+ <option value="@TaskPriority.Low">Low</option>
129
+ <option value="@TaskPriority.Medium">Medium</option>
130
+ <option value="@TaskPriority.High">High</option>
131
  </select>
132
  </div>
133
  <div>
 
194
  // Task form
195
  private string taskTitle = "";
196
  private string? taskDescription;
197
+ private TaskPriority taskPriorityId = TaskPriority.Medium;
198
  private DateTime taskDueDate = DateTime.Today.AddDays(7);
199
 
200
  private List<KanbanColumn> columns = new()
201
  {
202
+ new(AppTaskStatus.Todo, "To Do", "bg-slate-400"),
203
+ new(AppTaskStatus.InProgress, "In Progress", "bg-blue-500"),
204
+ new(AppTaskStatus.Done, "Done", "bg-emerald-500")
205
  };
206
 
207
  private TaskDto? draggedTask;
 
216
  // Allow dropping
217
  }
218
 
219
+ private async Task HandleDrop(AppTaskStatus newStatusId)
220
  {
221
  if (draggedTask != null && draggedTask.StatusId != newStatusId)
222
  {
 
282
  }
283
  }
284
 
285
+ private async Task MoveTask(long taskId, AppTaskStatus newStatusId)
286
  {
287
  if (!canUpdateTask)
288
  {
 
312
  {
313
  taskTitle = "";
314
  taskDescription = null;
315
+ taskPriorityId = TaskPriority.Medium;
316
  taskDueDate = DateTime.Today.AddDays(7);
317
  taskError = null;
318
  showTaskModal = true;
 
374
  Title = taskTitle,
375
  Description = taskDescription,
376
  ProjectId = ProjectId > 0 ? ProjectId : 1, // Default fallback if global board
377
+ StatusId = AppTaskStatus.Todo,
378
  PriorityId = taskPriorityId,
379
  DueDate = taskDueDate
380
  };
 
399
  }
400
  }
401
 
402
+ private string GetPriorityBadge(TaskPriority priorityId) => priorityId switch
403
  {
404
+ TaskPriority.Low => "bg-slate-100 text-slate-600",
405
+ TaskPriority.Medium => "bg-amber-100 text-amber-700",
406
+ TaskPriority.High => "bg-red-100 text-red-700",
407
  _ => "bg-slate-100 text-slate-600"
408
  };
409
 
410
+ private string GetPriorityLabel(TaskPriority priorityId) => priorityId switch
411
  {
412
+ TaskPriority.Low => "Low",
413
+ TaskPriority.Medium => "Medium",
414
+ TaskPriority.High => "High",
415
+ _ => "—"
416
  };
417
 
418
+ private record KanbanColumn(AppTaskStatus StatusId, string Name, string DotColor);
419
  }
TaskTrackingSystem.WebApp/Components/Pages/OverdueTasksReport.razor CHANGED
@@ -166,7 +166,7 @@
166
  <td class="px-6 py-4">
167
  <span class="inline-flex items-center gap-1 text-sm text-slate-600">
168
  <i data-lucide="folder" class="h-3.5 w-3.5 text-violet-500"></i>
169
- @(project?.Name ?? "")
170
  </span>
171
  </td>
172
  <td class="px-6 py-4">
@@ -207,25 +207,25 @@
207
  }
208
  </td>
209
  <td class="px-6 py-4">
210
- @{
211
- var (priorityLabel, priorityClass) = task.PriorityId switch {
212
- 3 => ("High", "bg-rose-50 text-rose-700"),
213
- 2 => ("Medium", "bg-amber-50 text-amber-700"),
214
- _ => ("Low", "bg-slate-100 text-slate-600")
215
- };
216
- }
217
- <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium @priorityClass">@priorityLabel</span>
218
- </td>
219
- <td class="px-6 py-4">
220
- @{
221
- var (statusLabel, statusClass) = task.StatusId switch {
222
- 2 => ("In Progress", "bg-blue-50 text-blue-700"),
223
- 3 => ("Done", "bg-emerald-50 text-emerald-700"),
224
- _ => ("To Do", "bg-slate-100 text-slate-600")
225
- };
226
- }
227
- <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium @statusClass">@statusLabel</span>
228
- </td>
229
  <td class="px-6 py-4 text-right space-x-3 whitespace-nowrap">
230
  <button @onclick="() => RemindAssignee(task)" class="text-xs font-semibold text-violet-600 hover:text-violet-900 transition-colors font-sans">
231
  Remind
@@ -326,7 +326,7 @@
326
  var result = allTasks.AsEnumerable();
327
 
328
  // Only show non-completed tasks that are overdue or due soon
329
- result = result.Where(t => t.StatusId != 3); // exclude Done
330
 
331
  if (appliedDelayType == "overdue")
332
  result = result.Where(t => t.DueDate < today);
@@ -349,7 +349,7 @@
349
  }
350
 
351
  if (appliedPriority > 0)
352
- result = result.Where(t => t.PriorityId == appliedPriority);
353
 
354
  return result.OrderBy(t => t.Title).ThenBy(t => t.DueDate);
355
  }
@@ -388,10 +388,10 @@
388
  private void ComputeSummary()
389
  {
390
  var today = DateTime.Today;
391
- var activeTasks = allTasks.Where(t => t.StatusId != 3).ToList();
392
  var overdueTasks = activeTasks.Where(t => t.DueDate < today).ToList();
393
  overdueCount = overdueTasks.Count;
394
- criticalCount = overdueTasks.Count(t => t.PriorityId == 3);
395
  dueSoonCount = activeTasks.Count(t => t.DueDate >= today && t.DueDate <= today.AddDays(3));
396
  avgDaysOverdue = overdueTasks.Any()
397
  ? (int)overdueTasks.Average(t => (today - t.DueDate).Days)
@@ -427,8 +427,8 @@
427
  var daysOver = (DateTime.Today - task.DueDate).Days;
428
  var assignee = allUsers.FirstOrDefault(u => u.Id == task.AssignedTo);
429
  var project = allProjects.FirstOrDefault(p => p.Id == task.ProjectId);
430
- var priority = task.PriorityId switch { 3 => "High", 2 => "Medium", _ => "Low" };
431
- var status = task.StatusId switch { 2 => "In Progress", 3 => "Done", _ => "To Do" };
432
 
433
  return new object?[]
434
  {
@@ -477,8 +477,8 @@
477
  var daysOver = (DateTime.Today - task.DueDate).Days;
478
  var assignee = allUsers.FirstOrDefault(u => u.Id == task.AssignedTo);
479
  var project = allProjects.FirstOrDefault(p => p.Id == task.ProjectId);
480
- var priority = task.PriorityId switch { 3 => "High", 2 => "Medium", _ => "Low" };
481
- var status = task.StatusId switch { 2 => "In Progress", 3 => "Done", _ => "To Do" };
482
  return new[]
483
  {
484
  task.Title,
 
166
  <td class="px-6 py-4">
167
  <span class="inline-flex items-center gap-1 text-sm text-slate-600">
168
  <i data-lucide="folder" class="h-3.5 w-3.5 text-violet-500"></i>
169
+ @(project?.Name ?? "—")
170
  </span>
171
  </td>
172
  <td class="px-6 py-4">
 
207
  }
208
  </td>
209
  <td class="px-6 py-4">
210
+ @{
211
+ var (priorityLabel, priorityClass) = task.PriorityId switch {
212
+ TaskPriority.High => ("High", "bg-rose-50 text-rose-700"),
213
+ TaskPriority.Medium => ("Medium", "bg-amber-50 text-amber-700"),
214
+ _ => ("Low", "bg-slate-100 text-slate-600")
215
+ };
216
+ }
217
+ <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium @priorityClass">@priorityLabel</span>
218
+ </td>
219
+ <td class="px-6 py-4">
220
+ @{
221
+ var (statusLabel, statusClass) = task.StatusId switch {
222
+ AppTaskStatus.InProgress => ("In Progress", "bg-blue-50 text-blue-700"),
223
+ AppTaskStatus.Done => ("Done", "bg-emerald-50 text-emerald-700"),
224
+ _ => ("To Do", "bg-slate-100 text-slate-600")
225
+ };
226
+ }
227
+ <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium @statusClass">@statusLabel</span>
228
+ </td>
229
  <td class="px-6 py-4 text-right space-x-3 whitespace-nowrap">
230
  <button @onclick="() => RemindAssignee(task)" class="text-xs font-semibold text-violet-600 hover:text-violet-900 transition-colors font-sans">
231
  Remind
 
326
  var result = allTasks.AsEnumerable();
327
 
328
  // Only show non-completed tasks that are overdue or due soon
329
+ result = result.Where(t => t.StatusId != AppTaskStatus.Done); // exclude Done
330
 
331
  if (appliedDelayType == "overdue")
332
  result = result.Where(t => t.DueDate < today);
 
349
  }
350
 
351
  if (appliedPriority > 0)
352
+ result = result.Where(t => (int)t.PriorityId == appliedPriority);
353
 
354
  return result.OrderBy(t => t.Title).ThenBy(t => t.DueDate);
355
  }
 
388
  private void ComputeSummary()
389
  {
390
  var today = DateTime.Today;
391
+ var activeTasks = allTasks.Where(t => t.StatusId != AppTaskStatus.Done).ToList();
392
  var overdueTasks = activeTasks.Where(t => t.DueDate < today).ToList();
393
  overdueCount = overdueTasks.Count;
394
+ criticalCount = overdueTasks.Count(t => t.PriorityId == TaskPriority.High);
395
  dueSoonCount = activeTasks.Count(t => t.DueDate >= today && t.DueDate <= today.AddDays(3));
396
  avgDaysOverdue = overdueTasks.Any()
397
  ? (int)overdueTasks.Average(t => (today - t.DueDate).Days)
 
427
  var daysOver = (DateTime.Today - task.DueDate).Days;
428
  var assignee = allUsers.FirstOrDefault(u => u.Id == task.AssignedTo);
429
  var project = allProjects.FirstOrDefault(p => p.Id == task.ProjectId);
430
+ var priority = task.PriorityId switch { TaskPriority.High => "High", TaskPriority.Medium => "Medium", _ => "Low" };
431
+ var status = task.StatusId switch { AppTaskStatus.InProgress => "In Progress", AppTaskStatus.Done => "Done", _ => "To Do" };
432
 
433
  return new object?[]
434
  {
 
477
  var daysOver = (DateTime.Today - task.DueDate).Days;
478
  var assignee = allUsers.FirstOrDefault(u => u.Id == task.AssignedTo);
479
  var project = allProjects.FirstOrDefault(p => p.Id == task.ProjectId);
480
+ var priority = task.PriorityId switch { TaskPriority.High => "High", TaskPriority.Medium => "Medium", _ => "Low" };
481
+ var status = task.StatusId switch { AppTaskStatus.InProgress => "In Progress", AppTaskStatus.Done => "Done", _ => "To Do" };
482
  return new[]
483
  {
484
  task.Title,
TaskTrackingSystem.WebApp/Components/Pages/Register.razor CHANGED
@@ -64,7 +64,7 @@
64
 
65
  @if (!string.IsNullOrEmpty(Error))
66
  {
67
- <div class="p-3 rounded-lg border" style="background-color: var(--status-warning-bg); border-color: rgba(255, 191, 89, 0.2);">
68
  <p class="text-sm text-center" style="color: var(--text-primary); font-weight: 500;">@Error</p>
69
  </div>
70
  }
 
64
 
65
  @if (!string.IsNullOrEmpty(Error))
66
  {
67
+ <div class="p-3 rounded-lg border" style="background-color: var(--status-warning-bg); border-color: rgba(153, 27, 27, 0.2);">
68
  <p class="text-sm text-center" style="color: var(--text-primary); font-weight: 500;">@Error</p>
69
  </div>
70
  }
TaskTrackingSystem.WebApp/Components/Pages/Roles.razor CHANGED
@@ -190,7 +190,7 @@
190
  {
191
  <p class="text-xs text-slate-400 flex items-center gap-1">
192
  <i data-lucide="info" class="h-3.5 w-3.5 shrink-0"></i>
193
- Pick access on the right it will be assigned when you create.
194
  </p>
195
  }
196
  </div>
@@ -199,26 +199,32 @@
199
  @if (!isEditing)
200
  {
201
  <div class="w-7/12 flex flex-col min-h-0 border-l border-slate-100">
202
- <!-- Permission panel header -->
203
- <div class="px-5 py-3 border-b border-slate-100 bg-slate-50/60 shrink-0 flex items-center justify-between">
204
- <div class="flex items-center gap-2">
205
  <i data-lucide="shield-check" class="h-4 w-4 text-violet-500"></i>
206
  <span class="text-sm font-semibold text-slate-700">Assign Access</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
  </div>
208
- @if (formAccessItems.Any())
209
- {
210
- <label class="flex items-center gap-1.5 cursor-pointer group">
211
- <input type="checkbox"
212
- checked="@IsAllFormAccessSelected"
213
- @onchange="ToggleSelectAllFormAccess"
214
- class="h-3.5 w-3.5 rounded accent-violet-600" />
215
- <span class="text-xs text-slate-500 group-hover:text-violet-600 transition-colors">Select All</span>
216
- </label>
217
- }
218
  </div>
219
 
220
- <!-- Permission items -->
221
- <div class="overflow-y-auto flex-1 px-5 py-4 space-y-4">
222
  @if (isLoadingFormAccessItems)
223
  {
224
  <div class="flex items-center justify-center py-10">
@@ -234,84 +240,114 @@
234
  }
235
  else
236
  {
237
- @foreach (var parent in formAccessItems.Where(m => string.IsNullOrEmpty(m.ParentCode) || m.ParentCode == "0").OrderBy(m => m.OrderNo))
238
- {
239
- var children = formAccessItems.Where(c => c.ParentCode == parent.MenuCode).OrderBy(c => c.OrderNo).ToList();
240
- var parentChecked = formSelectedAccessCodes.Contains(parent.MenuCode);
241
-
242
- <div class="mb-3 rounded-xl border border-slate-100 overflow-hidden shadow-sm">
243
- <!-- Parent Row -->
244
- <label class="flex items-center gap-3 px-4 py-2.5 bg-slate-50/80 cursor-pointer hover:bg-slate-100 transition-colors">
245
- <input type="checkbox" checked="@parentChecked"
246
- @onchange="(e) => ToggleAccessSelection(parent, (bool)(e.Value ?? false), isForm: true)"
247
- class="h-4 w-4 rounded border-slate-300 accent-violet-600" />
248
- <div class="flex items-center gap-2 flex-1">
249
- @if (!string.IsNullOrEmpty(parent.Icon))
250
- {
251
- <i data-lucide="@parent.Icon" class="h-4 w-4 text-violet-500 shrink-0"></i>
252
- }
253
- <span class="text-sm font-semibold text-slate-800">@parent.MenuName</span>
254
- </div>
255
- </label>
256
 
257
- @if (children.Any())
 
 
 
258
  {
259
- <!-- Submenus inside Card -->
260
- <div class="divide-y divide-slate-100 bg-white">
261
- @foreach (var child in children)
262
- {
263
- var childChecked = formSelectedAccessCodes.Contains(child.MenuCode);
264
- <div class="pl-8 pr-4 py-2.5">
265
- <label class="flex items-center gap-3 cursor-pointer hover:text-violet-600 transition-colors mb-1.5">
266
- <input type="checkbox" checked="@childChecked"
267
- @onchange="(e) => ToggleAccessSelection(child, (bool)(e.Value ?? false), isForm: true)"
268
- class="h-4 w-4 rounded border-slate-300 accent-violet-600" />
269
- <span class="text-sm font-medium text-slate-700">@child.MenuName</span>
270
- </label>
271
-
272
- @if (child.Permissions.Any())
273
  {
274
- <div class="flex flex-wrap gap-2 pl-7 mt-1">
275
- @foreach (var action in child.Permissions)
276
- {
277
- var isActionChecked = formSelectedAccessCodes.Contains(action.PermissionCode);
278
- <label class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md border cursor-pointer select-none transition-all
279
- @(isActionChecked
280
- ? "bg-violet-50 border-violet-200 text-violet-700"
281
- : "bg-white border-slate-200 text-slate-500 hover:border-violet-200 hover:text-violet-500")">
282
- <input type="checkbox" checked="@isActionChecked"
283
- @onchange="(e) => TogglePermissionSelection(action.PermissionCode, (bool)(e.Value ?? false), isForm: true)"
284
- class="h-3 w-3 rounded accent-violet-600" />
285
- <span class="text-xs">@action.ActionName</span>
286
- </label>
287
- }
288
- </div>
289
  }
 
 
 
 
 
 
 
290
  </div>
291
- }
292
- </div>
293
- }
294
- else if (parent.Permissions.Any())
295
- {
296
- <!-- Direct Access under Parent -->
297
- <div class="px-5 py-2.5 bg-white flex flex-wrap gap-2">
298
- @foreach (var action in parent.Permissions)
299
  {
300
- var isActionChecked = formSelectedAccessCodes.Contains(action.PermissionCode);
301
- <label class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border cursor-pointer select-none transition-all
302
- @(isActionChecked
303
- ? "bg-violet-50 border-violet-200 text-violet-700 shadow-sm"
304
- : "bg-white border-slate-200 text-slate-500 hover:border-violet-200 hover:text-violet-500")">
305
- <input type="checkbox" checked="@isActionChecked"
306
- @onchange="(e) => TogglePermissionSelection(action.PermissionCode, (bool)(e.Value ?? false), isForm: true)"
307
- class="h-3.5 w-3.5 rounded accent-violet-600" />
308
- <span class="text-xs font-medium">@action.ActionName</span>
309
- </label>
 
 
 
 
 
 
 
 
 
 
 
310
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
311
  </div>
312
  }
313
  </div>
314
  }
 
 
 
 
315
  }
316
  </div>
317
  </div>
@@ -323,8 +359,8 @@
323
  <span class="text-xs text-slate-400">
324
  @if (!isEditing && formSelectedAccessCodes.Count > 0)
325
  {
326
- <span class="text-violet-600 font-medium">@formSelectedAccessCodes.Count access item(s)</span>
327
- <span> will be assigned</span>
328
  }
329
  </span>
330
  <div class="flex items-center gap-3">
@@ -350,7 +386,7 @@
350
  @if (showAccessModal)
351
  {
352
  <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
353
- <div class="bg-white rounded-2xl shadow-2xl w-full max-w-2xl mx-4 overflow-hidden flex flex-col" style="max-height: 85vh;">
354
 
355
  <!-- Header -->
356
  <div class="flex items-center justify-between px-6 py-4 border-b border-slate-100 bg-gradient-to-r from-violet-50 to-indigo-50">
@@ -369,17 +405,27 @@
369
  </div>
370
 
371
  <!-- Select All Bar -->
372
- <div class="px-6 py-3 border-b border-slate-100 bg-slate-50/60 flex items-center justify-between">
373
- <label class="flex items-center gap-2 cursor-pointer">
374
- <input type="checkbox" checked="@IsAllAccessSelected" @onchange="ToggleSelectAllAccess"
375
- class="h-4 w-4 rounded border-slate-300 accent-violet-600" />
376
- <span class="text-sm font-medium text-slate-700">Select All Access</span>
377
- </label>
378
- <span class="text-xs text-slate-400">@selectedAccessCodes.Count access item(s) selected</span>
 
 
 
 
 
 
 
 
 
 
379
  </div>
380
 
381
  <!-- Menu Items Body -->
382
- <div class="overflow-y-auto flex-1 px-6 py-4">
383
  @if (isLoadingAccessItems)
384
  {
385
  <div class="flex items-center justify-center py-12">
@@ -398,84 +444,114 @@
398
  }
399
  else
400
  {
401
- @foreach (var parent in accessItems.Where(m => string.IsNullOrEmpty(m.ParentCode) || m.ParentCode == "0").OrderBy(m => m.OrderNo))
402
- {
403
- var children = accessItems.Where(c => c.ParentCode == parent.MenuCode).OrderBy(c => c.OrderNo).ToList();
404
- var parentChecked = selectedAccessCodes.Contains(parent.MenuCode);
405
-
406
- <div class="mb-4 rounded-xl border border-slate-100 overflow-hidden shadow-sm">
407
- <!-- Parent Row -->
408
- <label class="flex items-center gap-3 px-4 py-3 bg-slate-50/80 cursor-pointer hover:bg-slate-100 transition-colors">
409
- <input type="checkbox" checked="@parentChecked"
410
- @onchange="(e) => ToggleAccessSelection(parent, (bool)(e.Value ?? false), isForm: false)"
411
- class="h-4 w-4 rounded border-slate-300 accent-violet-600" />
412
- <div class="flex items-center gap-2 flex-1">
413
- @if (!string.IsNullOrEmpty(parent.Icon))
414
- {
415
- <i data-lucide="@parent.Icon" class="h-4 w-4 text-violet-500 shrink-0"></i>
416
- }
417
- <span class="text-sm font-semibold text-slate-800">@parent.MenuName</span>
418
- </div>
419
- </label>
420
 
421
- @if (children.Any())
 
 
 
422
  {
423
- <!-- Submenus inside Card -->
424
- <div class="divide-y divide-slate-100 bg-white">
425
- @foreach (var child in children)
426
- {
427
- var childChecked = selectedAccessCodes.Contains(child.MenuCode);
428
- <div class="pl-8 pr-4 py-3">
429
- <label class="flex items-center gap-3 cursor-pointer hover:text-violet-600 transition-colors mb-2">
430
- <input type="checkbox" checked="@childChecked"
431
- @onchange="(e) => ToggleAccessSelection(child, (bool)(e.Value ?? false), isForm: false)"
432
- class="h-4 w-4 rounded border-slate-300 accent-violet-600" />
433
- <span class="text-sm font-medium text-slate-700">@child.MenuName</span>
434
- </label>
435
-
436
- @if (child.Permissions.Any())
437
  {
438
- <div class="flex flex-wrap gap-2 pl-7 mt-1.5">
439
- @foreach (var action in child.Permissions)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
440
  {
441
  var isActionChecked = selectedAccessCodes.Contains(action.PermissionCode);
442
- <label class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md border cursor-pointer select-none transition-all
443
  @(isActionChecked
444
  ? "bg-violet-50 border-violet-200 text-violet-700"
445
  : "bg-white border-slate-200 text-slate-500 hover:border-violet-200 hover:text-violet-500")">
446
  <input type="checkbox" checked="@isActionChecked"
447
  @onchange="(e) => TogglePermissionSelection(action.PermissionCode, (bool)(e.Value ?? false), isForm: false)"
448
- class="h-3 w-3 rounded accent-violet-600" />
449
- <span class="text-xs">@action.ActionName</span>
450
  </label>
451
  }
452
  </div>
453
- }
454
- </div>
455
- }
456
- </div>
457
- }
458
- else if (parent.Permissions.Any())
459
- {
460
- <!-- Direct Access under Parent -->
461
- <div class="px-6 py-3.5 bg-white flex flex-wrap gap-2.5">
462
- @foreach (var action in parent.Permissions)
463
- {
464
- var isActionChecked = selectedAccessCodes.Contains(action.PermissionCode);
465
- <label class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border cursor-pointer select-none transition-all
466
- @(isActionChecked
467
- ? "bg-violet-50 border-violet-300 text-violet-700 shadow-sm"
468
- : "bg-white border-slate-200 text-slate-500 hover:border-violet-200 hover:text-violet-500")">
469
- <input type="checkbox" checked="@isActionChecked"
470
- @onchange="(e) => TogglePermissionSelection(action.PermissionCode, (bool)(e.Value ?? false), isForm: false)"
471
- class="h-3.5 w-3.5 rounded accent-violet-600" />
472
- <span class="text-xs font-medium">@action.ActionName</span>
473
- </label>
474
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
475
  </div>
476
  }
477
  </div>
478
  }
 
 
 
 
479
  }
480
  </div>
481
 
@@ -599,6 +675,7 @@
599
  // Inline access items in Create form
600
  private List<AccessMenuDto> formAccessItems = new();
601
  private HashSet<string> formSelectedAccessCodes = new();
 
602
  private bool isLoadingFormAccessItems = false;
603
 
604
  private bool IsAllFormAccessSelected
@@ -609,6 +686,7 @@
609
  private string selectedRoleName = "";
610
  private List<AccessMenuDto> accessItems = new();
611
  private HashSet<string> selectedAccessCodes = new();
 
612
  private bool isLoadingAccessItems = false;
613
  private bool isSavingAccess = false;
614
  private string? accessSaveError;
@@ -675,6 +753,7 @@
675
  roleNameErrorMsg = "";
676
  formSelectedAccessCodes = new(StringComparer.OrdinalIgnoreCase);
677
  formAccessItems = new();
 
678
  isLoadingFormAccessItems = true;
679
  showRoleModal = true;
680
  StateHasChanged();
@@ -715,6 +794,7 @@
715
  roleNameErrorMsg = "";
716
  formAccessItems = new();
717
  formSelectedAccessCodes = new(StringComparer.OrdinalIgnoreCase);
 
718
  }
719
 
720
  private void ToggleAccessSelection(AccessMenuDto menu, bool isChecked, bool isForm)
@@ -724,41 +804,11 @@
724
 
725
  if (isChecked)
726
  {
727
- targetSet.Add(menu.MenuCode);
728
- // Select direct actions
729
- foreach (var action in menu.Permissions)
730
- {
731
- targetSet.Add(action.PermissionCode);
732
- }
733
- // Select child menus and their actions
734
- var children = allMenuSource.Where(c => c.ParentCode == menu.MenuCode).ToList();
735
- foreach (var child in children)
736
- {
737
- targetSet.Add(child.MenuCode);
738
- foreach (var action in child.Permissions)
739
- {
740
- targetSet.Add(action.PermissionCode);
741
- }
742
- }
743
  }
744
  else
745
  {
746
- targetSet.Remove(menu.MenuCode);
747
- // Unselect direct actions
748
- foreach (var action in menu.Permissions)
749
- {
750
- targetSet.Remove(action.PermissionCode);
751
- }
752
- // Unselect child menus and their actions
753
- var children = allMenuSource.Where(c => c.ParentCode == menu.MenuCode).ToList();
754
- foreach (var child in children)
755
- {
756
- targetSet.Remove(child.MenuCode);
757
- foreach (var action in child.Permissions)
758
- {
759
- targetSet.Remove(action.PermissionCode);
760
- }
761
- }
762
  }
763
  StateHasChanged();
764
  }
@@ -766,9 +816,16 @@
766
  private void TogglePermissionSelection(string actionCode, bool isChecked, bool isForm)
767
  {
768
  var targetSet = isForm ? formSelectedAccessCodes : selectedAccessCodes;
 
769
  if (isChecked)
770
  {
771
  targetSet.Add(actionCode);
 
 
 
 
 
 
772
  }
773
  else
774
  {
@@ -798,6 +855,207 @@
798
  }
799
  }
800
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
801
  private void RequestSaveConfirmation()
802
  {
803
  isRoleNameError = false;
@@ -885,6 +1143,7 @@
885
  }
886
  else
887
  {
 
888
  var dto = new CreateRoleDto
889
  {
890
  Name = formName,
@@ -933,6 +1192,7 @@
933
  selectedRoleName = role.Name;
934
  accessSaveError = null;
935
  accessSaveSuccess = null;
 
936
  isLoadingAccessItems = true;
937
  showAccessModal = true;
938
  StateHasChanged();
@@ -946,6 +1206,7 @@
946
  // Load currently assigned access items for this role
947
  var assignedResult = await client.GetFromJsonAsync<Result<List<string>>>($"Role/{role.Id}/access", Serialization.CaseInsensitive);
948
  selectedAccessCodes = new HashSet<string>(assignedResult?.Value ?? new(), StringComparer.OrdinalIgnoreCase);
 
949
  }
950
  catch (Exception ex)
951
  {
@@ -964,6 +1225,7 @@
964
  showAccessModal = false;
965
  accessItems = new();
966
  selectedAccessCodes = new(StringComparer.OrdinalIgnoreCase);
 
967
  accessSaveError = null;
968
  accessSaveSuccess = null;
969
  }
@@ -997,6 +1259,7 @@
997
  var client = ApiClient.CreateClient();
998
  try
999
  {
 
1000
  var dto = new AssignAccessDto { AccessCodes = selectedAccessCodes.ToList() };
1001
  var response = await client.PostAsJsonAsync($"Role/{selectedRoleId}/access", dto);
1002
  if (response.IsSuccessStatusCode)
 
190
  {
191
  <p class="text-xs text-slate-400 flex items-center gap-1">
192
  <i data-lucide="info" class="h-3.5 w-3.5 shrink-0"></i>
193
+ Choose what this role can see and do on the right. It will be saved with the role.
194
  </p>
195
  }
196
  </div>
 
199
  @if (!isEditing)
200
  {
201
  <div class="w-7/12 flex flex-col min-h-0 border-l border-slate-100">
202
+ <div class="px-5 py-3 border-b border-slate-100 bg-slate-50/60 shrink-0 flex items-center justify-between gap-3">
203
+ <div class="flex items-center gap-2 min-w-0">
 
204
  <i data-lucide="shield-check" class="h-4 w-4 text-violet-500"></i>
205
  <span class="text-sm font-semibold text-slate-700">Assign Access</span>
206
+ <span class="hidden md:inline text-xs text-slate-400">Pages control where the role can go. Actions control what it can do.</span>
207
+ </div>
208
+ <div class="flex items-center gap-2">
209
+ <div class="relative w-36 sm:w-44">
210
+ <i data-lucide="search" class="pointer-events-none absolute left-2.5 top-2.5 h-3.5 w-3.5 text-slate-400"></i>
211
+ <input @bind-value="formAccessSearch" @bind-value:event="oninput" type="text" placeholder="Search"
212
+ class="w-full rounded-lg border border-slate-200 bg-white pl-8 pr-3 py-2 text-xs text-slate-700 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent" />
213
+ </div>
214
+ @if (formAccessItems.Any())
215
+ {
216
+ <label class="flex items-center gap-1.5 cursor-pointer group whitespace-nowrap">
217
+ <input type="checkbox"
218
+ checked="@IsAllFormAccessSelected"
219
+ @onchange="ToggleSelectAllFormAccess"
220
+ class="h-3.5 w-3.5 rounded accent-violet-600" />
221
+ <span class="text-xs text-slate-500 group-hover:text-violet-600 transition-colors">All</span>
222
+ </label>
223
+ }
224
  </div>
 
 
 
 
 
 
 
 
 
 
225
  </div>
226
 
227
+ <div class="overflow-y-auto flex-1 px-4 py-4">
 
228
  @if (isLoadingFormAccessItems)
229
  {
230
  <div class="flex items-center justify-center py-10">
 
240
  }
241
  else
242
  {
243
+ <div class="mb-4 rounded-xl border border-slate-100 bg-slate-50/60 px-4 py-3 text-xs text-slate-500 flex items-center justify-between">
244
+ <span>Page access lets the role open this section. Permissions let it use the buttons and actions inside.</span>
245
+ <span class="font-medium text-slate-400">@formSelectedAccessCodes.Count selected</span>
246
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
 
248
+ @if (GetRootAccessItems(true).Any())
249
+ {
250
+ <div class="grid grid-cols-1 xl:grid-cols-2 gap-3">
251
+ @foreach (var parent in GetRootAccessItems(true))
252
  {
253
+ var children = GetChildAccessItems(parent, true).ToList();
254
+ var parentChecked = formSelectedAccessCodes.Contains(parent.MenuCode);
255
+
256
+ <div class="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden">
257
+ <label class="flex items-start gap-3 px-4 py-3 bg-gradient-to-r from-slate-50 to-white cursor-pointer hover:bg-slate-50 transition-colors">
258
+ <input type="checkbox" checked="@parentChecked"
259
+ @onchange="(e) => ToggleAccessSelection(parent, (bool)(e.Value ?? false), isForm: true)"
260
+ class="mt-0.5 h-4 w-4 rounded border-slate-300 accent-violet-600" />
261
+ <div class="flex min-w-0 flex-1 items-start gap-2">
262
+ @if (!string.IsNullOrEmpty(parent.Icon))
 
 
 
 
263
  {
264
+ <i data-lucide="@parent.Icon" class="mt-0.5 h-4 w-4 text-violet-500 shrink-0"></i>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
265
  }
266
+ <div class="min-w-0 flex-1">
267
+ <div class="flex items-center gap-2">
268
+ <span class="text-sm font-semibold text-slate-800 truncate">@parent.MenuName</span>
269
+ <span class="rounded-full bg-violet-50 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-violet-600">Menu</span>
270
+ </div>
271
+ <div class="text-[11px] text-slate-400 truncate">@parent.MenuCode</div>
272
+ </div>
273
  </div>
274
+ </label>
275
+
276
+ <div class="border-t border-slate-100 px-3 py-3">
277
+ @if (parent.Permissions.Any())
 
 
 
 
278
  {
279
+ <div class="mb-3">
280
+ <div class="mb-2 flex items-center justify-between px-1">
281
+ <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">Actions</span>
282
+ <span class="text-[11px] text-slate-400">CRUD permissions for this page</span>
283
+ </div>
284
+ <div class="flex flex-wrap gap-2">
285
+ @foreach (var action in parent.Permissions)
286
+ {
287
+ var isActionChecked = formSelectedAccessCodes.Contains(action.PermissionCode);
288
+ <label class="inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] cursor-pointer select-none transition-all
289
+ @(isActionChecked
290
+ ? "bg-violet-50 border-violet-200 text-violet-700"
291
+ : "bg-white border-slate-200 text-slate-500 hover:border-violet-200 hover:text-violet-500")">
292
+ <input type="checkbox" checked="@isActionChecked"
293
+ @onchange="(e) => TogglePermissionSelection(action.PermissionCode, (bool)(e.Value ?? false), isForm: true)"
294
+ class="h-3.5 w-3.5 rounded accent-violet-600" />
295
+ <span>@action.ActionName</span>
296
+ </label>
297
+ }
298
+ </div>
299
+ </div>
300
  }
301
+
302
+ @if (children.Any())
303
+ {
304
+ <div class="space-y-2">
305
+ @foreach (var child in children)
306
+ {
307
+ var childChecked = formSelectedAccessCodes.Contains(child.MenuCode);
308
+ <div class="rounded-xl border border-slate-100 bg-slate-50/50 p-3">
309
+ <label class="flex items-center gap-2 cursor-pointer">
310
+ <input type="checkbox" checked="@childChecked"
311
+ @onchange="(e) => ToggleAccessSelection(child, (bool)(e.Value ?? false), isForm: true)"
312
+ class="h-4 w-4 rounded border-slate-300 accent-violet-600" />
313
+ <span class="text-sm font-medium text-slate-700">@child.MenuName</span>
314
+ </label>
315
+
316
+ @if (child.Permissions.Any())
317
+ {
318
+ <div class="mt-2 flex flex-wrap gap-2 pl-6">
319
+ @foreach (var action in child.Permissions)
320
+ {
321
+ var isActionChecked = formSelectedAccessCodes.Contains(action.PermissionCode);
322
+ <label class="inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] cursor-pointer select-none transition-all
323
+ @(isActionChecked
324
+ ? "bg-violet-50 border-violet-200 text-violet-700"
325
+ : "bg-white border-slate-200 text-slate-500 hover:border-violet-200 hover:text-violet-500")">
326
+ <input type="checkbox" checked="@isActionChecked"
327
+ @onchange="(e) => TogglePermissionSelection(action.PermissionCode, (bool)(e.Value ?? false), isForm: true)"
328
+ class="h-3 w-3 rounded accent-violet-600" />
329
+ <span>@action.ActionName</span>
330
+ </label>
331
+ }
332
+ </div>
333
+ }
334
+ </div>
335
+ }
336
+ </div>
337
+ }
338
+ else if (!parent.Permissions.Any())
339
+ {
340
+ <p class="text-xs text-slate-400">Nothing else is available inside this page.</p>
341
+ }
342
+ </div>
343
  </div>
344
  }
345
  </div>
346
  }
347
+ else
348
+ {
349
+ <p class="py-10 text-center text-sm text-slate-400">No pages or permissions match your search.</p>
350
+ }
351
  }
352
  </div>
353
  </div>
 
359
  <span class="text-xs text-slate-400">
360
  @if (!isEditing && formSelectedAccessCodes.Count > 0)
361
  {
362
+ <span class="text-violet-600 font-medium">@formSelectedAccessCodes.Count selected item(s)</span>
363
+ <span> will be saved</span>
364
  }
365
  </span>
366
  <div class="flex items-center gap-3">
 
386
  @if (showAccessModal)
387
  {
388
  <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
389
+ <div class="bg-white rounded-2xl shadow-2xl w-full max-w-6xl mx-4 overflow-hidden flex flex-col" style="max-height: 88vh;">
390
 
391
  <!-- Header -->
392
  <div class="flex items-center justify-between px-6 py-4 border-b border-slate-100 bg-gradient-to-r from-violet-50 to-indigo-50">
 
405
  </div>
406
 
407
  <!-- Select All Bar -->
408
+ <div class="px-6 py-3 border-b border-slate-100 bg-slate-50/60 flex items-center justify-between gap-3">
409
+ <div class="flex items-center gap-2 min-w-0">
410
+ <label class="flex items-center gap-2 cursor-pointer">
411
+ <input type="checkbox" checked="@IsAllAccessSelected" @onchange="ToggleSelectAllAccess"
412
+ class="h-4 w-4 rounded border-slate-300 accent-violet-600" />
413
+ <span class="text-sm font-medium text-slate-700">Select All</span>
414
+ </label>
415
+ <span class="hidden md:inline text-xs text-slate-400">Menus open pages. Permissions unlock actions.</span>
416
+ </div>
417
+ <div class="flex items-center gap-2">
418
+ <div class="relative w-36 sm:w-48">
419
+ <i data-lucide="search" class="pointer-events-none absolute left-2.5 top-2.5 h-3.5 w-3.5 text-slate-400"></i>
420
+ <input @bind-value="accessSearch" @bind-value:event="oninput" type="text" placeholder="Search"
421
+ class="w-full rounded-lg border border-slate-200 bg-white pl-8 pr-3 py-2 text-xs text-slate-700 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent" />
422
+ </div>
423
+ <span class="text-xs text-slate-400 whitespace-nowrap">@selectedAccessCodes.Count selected</span>
424
+ </div>
425
  </div>
426
 
427
  <!-- Menu Items Body -->
428
+ <div class="overflow-y-auto flex-1 px-4 py-4">
429
  @if (isLoadingAccessItems)
430
  {
431
  <div class="flex items-center justify-center py-12">
 
444
  }
445
  else
446
  {
447
+ <div class="mb-4 rounded-xl border border-slate-100 bg-slate-50/60 px-4 py-3 text-xs text-slate-500 flex items-center justify-between">
448
+ <span>Choosing an action also turns on its page. Turning off a page clears the actions inside it.</span>
449
+ <span class="font-medium text-slate-400">@selectedAccessCodes.Count selected</span>
450
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
451
 
452
+ @if (GetRootAccessItems(false).Any())
453
+ {
454
+ <div class="grid grid-cols-1 xl:grid-cols-2 gap-3">
455
+ @foreach (var parent in GetRootAccessItems(false))
456
  {
457
+ var children = GetChildAccessItems(parent, false).ToList();
458
+ var parentChecked = selectedAccessCodes.Contains(parent.MenuCode);
459
+
460
+ <div class="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden">
461
+ <label class="flex items-start gap-3 px-4 py-3 bg-gradient-to-r from-slate-50 to-white cursor-pointer hover:bg-slate-50 transition-colors">
462
+ <input type="checkbox" checked="@parentChecked"
463
+ @onchange="(e) => ToggleAccessSelection(parent, (bool)(e.Value ?? false), isForm: false)"
464
+ class="mt-0.5 h-4 w-4 rounded border-slate-300 accent-violet-600" />
465
+ <div class="flex min-w-0 flex-1 items-start gap-2">
466
+ @if (!string.IsNullOrEmpty(parent.Icon))
 
 
 
 
467
  {
468
+ <i data-lucide="@parent.Icon" class="mt-0.5 h-4 w-4 text-violet-500 shrink-0"></i>
469
+ }
470
+ <div class="min-w-0 flex-1">
471
+ <div class="flex items-center gap-2">
472
+ <span class="text-sm font-semibold text-slate-800 truncate">@parent.MenuName</span>
473
+ <span class="rounded-full bg-violet-50 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-violet-600">Menu</span>
474
+ </div>
475
+ <div class="text-[11px] text-slate-400 truncate">@parent.MenuCode</div>
476
+ </div>
477
+ </div>
478
+ </label>
479
+
480
+ <div class="border-t border-slate-100 px-3 py-3">
481
+ @if (parent.Permissions.Any())
482
+ {
483
+ <div class="mb-3">
484
+ <div class="mb-2 flex items-center justify-between px-1">
485
+ <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">Actions</span>
486
+ <span class="text-[11px] text-slate-400">CRUD permissions for this page</span>
487
+ </div>
488
+ <div class="flex flex-wrap gap-2">
489
+ @foreach (var action in parent.Permissions)
490
  {
491
  var isActionChecked = selectedAccessCodes.Contains(action.PermissionCode);
492
+ <label class="inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] cursor-pointer select-none transition-all
493
  @(isActionChecked
494
  ? "bg-violet-50 border-violet-200 text-violet-700"
495
  : "bg-white border-slate-200 text-slate-500 hover:border-violet-200 hover:text-violet-500")">
496
  <input type="checkbox" checked="@isActionChecked"
497
  @onchange="(e) => TogglePermissionSelection(action.PermissionCode, (bool)(e.Value ?? false), isForm: false)"
498
+ class="h-3.5 w-3.5 rounded accent-violet-600" />
499
+ <span>@action.ActionName</span>
500
  </label>
501
  }
502
  </div>
503
+ </div>
504
+ }
505
+
506
+ @if (children.Any())
507
+ {
508
+ <div class="space-y-2">
509
+ @foreach (var child in children)
510
+ {
511
+ var childChecked = selectedAccessCodes.Contains(child.MenuCode);
512
+ <div class="rounded-xl border border-slate-100 bg-slate-50/50 p-3">
513
+ <label class="flex items-center gap-2 cursor-pointer">
514
+ <input type="checkbox" checked="@childChecked"
515
+ @onchange="(e) => ToggleAccessSelection(child, (bool)(e.Value ?? false), isForm: false)"
516
+ class="h-4 w-4 rounded border-slate-300 accent-violet-600" />
517
+ <span class="text-sm font-medium text-slate-700">@child.MenuName</span>
518
+ </label>
519
+
520
+ @if (child.Permissions.Any())
521
+ {
522
+ <div class="mt-2 flex flex-wrap gap-2 pl-6">
523
+ @foreach (var action in child.Permissions)
524
+ {
525
+ var isActionChecked = selectedAccessCodes.Contains(action.PermissionCode);
526
+ <label class="inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] cursor-pointer select-none transition-all
527
+ @(isActionChecked
528
+ ? "bg-violet-50 border-violet-200 text-violet-700"
529
+ : "bg-white border-slate-200 text-slate-500 hover:border-violet-200 hover:text-violet-500")">
530
+ <input type="checkbox" checked="@isActionChecked"
531
+ @onchange="(e) => TogglePermissionSelection(action.PermissionCode, (bool)(e.Value ?? false), isForm: false)"
532
+ class="h-3 w-3 rounded accent-violet-600" />
533
+ <span>@action.ActionName</span>
534
+ </label>
535
+ }
536
+ </div>
537
+ }
538
+ </div>
539
+ }
540
+ </div>
541
+ }
542
+ else if (!parent.Permissions.Any())
543
+ {
544
+ <p class="text-xs text-slate-400">Nothing else is available inside this page.</p>
545
+ }
546
+ </div>
547
  </div>
548
  }
549
  </div>
550
  }
551
+ else
552
+ {
553
+ <p class="py-10 text-center text-sm text-slate-400">No pages or permissions match your search.</p>
554
+ }
555
  }
556
  </div>
557
 
 
675
  // Inline access items in Create form
676
  private List<AccessMenuDto> formAccessItems = new();
677
  private HashSet<string> formSelectedAccessCodes = new();
678
+ private string formAccessSearch = "";
679
  private bool isLoadingFormAccessItems = false;
680
 
681
  private bool IsAllFormAccessSelected
 
686
  private string selectedRoleName = "";
687
  private List<AccessMenuDto> accessItems = new();
688
  private HashSet<string> selectedAccessCodes = new();
689
+ private string accessSearch = "";
690
  private bool isLoadingAccessItems = false;
691
  private bool isSavingAccess = false;
692
  private string? accessSaveError;
 
753
  roleNameErrorMsg = "";
754
  formSelectedAccessCodes = new(StringComparer.OrdinalIgnoreCase);
755
  formAccessItems = new();
756
+ formAccessSearch = "";
757
  isLoadingFormAccessItems = true;
758
  showRoleModal = true;
759
  StateHasChanged();
 
794
  roleNameErrorMsg = "";
795
  formAccessItems = new();
796
  formSelectedAccessCodes = new(StringComparer.OrdinalIgnoreCase);
797
+ formAccessSearch = "";
798
  }
799
 
800
  private void ToggleAccessSelection(AccessMenuDto menu, bool isChecked, bool isForm)
 
804
 
805
  if (isChecked)
806
  {
807
+ AddMenuSelection(menu, allMenuSource, targetSet);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
808
  }
809
  else
810
  {
811
+ RemoveMenuSelection(menu, allMenuSource, targetSet);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
812
  }
813
  StateHasChanged();
814
  }
 
816
  private void TogglePermissionSelection(string actionCode, bool isChecked, bool isForm)
817
  {
818
  var targetSet = isForm ? formSelectedAccessCodes : selectedAccessCodes;
819
+ var menuSource = isForm ? formAccessItems : accessItems;
820
  if (isChecked)
821
  {
822
  targetSet.Add(actionCode);
823
+ var permission = FindPermission(actionCode, menuSource);
824
+ if (permission != null)
825
+ {
826
+ EnsureMenuPathSelected(permission.ParentMenuCode, menuSource, targetSet);
827
+ EnsureMenuBranchVisible(permission.ParentMenuCode, menuSource, targetSet);
828
+ }
829
  }
830
  else
831
  {
 
855
  }
856
  }
857
 
858
+ private static bool IsRootMenu(AccessMenuDto menu)
859
+ => string.IsNullOrWhiteSpace(menu.ParentCode) || menu.ParentCode == "0";
860
+
861
+ private static bool MatchesSearch(string? value, string search)
862
+ => !string.IsNullOrWhiteSpace(value)
863
+ && value.Contains(search, StringComparison.OrdinalIgnoreCase);
864
+
865
+ private IEnumerable<AccessMenuDto> GetRootAccessItems(bool isForm)
866
+ {
867
+ var source = isForm ? formAccessItems : accessItems;
868
+ var search = (isForm ? formAccessSearch : accessSearch).Trim();
869
+
870
+ return source
871
+ .Where(IsRootMenu)
872
+ .Where(menu => string.IsNullOrWhiteSpace(search) || AccessBranchMatchesSearch(menu, source, search))
873
+ .OrderBy(m => m.OrderNo);
874
+ }
875
+
876
+ private IEnumerable<AccessMenuDto> GetChildAccessItems(AccessMenuDto parent, bool isForm)
877
+ {
878
+ var source = isForm ? formAccessItems : accessItems;
879
+ var search = (isForm ? formAccessSearch : accessSearch).Trim();
880
+
881
+ var children = source
882
+ .Where(c => c.ParentCode == parent.MenuCode)
883
+ .OrderBy(c => c.OrderNo);
884
+
885
+ if (string.IsNullOrWhiteSpace(search))
886
+ {
887
+ return children;
888
+ }
889
+
890
+ return children.Where(child => AccessBranchMatchesSearch(child, source, search));
891
+ }
892
+
893
+ private static bool AccessBranchMatchesSearch(AccessMenuDto menu, IReadOnlyList<AccessMenuDto> source, string search)
894
+ {
895
+ if (MatchesSearch(menu.MenuName, search) || MatchesSearch(menu.MenuCode, search) || MatchesSearch(menu.MenuUrl, search))
896
+ {
897
+ return true;
898
+ }
899
+
900
+ if (menu.Permissions.Any(permission =>
901
+ MatchesSearch(permission.ActionName, search) ||
902
+ MatchesSearch(permission.PermissionCode, search) ||
903
+ MatchesSearch(permission.ApiName, search)))
904
+ {
905
+ return true;
906
+ }
907
+
908
+ return source
909
+ .Where(child => child.ParentCode == menu.MenuCode)
910
+ .Any(child => AccessBranchMatchesSearch(child, source, search));
911
+ }
912
+
913
+ private static AccessPermissionDto? FindPermission(string actionCode, IReadOnlyList<AccessMenuDto> source)
914
+ {
915
+ foreach (var menu in source)
916
+ {
917
+ var permission = menu.Permissions.FirstOrDefault(p => string.Equals(p.PermissionCode, actionCode, StringComparison.OrdinalIgnoreCase));
918
+ if (permission != null)
919
+ {
920
+ return permission;
921
+ }
922
+ }
923
+
924
+ return null;
925
+ }
926
+
927
+ private static AccessMenuDto? FindMenu(string menuCode, IReadOnlyList<AccessMenuDto> source)
928
+ {
929
+ return source.FirstOrDefault(m => string.Equals(m.MenuCode, menuCode, StringComparison.OrdinalIgnoreCase));
930
+ }
931
+
932
+ private static void EnsureMenuPathSelected(string menuCode, IReadOnlyList<AccessMenuDto> source, ISet<string> targetSet)
933
+ {
934
+ var current = FindMenu(menuCode, source);
935
+ while (current != null)
936
+ {
937
+ targetSet.Add(current.MenuCode);
938
+ if (string.IsNullOrWhiteSpace(current.ParentCode) || current.ParentCode == "0")
939
+ {
940
+ break;
941
+ }
942
+
943
+ current = FindMenu(current.ParentCode, source);
944
+ }
945
+ }
946
+
947
+ private static void EnsureMenuBranchVisible(string menuCode, IReadOnlyList<AccessMenuDto> source, ISet<string> targetSet)
948
+ {
949
+ var menu = FindMenu(menuCode, source);
950
+ if (menu == null)
951
+ {
952
+ return;
953
+ }
954
+
955
+ targetSet.Add(menu.MenuCode);
956
+
957
+ foreach (var child in GetAutoVisibleChildren(menu, source))
958
+ {
959
+ EnsureMenuBranchVisible(child.MenuCode, source, targetSet);
960
+ }
961
+ }
962
+
963
+ private static void AddMenuSelection(AccessMenuDto menu, IReadOnlyList<AccessMenuDto> source, ISet<string> targetSet)
964
+ {
965
+ targetSet.Add(menu.MenuCode);
966
+ foreach (var permission in menu.Permissions)
967
+ {
968
+ targetSet.Add(permission.PermissionCode);
969
+ }
970
+
971
+ EnsureMenuPathSelected(menu.ParentCode, source, targetSet);
972
+
973
+ foreach (var child in GetAutoVisibleChildren(menu, source))
974
+ {
975
+ EnsureMenuBranchVisible(child.MenuCode, source, targetSet);
976
+ }
977
+ }
978
+
979
+ private static void RemoveMenuSelection(AccessMenuDto menu, IReadOnlyList<AccessMenuDto> source, ISet<string> targetSet)
980
+ {
981
+ targetSet.Remove(menu.MenuCode);
982
+ foreach (var permission in menu.Permissions)
983
+ {
984
+ targetSet.Remove(permission.PermissionCode);
985
+ }
986
+
987
+ foreach (var child in GetAutoVisibleChildren(menu, source))
988
+ {
989
+ RemoveMenuSelection(child, source, targetSet);
990
+ }
991
+
992
+ CleanupAncestorSelection(menu.ParentCode, source, targetSet);
993
+ }
994
+
995
+ private static void CleanupAncestorSelection(string parentCode, IReadOnlyList<AccessMenuDto> source, ISet<string> targetSet)
996
+ {
997
+ if (string.IsNullOrWhiteSpace(parentCode) || parentCode == "0")
998
+ {
999
+ return;
1000
+ }
1001
+
1002
+ var parent = FindMenu(parentCode, source);
1003
+ if (parent == null)
1004
+ {
1005
+ return;
1006
+ }
1007
+
1008
+ if (HasAnySelectedInBranch(parent, source, targetSet))
1009
+ {
1010
+ return;
1011
+ }
1012
+
1013
+ targetSet.Remove(parent.MenuCode);
1014
+ CleanupAncestorSelection(parent.ParentCode, source, targetSet);
1015
+ }
1016
+
1017
+ private static bool HasAnySelectedInBranch(AccessMenuDto menu, IReadOnlyList<AccessMenuDto> source, ISet<string> targetSet)
1018
+ {
1019
+ if (targetSet.Contains(menu.MenuCode) || menu.Permissions.Any(p => targetSet.Contains(p.PermissionCode)))
1020
+ {
1021
+ return true;
1022
+ }
1023
+
1024
+ return GetAutoVisibleChildren(menu, source)
1025
+ .Any(child => HasAnySelectedInBranch(child, source, targetSet));
1026
+ }
1027
+
1028
+ private static void NormalizeAccessSelection(IReadOnlyList<AccessMenuDto> source, ISet<string> targetSet)
1029
+ {
1030
+ var selectedPermissions = source
1031
+ .SelectMany(menu => menu.Permissions)
1032
+ .Where(permission => targetSet.Contains(permission.PermissionCode))
1033
+ .ToList();
1034
+
1035
+ foreach (var permission in selectedPermissions)
1036
+ {
1037
+ EnsureMenuPathSelected(permission.ParentMenuCode, source, targetSet);
1038
+ EnsureMenuBranchVisible(permission.ParentMenuCode, source, targetSet);
1039
+ }
1040
+ }
1041
+
1042
+ private static IEnumerable<AccessMenuDto> GetAutoVisibleChildren(AccessMenuDto menu, IReadOnlyList<AccessMenuDto> source)
1043
+ {
1044
+ return source
1045
+ .Where(m => m.ParentCode == menu.MenuCode && IsAutoVisiblePageMenu(m))
1046
+ .OrderBy(m => m.OrderNo)
1047
+ .ThenBy(m => m.MenuName, StringComparer.OrdinalIgnoreCase);
1048
+ }
1049
+
1050
+ private static bool IsAutoVisiblePageMenu(AccessMenuDto menu)
1051
+ {
1052
+ var text = $"{menu.MenuName} {menu.MenuCode} {menu.MenuUrl}".Trim();
1053
+ return text.Contains("list", StringComparison.OrdinalIgnoreCase)
1054
+ || text.Contains("view", StringComparison.OrdinalIgnoreCase)
1055
+ || text.Contains("dashboard", StringComparison.OrdinalIgnoreCase)
1056
+ || text.Contains("home", StringComparison.OrdinalIgnoreCase);
1057
+ }
1058
+
1059
  private void RequestSaveConfirmation()
1060
  {
1061
  isRoleNameError = false;
 
1143
  }
1144
  else
1145
  {
1146
+ NormalizeAccessSelection(formAccessItems, formSelectedAccessCodes);
1147
  var dto = new CreateRoleDto
1148
  {
1149
  Name = formName,
 
1192
  selectedRoleName = role.Name;
1193
  accessSaveError = null;
1194
  accessSaveSuccess = null;
1195
+ accessSearch = "";
1196
  isLoadingAccessItems = true;
1197
  showAccessModal = true;
1198
  StateHasChanged();
 
1206
  // Load currently assigned access items for this role
1207
  var assignedResult = await client.GetFromJsonAsync<Result<List<string>>>($"Role/{role.Id}/access", Serialization.CaseInsensitive);
1208
  selectedAccessCodes = new HashSet<string>(assignedResult?.Value ?? new(), StringComparer.OrdinalIgnoreCase);
1209
+ NormalizeAccessSelection(accessItems, selectedAccessCodes);
1210
  }
1211
  catch (Exception ex)
1212
  {
 
1225
  showAccessModal = false;
1226
  accessItems = new();
1227
  selectedAccessCodes = new(StringComparer.OrdinalIgnoreCase);
1228
+ accessSearch = "";
1229
  accessSaveError = null;
1230
  accessSaveSuccess = null;
1231
  }
 
1259
  var client = ApiClient.CreateClient();
1260
  try
1261
  {
1262
+ NormalizeAccessSelection(accessItems, selectedAccessCodes);
1263
  var dto = new AssignAccessDto { AccessCodes = selectedAccessCodes.ToList() };
1264
  var response = await client.PostAsJsonAsync($"Role/{selectedRoleId}/access", dto);
1265
  if (response.IsSuccessStatusCode)
TaskTrackingSystem.WebApp/Components/Pages/TaskAssign.razor CHANGED
@@ -524,35 +524,35 @@
524
  }
525
  }
526
 
527
- private string GetStatusBadge(long statusId) => statusId switch
528
  {
529
- 1 => "bg-slate-100 text-slate-700",
530
- 2 => "bg-blue-100 text-blue-700",
531
- 3 => "bg-emerald-100 text-emerald-700",
532
  _ => "bg-slate-100 text-slate-600"
533
  };
534
 
535
- private string GetStatusLabel(long statusId) => statusId switch
536
  {
537
- 1 => "To Do",
538
- 2 => "In Progress",
539
- 3 => "Done",
540
  _ => "Unknown"
541
  };
542
 
543
- private string GetPriorityBadge(long priorityId) => priorityId switch
544
  {
545
- 1 => "bg-slate-100 text-slate-600",
546
- 2 => "bg-amber-100 text-amber-700",
547
- 3 => "bg-red-100 text-red-700",
548
  _ => "bg-slate-100 text-slate-600"
549
  };
550
 
551
- private string GetPriorityLabel(long priorityId) => priorityId switch
552
  {
553
- 1 => "Low",
554
- 2 => "Medium",
555
- 3 => "High",
556
- _ => ""
557
  };
558
  }
 
524
  }
525
  }
526
 
527
+ private string GetStatusBadge(AppTaskStatus statusId) => statusId switch
528
  {
529
+ AppTaskStatus.Todo => "bg-slate-100 text-slate-700",
530
+ AppTaskStatus.InProgress => "bg-blue-100 text-blue-700",
531
+ AppTaskStatus.Done => "bg-emerald-100 text-emerald-700",
532
  _ => "bg-slate-100 text-slate-600"
533
  };
534
 
535
+ private string GetStatusLabel(AppTaskStatus statusId) => statusId switch
536
  {
537
+ AppTaskStatus.Todo => "To Do",
538
+ AppTaskStatus.InProgress => "In Progress",
539
+ AppTaskStatus.Done => "Done",
540
  _ => "Unknown"
541
  };
542
 
543
+ private string GetPriorityBadge(TaskPriority priorityId) => priorityId switch
544
  {
545
+ TaskPriority.Low => "bg-slate-100 text-slate-600",
546
+ TaskPriority.Medium => "bg-amber-100 text-amber-700",
547
+ TaskPriority.High => "bg-red-100 text-red-700",
548
  _ => "bg-slate-100 text-slate-600"
549
  };
550
 
551
+ private string GetPriorityLabel(TaskPriority priorityId) => priorityId switch
552
  {
553
+ TaskPriority.Low => "Low",
554
+ TaskPriority.Medium => "Medium",
555
+ TaskPriority.High => "High",
556
+ _ => "—"
557
  };
558
  }
TaskTrackingSystem.WebApp/Components/Pages/TaskDetails.razor CHANGED
@@ -142,9 +142,9 @@
142
  <div class="flex flex-col gap-1.5">
143
  <span class="text-xs font-bold uppercase tracking-wider text-slate-400">Status</span>
144
  <select value="@task.StatusId" @onchange="OnStatusChanged" class="h-9 rounded-lg border border-slate-200 px-3 text-sm text-slate-800 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent bg-white w-full transition-all cursor-pointer shadow-sm">
145
- <option value="1">To Do</option>
146
- <option value="2">In Progress</option>
147
- <option value="3">Done</option>
148
  </select>
149
  </div>
150
 
@@ -154,9 +154,9 @@
154
  @if (hasTaskManagePermission)
155
  {
156
  <select value="@task.PriorityId" @onchange="OnPriorityChanged" class="h-9 rounded-lg border border-slate-200 px-3 text-sm text-slate-800 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent bg-white w-full transition-all cursor-pointer shadow-sm">
157
- <option value="1">Low</option>
158
- <option value="2">Medium</option>
159
- <option value="3">High</option>
160
  </select>
161
  }
162
  else
@@ -318,7 +318,7 @@
318
  </span>
319
  <div class="min-w-0">
320
  <p class="text-xs font-semibold text-slate-800 truncate" title="@file.FileName">@file.FileName</p>
321
- <p class="text-[10px] text-slate-400 mt-0.5 font-sans">@FormatBytes(file.FileSizeInBytes) by @file.CreatedByName</p>
322
  </div>
323
  </div>
324
  <div class="flex items-center gap-1.5 shrink-0">
@@ -584,20 +584,20 @@
584
  }
585
  }
586
 
587
- // ─── INLINE MUTATIONS ───────────────────────────────────────────────────
588
 
589
  private async Task OnStatusChanged(ChangeEventArgs e)
590
  {
591
  if (task == null) return;
592
- if (long.TryParse(e.Value?.ToString(), out var newStatusId))
593
  {
594
  var client = ApiClient.CreateClient();
595
  try
596
  {
597
- var response = await client.PatchAsync($"Task/{task.Id}/status?statusId={newStatusId}", null);
598
  if (response.IsSuccessStatusCode)
599
  {
600
- task.StatusId = newStatusId;
601
  await OnTaskUpdated.InvokeAsync();
602
  await JS.InvokeVoidAsync("initIcons");
603
  }
@@ -611,9 +611,9 @@
611
 
612
  private async Task OnPriorityChanged(ChangeEventArgs e)
613
  {
614
- if (long.TryParse(e.Value?.ToString(), out var newPriorityId))
615
  {
616
- await UpdateTaskProperty(dto => dto.PriorityId = newPriorityId);
617
  }
618
  }
619
 
@@ -729,7 +729,7 @@
729
  isSavingTitle = false;
730
  }
731
 
732
- // ─── COMMENTS ACTIONS ───────────────────────────────────────────────────
733
 
734
  private async Task PostComment()
735
  {
@@ -791,7 +791,7 @@
791
  }
792
  }
793
 
794
- // ─── ATTACHMENTS ACTIONS ────────────────────────────────────────────────
795
 
796
  private async Task OnInputFileChange(InputFileChangeEventArgs e)
797
  {
@@ -859,7 +859,7 @@
859
  }
860
  }
861
 
862
- // ─── CONFIRM DIALOG LOGIC ───────────────────────────────────────────────
863
 
864
  private async Task ExecuteConfirmAction()
865
  {
@@ -877,37 +877,37 @@
877
  confirmAction = null;
878
  }
879
 
880
- // ─── DISPLAY UTILITIES ──────────────────────────────────────────────────
881
 
882
- private string GetStatusLabel(long statusId) => statusId switch
883
  {
884
- 1 => "To Do",
885
- 2 => "In Progress",
886
- 3 => "Done",
887
  _ => "Unknown"
888
  };
889
 
890
- private string GetStatusBadge(long statusId) => statusId switch
891
  {
892
- 1 => "bg-slate-100 text-slate-800 border border-slate-200",
893
- 2 => "bg-blue-50 text-blue-700 border border-blue-100",
894
- 3 => "bg-emerald-50 text-emerald-700 border border-emerald-100",
895
  _ => "bg-slate-50 text-slate-600"
896
  };
897
 
898
- private string GetPriorityLabel(long priorityId) => priorityId switch
899
  {
900
- 1 => "Low",
901
- 2 => "Medium",
902
- 3 => "High",
903
  _ => "Unknown"
904
  };
905
 
906
- private string GetPriorityBadge(long priorityId) => priorityId switch
907
  {
908
- 1 => "bg-slate-100 text-slate-600",
909
- 2 => "bg-amber-50 text-amber-700 border border-amber-100",
910
- 3 => "bg-rose-50 text-rose-700 border border-rose-100",
911
  _ => "bg-slate-50 text-slate-600"
912
  };
913
 
 
142
  <div class="flex flex-col gap-1.5">
143
  <span class="text-xs font-bold uppercase tracking-wider text-slate-400">Status</span>
144
  <select value="@task.StatusId" @onchange="OnStatusChanged" class="h-9 rounded-lg border border-slate-200 px-3 text-sm text-slate-800 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent bg-white w-full transition-all cursor-pointer shadow-sm">
145
+ <option value="@AppTaskStatus.Todo">To Do</option>
146
+ <option value="@AppTaskStatus.InProgress">In Progress</option>
147
+ <option value="@AppTaskStatus.Done">Done</option>
148
  </select>
149
  </div>
150
 
 
154
  @if (hasTaskManagePermission)
155
  {
156
  <select value="@task.PriorityId" @onchange="OnPriorityChanged" class="h-9 rounded-lg border border-slate-200 px-3 text-sm text-slate-800 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent bg-white w-full transition-all cursor-pointer shadow-sm">
157
+ <option value="@TaskPriority.Low">Low</option>
158
+ <option value="@TaskPriority.Medium">Medium</option>
159
+ <option value="@TaskPriority.High">High</option>
160
  </select>
161
  }
162
  else
 
318
  </span>
319
  <div class="min-w-0">
320
  <p class="text-xs font-semibold text-slate-800 truncate" title="@file.FileName">@file.FileName</p>
321
+ <p class="text-[10px] text-slate-400 mt-0.5 font-sans">@FormatBytes(file.FileSizeInBytes) • by @file.CreatedByName</p>
322
  </div>
323
  </div>
324
  <div class="flex items-center gap-1.5 shrink-0">
 
584
  }
585
  }
586
 
587
+ // ─── INLINE MUTATIONS ───────────────────────────────────────────────────
588
 
589
  private async Task OnStatusChanged(ChangeEventArgs e)
590
  {
591
  if (task == null) return;
592
+ if (Enum.TryParse<AppTaskStatus>(e.Value?.ToString(), out var newStatus))
593
  {
594
  var client = ApiClient.CreateClient();
595
  try
596
  {
597
+ var response = await client.PatchAsync($"Task/{task.Id}/status?statusId={(int)newStatus}", null);
598
  if (response.IsSuccessStatusCode)
599
  {
600
+ task.StatusId = newStatus;
601
  await OnTaskUpdated.InvokeAsync();
602
  await JS.InvokeVoidAsync("initIcons");
603
  }
 
611
 
612
  private async Task OnPriorityChanged(ChangeEventArgs e)
613
  {
614
+ if (Enum.TryParse<TaskPriority>(e.Value?.ToString(), out var newPriority))
615
  {
616
+ await UpdateTaskProperty(dto => dto.PriorityId = newPriority);
617
  }
618
  }
619
 
 
729
  isSavingTitle = false;
730
  }
731
 
732
+ // ─── COMMENTS ACTIONS ───────────────────────────────────────────────────
733
 
734
  private async Task PostComment()
735
  {
 
791
  }
792
  }
793
 
794
+ // ─── ATTACHMENTS ACTIONS ────────────────────────────────────────────────
795
 
796
  private async Task OnInputFileChange(InputFileChangeEventArgs e)
797
  {
 
859
  }
860
  }
861
 
862
+ // ─── CONFIRM DIALOG LOGIC ───────────────────────────────────────────────
863
 
864
  private async Task ExecuteConfirmAction()
865
  {
 
877
  confirmAction = null;
878
  }
879
 
880
+ // ─── DISPLAY UTILITIES ──────────────────────────────────────────────────
881
 
882
+ private string GetStatusLabel(AppTaskStatus statusId) => statusId switch
883
  {
884
+ AppTaskStatus.Todo => "To Do",
885
+ AppTaskStatus.InProgress => "In Progress",
886
+ AppTaskStatus.Done => "Done",
887
  _ => "Unknown"
888
  };
889
 
890
+ private string GetStatusBadge(AppTaskStatus statusId) => statusId switch
891
  {
892
+ AppTaskStatus.Todo => "bg-slate-100 text-slate-800 border border-slate-200",
893
+ AppTaskStatus.InProgress => "bg-blue-50 text-blue-700 border border-blue-100",
894
+ AppTaskStatus.Done => "bg-emerald-50 text-emerald-700 border border-emerald-100",
895
  _ => "bg-slate-50 text-slate-600"
896
  };
897
 
898
+ private string GetPriorityLabel(TaskPriority priorityId) => priorityId switch
899
  {
900
+ TaskPriority.Low => "Low",
901
+ TaskPriority.Medium => "Medium",
902
+ TaskPriority.High => "High",
903
  _ => "Unknown"
904
  };
905
 
906
+ private string GetPriorityBadge(TaskPriority priorityId) => priorityId switch
907
  {
908
+ TaskPriority.Low => "bg-slate-100 text-slate-600",
909
+ TaskPriority.Medium => "bg-amber-50 text-amber-700 border border-amber-100",
910
+ TaskPriority.High => "bg-rose-50 text-rose-700 border border-rose-100",
911
  _ => "bg-slate-50 text-slate-600"
912
  };
913
 
TaskTrackingSystem.WebApp/Components/Pages/TaskReport.razor CHANGED
@@ -167,21 +167,21 @@
167
  <svg class="w-full max-w-[320px]" height="220" viewBox="0 0 320 220">
168
  <g transform="translate(0, 180)">
169
  <!-- Axes -->
170
- <line x1="40" y1="0" x2="280" y2="0" stroke="#cbd5e1" stroke-width="2" />
171
  <!-- To Do Bar -->
172
- <rect x="60" y="-@hTodo" width="40" height="@hTodo" rx="4" fill="#e2e8f0" />
173
- <text x="80" y="20" font-size="12" fill="#64748b" text-anchor="middle">To Do</text>
174
- <text x="80" y="-@(hTodo + 10)" font-size="12" font-weight="bold" fill="#334155" text-anchor="middle">@todo</text>
175
 
176
  <!-- In Progress Bar -->
177
- <rect x="140" y="-@hInProgress" width="40" height="@hInProgress" rx="4" fill="#3b82f6" />
178
- <text x="160" y="20" font-size="12" fill="#64748b" text-anchor="middle">In Progress</text>
179
- <text x="160" y="-@(hInProgress + 10)" font-size="12" font-weight="bold" fill="#334155" text-anchor="middle">@inProgress</text>
180
 
181
  <!-- Done Bar -->
182
- <rect x="220" y="-@hDone" width="40" height="@hDone" rx="4" fill="#10b981" />
183
- <text x="240" y="20" font-size="12" fill="#64748b" text-anchor="middle">Done</text>
184
- <text x="240" y="-@(hDone + 10)" font-size="12" font-weight="bold" fill="#334155" text-anchor="middle">@done</text>
185
  </g>
186
  </svg>
187
  }
@@ -213,7 +213,7 @@
213
  <span>@high tasks (@pctHigh%)</span>
214
  </div>
215
  <div class="h-3 rounded-full bg-slate-100 overflow-hidden">
216
- <div class="h-full bg-rose-500 rounded-full" style="width: @pctHigh%"></div>
217
  </div>
218
  </div>
219
  <div>
@@ -222,7 +222,7 @@
222
  <span>@med tasks (@pctMed%)</span>
223
  </div>
224
  <div class="h-3 rounded-full bg-slate-100 overflow-hidden">
225
- <div class="h-full bg-amber-500 rounded-full" style="width: @pctMed%"></div>
226
  </div>
227
  </div>
228
  <div>
@@ -231,7 +231,7 @@
231
  <span>@low tasks (@pctLow%)</span>
232
  </div>
233
  <div class="h-3 rounded-full bg-slate-100 overflow-hidden">
234
- <div class="h-full bg-slate-400 rounded-full" style="width: @pctLow%"></div>
235
  </div>
236
  </div>
237
  </div>
@@ -287,8 +287,8 @@
287
  <path d="M45 40H75" stroke="currentColor" stroke-width="3" stroke-linecap="round" />
288
  <path d="M45 55H75" stroke="currentColor" stroke-width="3" stroke-linecap="round" />
289
  <path d="M45 70H60" stroke="currentColor" stroke-width="3" stroke-linecap="round" />
290
- <circle cx="85" cy="85" r="15" fill="white" stroke="#7c3aed" stroke-width="3" />
291
- <path d="M80 85L90 85M85 80L85 90" stroke="#7c3aed" stroke-width="3" stroke-linecap="round" />
292
  </svg>
293
  <h3 class="text-lg font-semibold text-slate-700">No Tasks Found</h3>
294
  <p class="text-sm text-slate-400 max-w-xs">There are no tasks matching your filters. Try adjusting your search query or filters.</p>
 
167
  <svg class="w-full max-w-[320px]" height="220" viewBox="0 0 320 220">
168
  <g transform="translate(0, 180)">
169
  <!-- Axes -->
170
+ <line x1="40" y1="0" x2="280" y2="0" stroke="#E5E7EB" stroke-width="2" />
171
  <!-- To Do Bar -->
172
+ <rect x="60" y="-@hTodo" width="40" height="@hTodo" rx="4" fill="#E5E7EB" />
173
+ <text x="80" y="20" font-size="12" fill="#6B7280" text-anchor="middle">To Do</text>
174
+ <text x="80" y="-@(hTodo + 10)" font-size="12" font-weight="bold" fill="#10002B" text-anchor="middle">@todo</text>
175
 
176
  <!-- In Progress Bar -->
177
+ <rect x="140" y="-@hInProgress" width="40" height="@hInProgress" rx="4" fill="#06B6D4" />
178
+ <text x="160" y="20" font-size="12" fill="#6B7280" text-anchor="middle">In Progress</text>
179
+ <text x="160" y="-@(hInProgress + 10)" font-size="12" font-weight="bold" fill="#10002B" text-anchor="middle">@inProgress</text>
180
 
181
  <!-- Done Bar -->
182
+ <rect x="220" y="-@hDone" width="40" height="@hDone" rx="4" fill="#10B981" />
183
+ <text x="240" y="20" font-size="12" fill="#6B7280" text-anchor="middle">Done</text>
184
+ <text x="240" y="-@(hDone + 10)" font-size="12" font-weight="bold" fill="#10002B" text-anchor="middle">@done</text>
185
  </g>
186
  </svg>
187
  }
 
213
  <span>@high tasks (@pctHigh%)</span>
214
  </div>
215
  <div class="h-3 rounded-full bg-slate-100 overflow-hidden">
216
+ <div class="h-full bg-violet-600 rounded-full" style="width: @pctHigh%"></div>
217
  </div>
218
  </div>
219
  <div>
 
222
  <span>@med tasks (@pctMed%)</span>
223
  </div>
224
  <div class="h-3 rounded-full bg-slate-100 overflow-hidden">
225
+ <div class="h-full bg-violet-400 rounded-full" style="width: @pctMed%"></div>
226
  </div>
227
  </div>
228
  <div>
 
231
  <span>@low tasks (@pctLow%)</span>
232
  </div>
233
  <div class="h-3 rounded-full bg-slate-100 overflow-hidden">
234
+ <div class="h-full bg-violet-200 rounded-full" style="width: @pctLow%"></div>
235
  </div>
236
  </div>
237
  </div>
 
287
  <path d="M45 40H75" stroke="currentColor" stroke-width="3" stroke-linecap="round" />
288
  <path d="M45 55H75" stroke="currentColor" stroke-width="3" stroke-linecap="round" />
289
  <path d="M45 70H60" stroke="currentColor" stroke-width="3" stroke-linecap="round" />
290
+ <circle cx="85" cy="85" r="15" fill="white" stroke="#5A189A" stroke-width="3" />
291
+ <path d="M80 85L90 85M85 80L85 90" stroke="#5A189A" stroke-width="3" stroke-linecap="round" />
292
  </svg>
293
  <h3 class="text-lg font-semibold text-slate-700">No Tasks Found</h3>
294
  <p class="text-sm text-slate-400 max-w-xs">There are no tasks matching your filters. Try adjusting your search query or filters.</p>