Spaces:
Running
Running
hlaingnandarhtet commited on
Commit ·
47e77e5
1
Parent(s): 49c7853
Merge remote changes and apply TaskStatus/TaskPriority enum refactoring
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitignore +4 -1
- TaskTrackingSystem.Database/AppDbContextModels/AppDbContext.cs +55 -2
- TaskTrackingSystem.Database/AppDbContextModels/Notification.cs +32 -0
- TaskTrackingSystem.Database/AppDbContextModels/Task.cs +4 -3
- TaskTrackingSystem.Database/AppDbContextModels/TaskHistory.cs +6 -5
- TaskTrackingSystem.Database/AppDbContextModels/UserDevice.cs +18 -0
- TaskTrackingSystem.Database/TaskTrackingSystem.Database.csproj +3 -0
- TaskTrackingSystem.Shared/Enums/EnumAdminMenuCode.cs +0 -37
- TaskTrackingSystem.Shared/Enums/TaskPriority.cs +11 -0
- TaskTrackingSystem.Shared/Enums/TaskStatus.cs +11 -0
- TaskTrackingSystem.Shared/Models/Dashboard/TaskStatusOverviewDto.cs +4 -1
- TaskTrackingSystem.Shared/Models/Notification/NotificationDto.cs +17 -0
- TaskTrackingSystem.Shared/Models/Notification/NotificationType.cs +14 -0
- TaskTrackingSystem.Shared/Models/Notification/RegisterDeviceTokenDto.cs +6 -0
- TaskTrackingSystem.Shared/Models/Report/TaskReportDto.cs +4 -2
- TaskTrackingSystem.Shared/Models/Report/TimeTrackingReportDto.cs +3 -1
- TaskTrackingSystem.Shared/Models/Task/CreateTaskDto.cs +6 -4
- TaskTrackingSystem.Shared/Models/Task/TaskDto.cs +4 -3
- TaskTrackingSystem.Shared/Models/Task/UpdateTaskDto.cs +6 -4
- TaskTrackingSystem.WebApi/Features/Dashboard/DashboardService.cs +7 -6
- TaskTrackingSystem.WebApi/Features/Notification/FirebaseNotificationService.cs +333 -0
- TaskTrackingSystem.WebApi/Features/Notification/NotificationController.cs +58 -0
- TaskTrackingSystem.WebApi/Features/Notification/NotificationService.cs +67 -0
- TaskTrackingSystem.WebApi/Features/Project/ProjectService.cs +18 -1
- TaskTrackingSystem.WebApi/Features/Report/ReportController.cs +9 -8
- TaskTrackingSystem.WebApi/Features/Report/ReportService.cs +21 -20
- TaskTrackingSystem.WebApi/Features/Role/RoleController.cs +4 -4
- TaskTrackingSystem.WebApi/Features/Role/RoleService.cs +81 -24
- TaskTrackingSystem.WebApi/Features/Task/TaskController.cs +2 -1
- TaskTrackingSystem.WebApi/Features/Task/TaskDetailsController.cs +5 -1
- TaskTrackingSystem.WebApi/Features/Task/TaskService.cs +49 -5
- TaskTrackingSystem.WebApi/Features/UserDevice/UserDeviceService.cs +66 -0
- TaskTrackingSystem.WebApi/Features/UserDevice/UserDevicesController.cs +46 -0
- TaskTrackingSystem.WebApi/Program.cs +18 -0
- TaskTrackingSystem.WebApi/TaskTrackingSystem.WebApi.csproj +6 -0
- TaskTrackingSystem.WebApi/appsettings.Development.json +3 -0
- TaskTrackingSystem.WebApi/appsettings.json +3 -0
- TaskTrackingSystem.WebApp/Components/App.razor +197 -18
- TaskTrackingSystem.WebApp/Components/Layout/MainLayout.razor +14 -12
- TaskTrackingSystem.WebApp/Components/Layout/MainLayout.razor.css +6 -7
- TaskTrackingSystem.WebApp/Components/Layout/NavMenu.razor.css +26 -12
- TaskTrackingSystem.WebApp/Components/Pages/Backlog.razor +31 -31
- TaskTrackingSystem.WebApp/Components/Pages/Home.razor +19 -19
- TaskTrackingSystem.WebApp/Components/Pages/KanbanBoard.razor +22 -22
- TaskTrackingSystem.WebApp/Components/Pages/OverdueTasksReport.razor +28 -28
- TaskTrackingSystem.WebApp/Components/Pages/Register.razor +1 -1
- TaskTrackingSystem.WebApp/Components/Pages/Roles.razor +450 -187
- TaskTrackingSystem.WebApp/Components/Pages/TaskAssign.razor +17 -17
- TaskTrackingSystem.WebApp/Components/Pages/TaskDetails.razor +33 -33
- 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(
|
| 267 |
-
entity.Property(e => e.StatusId).HasDefaultValue(
|
| 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 |
-
|
| 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
|
| 17 |
|
| 18 |
-
public
|
| 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 |
-
|
| 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
|
| 15 |
|
| 16 |
-
public
|
| 17 |
|
| 18 |
-
public
|
| 19 |
|
| 20 |
-
public
|
| 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
|
| 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
|
| 13 |
public string StatusName { get; set; } = string.Empty;
|
| 14 |
-
public
|
| 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
|
| 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 |
-
[
|
| 20 |
-
public
|
| 21 |
|
| 22 |
-
[
|
| 23 |
-
public
|
| 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
|
|
|
|
| 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
|
| 16 |
-
public
|
| 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 |
-
[
|
| 17 |
-
public
|
| 18 |
|
| 19 |
[Required]
|
| 20 |
-
[
|
| 21 |
-
public
|
| 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 !=
|
| 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<
|
| 61 |
{
|
| 62 |
-
{
|
| 63 |
-
{
|
| 64 |
-
{
|
| 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 ==
|
| 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 ==
|
| 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 |
-
//
|
| 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 |
-
//
|
| 45 |
|
| 46 |
[HttpGet("task-status-summary")]
|
| 47 |
public async Task<ActionResult<Result<IEnumerable<TaskStatusSummaryDto>>>> GetTaskStatusSummary(
|
| 48 |
[FromQuery] string? search,
|
| 49 |
-
[FromQuery]
|
| 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]
|
| 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 |
-
//
|
| 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 |
-
//
|
| 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 |
-
//
|
| 116 |
|
| 117 |
[HttpGet("time-tracking")]
|
| 118 |
public async Task<ActionResult<Result<TimeTrackingReportDto>>> GetTimeTracking(
|
| 119 |
[FromQuery] string? search,
|
| 120 |
[FromQuery] long? projectId,
|
| 121 |
-
[FromQuery]
|
| 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<
|
| 18 |
-
private static readonly Dictionary<
|
| 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 |
-
//
|
| 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 !=
|
| 101 |
}
|
| 102 |
else
|
| 103 |
{
|
| 104 |
-
|
| 105 |
if (sid.HasValue) query = query.Where(t => t.StatusId == sid.Value);
|
| 106 |
-
else if (
|
| 107 |
}
|
| 108 |
}
|
| 109 |
|
|
@@ -167,17 +168,17 @@ namespace TaskTrackingSystem.WebApi.Features.Report
|
|
| 167 |
return Result<IEnumerable<UserProductivityDto>>.Success(list);
|
| 168 |
}
|
| 169 |
|
| 170 |
-
//
|
| 171 |
|
| 172 |
public async Task<Result<IEnumerable<TaskStatusSummaryDto>>> GetTaskStatusSummaryAsync(
|
| 173 |
-
string? search,
|
| 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
|
| 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 |
-
//
|
| 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 ==
|
| 266 |
-
InProgress = uTasks.Count(t => t.StatusId ==
|
| 267 |
-
ToDo = uTasks.Count(t => t.StatusId ==
|
| 268 |
-
Overdue = uTasks.Count(t => t.DueDate < now && t.StatusId !=
|
| 269 |
-
CompletionRate = uTasks.Count > 0 ? Math.Round((double)uTasks.Count(t => t.StatusId ==
|
| 270 |
};
|
| 271 |
}).ToList();
|
| 272 |
|
|
@@ -312,7 +313,7 @@ namespace TaskTrackingSystem.WebApi.Features.Report
|
|
| 312 |
return ms.ToArray();
|
| 313 |
}
|
| 314 |
|
| 315 |
-
//
|
| 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 !=
|
| 324 |
-
(t.DueDate < now || t.PriorityId ==
|
| 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,
|
| 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
|
| 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 =
|
| 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 =
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 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
|
| 235 |
-
var
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
|
| 242 |
-
|
| 243 |
{
|
| 244 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
|
|
|
| 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 =
|
|
|
|
| 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(
|
| 296 |
.Select(id => menuNameLookup.TryGetValue(id, out var n) ? n : id.ToString())
|
| 297 |
.OrderBy(n => n)
|
| 298 |
.ToList();
|
| 299 |
|
| 300 |
-
var removedMenuNames =
|
| 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]
|
| 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 ?
|
| 111 |
-
PriorityId = dto.PriorityId == 0 ?
|
| 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,
|
| 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 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
-
|
| 29 |
-
|
| 30 |
|
| 31 |
-
|
| 32 |
-
|
| 33 |
|
| 34 |
-
|
| 35 |
-
|
| 36 |
|
| 37 |
-
|
| 38 |
-
|
| 39 |
|
| 40 |
-
|
| 41 |
-
}
|
| 42 |
}
|
| 43 |
}
|
| 44 |
}
|
|
|
|
| 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-
|
| 6 |
-
<aside class="flex w-64 flex-col border-r border-slate-
|
| 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-
|
| 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-
|
| 12 |
</div>
|
| 13 |
|
| 14 |
<NavMenu @rendermode="new InteractiveServerRenderMode(prerender: false)" />
|
| 15 |
|
| 16 |
-
<div class="border-t border-slate-
|
| 17 |
<AuthorizeView>
|
| 18 |
<Authorized>
|
| 19 |
-
<div class="flex items-center rounded-xl p-2 hover:bg-
|
| 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]
|
| 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
|
| 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 |
-
<
|
| 69 |
-
<
|
| 70 |
-
|
| 71 |
-
|
|
|
|
|
|
|
| 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: #
|
| 14 |
}
|
| 15 |
|
| 16 |
.top-row {
|
| 17 |
background-color: #ffffff;
|
| 18 |
-
border-bottom: 1px solid #
|
| 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: #
|
| 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:
|
| 38 |
}
|
| 39 |
|
| 40 |
@media (min-width: 641px) {
|
|
@@ -51,8 +50,8 @@ main {
|
|
| 51 |
}
|
| 52 |
|
| 53 |
#blazor-error-ui {
|
| 54 |
-
background: #
|
| 55 |
-
color: #
|
| 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: #
|
| 10 |
transition: all 0.2s ease;
|
| 11 |
border-left: 3px solid transparent;
|
| 12 |
}
|
| 13 |
|
| 14 |
::deep a.nav-link-item:hover {
|
| 15 |
-
background-color:
|
| 16 |
-
color: #
|
| 17 |
}
|
| 18 |
|
| 19 |
::deep a.nav-link-item.active {
|
| 20 |
-
background-color:
|
| 21 |
-
color: #
|
| 22 |
-
border-left: 3px solid #
|
| 23 |
font-weight: 600;
|
| 24 |
}
|
| 25 |
|
| 26 |
::deep a.nav-link-item.active i {
|
| 27 |
-
color: #
|
| 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: #
|
| 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:
|
| 53 |
-
color: #
|
| 54 |
}
|
| 55 |
|
| 56 |
::deep button.nav-dropdown-toggle.expanded {
|
| 57 |
-
color: #
|
| 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
|
| 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="
|
| 51 |
-
<option value="
|
| 52 |
-
<option value="
|
| 53 |
-
<option value="
|
| 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="
|
| 58 |
-
<option value="
|
| 59 |
-
<option value="
|
| 60 |
-
<option value="
|
| 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 ==
|
| 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
|
| 173 |
-
private
|
| 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
|
| 264 |
{
|
| 265 |
-
result = result.Where(t => t.StatusId == filterStatusId);
|
| 266 |
}
|
| 267 |
-
if (filterPriorityId
|
| 268 |
{
|
| 269 |
-
result = result.Where(t => t.PriorityId == filterPriorityId);
|
| 270 |
}
|
| 271 |
return result;
|
| 272 |
}
|
| 273 |
}
|
| 274 |
|
| 275 |
-
private string GetStatusLabel(
|
| 276 |
{
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
_ => "Unknown"
|
| 281 |
};
|
| 282 |
|
| 283 |
-
private string GetStatusBadge(
|
| 284 |
{
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
_ => "bg-slate-50 text-slate-600"
|
| 289 |
};
|
| 290 |
|
| 291 |
-
private string GetPriorityLabel(
|
| 292 |
{
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
_ => "Unknown"
|
| 297 |
};
|
| 298 |
|
| 299 |
-
private string GetPriorityBadge(
|
| 300 |
{
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 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-
|
| 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-
|
| 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-
|
| 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 ==
|
| 228 |
<div class="min-w-0 flex-1">
|
| 229 |
-
<p class="text-xs font-semibold text-slate-800 truncate @(task.StatusId ==
|
| 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 ==
|
| 340 |
-
private IEnumerable<TaskDto> ChecklistTasks => taskList.Where(t => t.StatusId !=
|
| 341 |
-
private int ChecklistCompletedCount => taskList.Count(t => t.StatusId ==
|
| 342 |
|
| 343 |
private int TotalTasksCount => taskStatuses.Sum(s => s.TaskCount);
|
| 344 |
-
private int CompletedTasksCount => taskStatuses.FirstOrDefault(s => s.StatusId ==
|
| 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 !=
|
| 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(
|
| 354 |
{
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 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 ==
|
| 371 |
-
var maxCount = Math.Max(taskList.Count(t => t.StatusId ==
|
| 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
|
| 387 |
var client = ApiClient.CreateClient();
|
| 388 |
try
|
| 389 |
{
|
| 390 |
-
var response = await client.PatchAsync($"Task/{task.Id}/status?statusId={
|
| 391 |
if (response.IsSuccessStatusCode)
|
| 392 |
{
|
| 393 |
-
task.StatusId =
|
| 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}
|
| 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="
|
| 129 |
-
<option value="
|
| 130 |
-
<option value="
|
| 131 |
</select>
|
| 132 |
</div>
|
| 133 |
<div>
|
|
@@ -194,14 +194,14 @@
|
|
| 194 |
// Task form
|
| 195 |
private string taskTitle = "";
|
| 196 |
private string? taskDescription;
|
| 197 |
-
private
|
| 198 |
private DateTime taskDueDate = DateTime.Today.AddDays(7);
|
| 199 |
|
| 200 |
private List<KanbanColumn> columns = new()
|
| 201 |
{
|
| 202 |
-
new(
|
| 203 |
-
new(
|
| 204 |
-
new(
|
| 205 |
};
|
| 206 |
|
| 207 |
private TaskDto? draggedTask;
|
|
@@ -216,7 +216,7 @@
|
|
| 216 |
// Allow dropping
|
| 217 |
}
|
| 218 |
|
| 219 |
-
private async Task HandleDrop(
|
| 220 |
{
|
| 221 |
if (draggedTask != null && draggedTask.StatusId != newStatusId)
|
| 222 |
{
|
|
@@ -282,7 +282,7 @@
|
|
| 282 |
}
|
| 283 |
}
|
| 284 |
|
| 285 |
-
private async Task MoveTask(long taskId,
|
| 286 |
{
|
| 287 |
if (!canUpdateTask)
|
| 288 |
{
|
|
@@ -312,7 +312,7 @@
|
|
| 312 |
{
|
| 313 |
taskTitle = "";
|
| 314 |
taskDescription = null;
|
| 315 |
-
taskPriorityId =
|
| 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 =
|
| 378 |
PriorityId = taskPriorityId,
|
| 379 |
DueDate = taskDueDate
|
| 380 |
};
|
|
@@ -399,21 +399,21 @@
|
|
| 399 |
}
|
| 400 |
}
|
| 401 |
|
| 402 |
-
private string GetPriorityBadge(
|
| 403 |
{
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
_ => "bg-slate-100 text-slate-600"
|
| 408 |
};
|
| 409 |
|
| 410 |
-
private string GetPriorityLabel(
|
| 411 |
{
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
_ => "
|
| 416 |
};
|
| 417 |
|
| 418 |
-
private record KanbanColumn(
|
| 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 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 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 !=
|
| 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 !=
|
| 392 |
var overdueTasks = activeTasks.Where(t => t.DueDate < today).ToList();
|
| 393 |
overdueCount = overdueTasks.Count;
|
| 394 |
-
criticalCount = overdueTasks.Count(t => t.PriorityId ==
|
| 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 {
|
| 431 |
-
var status = task.StatusId switch {
|
| 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 {
|
| 481 |
-
var status = task.StatusId switch {
|
| 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(
|
| 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 |
-
|
| 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 |
-
<
|
| 203 |
-
|
| 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 |
-
<
|
| 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 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 258 |
{
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
<
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
<span class="text-sm font-medium text-slate-700">@child.MenuName</span>
|
| 270 |
-
</label>
|
| 271 |
-
|
| 272 |
-
@if (child.Permissions.Any())
|
| 273 |
{
|
| 274 |
-
<
|
| 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 |
-
|
| 293 |
-
|
| 294 |
-
|
| 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 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
<
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 327 |
-
<span> will be
|
| 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-
|
| 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 |
-
<
|
| 374 |
-
<
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 379 |
</div>
|
| 380 |
|
| 381 |
<!-- Menu Items Body -->
|
| 382 |
-
<div class="overflow-y-auto flex-1 px-
|
| 383 |
@if (isLoadingAccessItems)
|
| 384 |
{
|
| 385 |
<div class="flex items-center justify-center py-12">
|
|
@@ -398,84 +444,114 @@
|
|
| 398 |
}
|
| 399 |
else
|
| 400 |
{
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 422 |
{
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
<
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
<span class="text-sm font-medium text-slate-700">@child.MenuName</span>
|
| 434 |
-
</label>
|
| 435 |
-
|
| 436 |
-
@if (child.Permissions.Any())
|
| 437 |
{
|
| 438 |
-
<
|
| 439 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 440 |
{
|
| 441 |
var isActionChecked = selectedAccessCodes.Contains(action.PermissionCode);
|
| 442 |
-
<label class="inline-flex items-center gap-1.5 px-2.5 py-1
|
| 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
|
| 450 |
</label>
|
| 451 |
}
|
| 452 |
</div>
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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(
|
| 528 |
{
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
_ => "bg-slate-100 text-slate-600"
|
| 533 |
};
|
| 534 |
|
| 535 |
-
private string GetStatusLabel(
|
| 536 |
{
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
_ => "Unknown"
|
| 541 |
};
|
| 542 |
|
| 543 |
-
private string GetPriorityBadge(
|
| 544 |
{
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
_ => "bg-slate-100 text-slate-600"
|
| 549 |
};
|
| 550 |
|
| 551 |
-
private string GetPriorityLabel(
|
| 552 |
{
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 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="
|
| 146 |
-
<option value="
|
| 147 |
-
<option value="
|
| 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="
|
| 158 |
-
<option value="
|
| 159 |
-
<option value="
|
| 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)
|
| 322 |
</div>
|
| 323 |
</div>
|
| 324 |
<div class="flex items-center gap-1.5 shrink-0">
|
|
@@ -584,20 +584,20 @@
|
|
| 584 |
}
|
| 585 |
}
|
| 586 |
|
| 587 |
-
//
|
| 588 |
|
| 589 |
private async Task OnStatusChanged(ChangeEventArgs e)
|
| 590 |
{
|
| 591 |
if (task == null) return;
|
| 592 |
-
if (
|
| 593 |
{
|
| 594 |
var client = ApiClient.CreateClient();
|
| 595 |
try
|
| 596 |
{
|
| 597 |
-
var response = await client.PatchAsync($"Task/{task.Id}/status?statusId={
|
| 598 |
if (response.IsSuccessStatusCode)
|
| 599 |
{
|
| 600 |
-
task.StatusId =
|
| 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 (
|
| 615 |
{
|
| 616 |
-
await UpdateTaskProperty(dto => dto.PriorityId =
|
| 617 |
}
|
| 618 |
}
|
| 619 |
|
|
@@ -729,7 +729,7 @@
|
|
| 729 |
isSavingTitle = false;
|
| 730 |
}
|
| 731 |
|
| 732 |
-
//
|
| 733 |
|
| 734 |
private async Task PostComment()
|
| 735 |
{
|
|
@@ -791,7 +791,7 @@
|
|
| 791 |
}
|
| 792 |
}
|
| 793 |
|
| 794 |
-
//
|
| 795 |
|
| 796 |
private async Task OnInputFileChange(InputFileChangeEventArgs e)
|
| 797 |
{
|
|
@@ -859,7 +859,7 @@
|
|
| 859 |
}
|
| 860 |
}
|
| 861 |
|
| 862 |
-
//
|
| 863 |
|
| 864 |
private async Task ExecuteConfirmAction()
|
| 865 |
{
|
|
@@ -877,37 +877,37 @@
|
|
| 877 |
confirmAction = null;
|
| 878 |
}
|
| 879 |
|
| 880 |
-
//
|
| 881 |
|
| 882 |
-
private string GetStatusLabel(
|
| 883 |
{
|
| 884 |
-
|
| 885 |
-
|
| 886 |
-
|
| 887 |
_ => "Unknown"
|
| 888 |
};
|
| 889 |
|
| 890 |
-
private string GetStatusBadge(
|
| 891 |
{
|
| 892 |
-
|
| 893 |
-
|
| 894 |
-
|
| 895 |
_ => "bg-slate-50 text-slate-600"
|
| 896 |
};
|
| 897 |
|
| 898 |
-
private string GetPriorityLabel(
|
| 899 |
{
|
| 900 |
-
|
| 901 |
-
|
| 902 |
-
|
| 903 |
_ => "Unknown"
|
| 904 |
};
|
| 905 |
|
| 906 |
-
private string GetPriorityBadge(
|
| 907 |
{
|
| 908 |
-
|
| 909 |
-
|
| 910 |
-
|
| 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="#
|
| 171 |
<!-- To Do Bar -->
|
| 172 |
-
<rect x="60" y="-@hTodo" width="40" height="@hTodo" rx="4" fill="#
|
| 173 |
-
<text x="80" y="20" font-size="12" fill="#
|
| 174 |
-
<text x="80" y="-@(hTodo + 10)" font-size="12" font-weight="bold" fill="#
|
| 175 |
|
| 176 |
<!-- In Progress Bar -->
|
| 177 |
-
<rect x="140" y="-@hInProgress" width="40" height="@hInProgress" rx="4" fill="#
|
| 178 |
-
<text x="160" y="20" font-size="12" fill="#
|
| 179 |
-
<text x="160" y="-@(hInProgress + 10)" font-size="12" font-weight="bold" fill="#
|
| 180 |
|
| 181 |
<!-- Done Bar -->
|
| 182 |
-
<rect x="220" y="-@hDone" width="40" height="@hDone" rx="4" fill="#
|
| 183 |
-
<text x="240" y="20" font-size="12" fill="#
|
| 184 |
-
<text x="240" y="-@(hDone + 10)" font-size="12" font-weight="bold" fill="#
|
| 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-
|
| 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-
|
| 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-
|
| 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="#
|
| 291 |
-
<path d="M80 85L90 85M85 80L85 90" stroke="#
|
| 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>
|