Spaces:
Running
Running
User commited on
Commit ·
1201c4d
1
Parent(s): 7781da8
fix: Notification auto delete in 30 days, dashboard fixes
Browse files- TaskTrackingSystem.WebApi/Features/Notification/NotificationCleanupHostedService.cs +58 -0
- TaskTrackingSystem.WebApi/Features/Notification/NotificationController.cs +13 -0
- TaskTrackingSystem.WebApi/Features/Notification/NotificationService.cs +13 -0
- TaskTrackingSystem.WebApi/Program.cs +1 -0
- TaskTrackingSystem.WebApp/Components/App.razor +14 -3
- TaskTrackingSystem.WebApp/Components/Pages/EmployeeDashboard.razor +3 -6
- TaskTrackingSystem.WebApp/Components/Pages/Home.razor +179 -52
- TaskTrackingSystem.WebApp/Components/Pages/ManagerDashboard.razor +3 -6
- TaskTrackingSystem.WebApp/Components/Pages/Tasks.razor +232 -8
- TaskTrackingSystem.WebApp/Components/Partial/NotificationBell.razor +54 -0
- TaskTrackingSystem.WebApp/wwwroot/app.css +45 -0
TaskTrackingSystem.WebApi/Features/Notification/NotificationCleanupHostedService.cs
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using Microsoft.EntityFrameworkCore;
|
| 2 |
+
using Microsoft.Extensions.DependencyInjection;
|
| 3 |
+
using TaskTrackingSystem.Database.AppDbContextModels;
|
| 4 |
+
|
| 5 |
+
namespace TaskTrackingSystem.WebApi.Features.Notification;
|
| 6 |
+
|
| 7 |
+
public class NotificationCleanupHostedService : BackgroundService
|
| 8 |
+
{
|
| 9 |
+
private static readonly TimeSpan RunInterval = TimeSpan.FromHours(24);
|
| 10 |
+
private static readonly TimeSpan RetentionWindow = TimeSpan.FromDays(30);
|
| 11 |
+
|
| 12 |
+
private readonly IServiceScopeFactory _scopeFactory;
|
| 13 |
+
private readonly ILogger<NotificationCleanupHostedService> _logger;
|
| 14 |
+
|
| 15 |
+
public NotificationCleanupHostedService(IServiceScopeFactory scopeFactory, ILogger<NotificationCleanupHostedService> logger)
|
| 16 |
+
{
|
| 17 |
+
_scopeFactory = scopeFactory;
|
| 18 |
+
_logger = logger;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
protected override async System.Threading.Tasks.Task ExecuteAsync(CancellationToken stoppingToken)
|
| 22 |
+
{
|
| 23 |
+
using var timer = new PeriodicTimer(RunInterval);
|
| 24 |
+
|
| 25 |
+
await CleanupOnceAsync(stoppingToken);
|
| 26 |
+
|
| 27 |
+
while (await timer.WaitForNextTickAsync(stoppingToken))
|
| 28 |
+
{
|
| 29 |
+
await CleanupOnceAsync(stoppingToken);
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
private async System.Threading.Tasks.Task CleanupOnceAsync(CancellationToken cancellationToken)
|
| 34 |
+
{
|
| 35 |
+
try
|
| 36 |
+
{
|
| 37 |
+
using var scope = _scopeFactory.CreateScope();
|
| 38 |
+
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
| 39 |
+
var cutoffUtc = DateTime.UtcNow.Subtract(RetentionWindow);
|
| 40 |
+
|
| 41 |
+
var deletedCount = await db.Notifications
|
| 42 |
+
.Where(n => n.IsRead && (!n.CreatedAt.HasValue || n.CreatedAt < cutoffUtc))
|
| 43 |
+
.ExecuteDeleteAsync(cancellationToken);
|
| 44 |
+
|
| 45 |
+
if (deletedCount > 0)
|
| 46 |
+
{
|
| 47 |
+
_logger.LogInformation("Notification cleanup removed {DeletedCount} read notifications older than {CutoffUtc}.", deletedCount, cutoffUtc);
|
| 48 |
+
}
|
| 49 |
+
}
|
| 50 |
+
catch (OperationCanceledException)
|
| 51 |
+
{
|
| 52 |
+
}
|
| 53 |
+
catch (Exception ex)
|
| 54 |
+
{
|
| 55 |
+
_logger.LogError(ex, "Notification cleanup failed.");
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
}
|
TaskTrackingSystem.WebApi/Features/Notification/NotificationController.cs
CHANGED
|
@@ -55,4 +55,17 @@ public class NotificationController : ControllerBase
|
|
| 55 |
var result = await _notificationService.MarkReadAsync(userId, id);
|
| 56 |
return StatusCode(result.StatusCode, result);
|
| 57 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
}
|
|
|
|
| 55 |
var result = await _notificationService.MarkReadAsync(userId, id);
|
| 56 |
return StatusCode(result.StatusCode, result);
|
| 57 |
}
|
| 58 |
+
|
| 59 |
+
[HttpDelete("read")]
|
| 60 |
+
public async Task<ActionResult<Result<int>>> DeleteRead([FromQuery] DateTime? before = null)
|
| 61 |
+
{
|
| 62 |
+
var userId = User.GetUserId();
|
| 63 |
+
if (userId <= 0)
|
| 64 |
+
{
|
| 65 |
+
return Unauthorized(Result.Failure("User is not authenticated.", 401));
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
var deletedCount = await _notificationService.DeleteReadNotificationsAsync(userId, before);
|
| 69 |
+
return Ok(Result<int>.Success(deletedCount));
|
| 70 |
+
}
|
| 71 |
}
|
TaskTrackingSystem.WebApi/Features/Notification/NotificationService.cs
CHANGED
|
@@ -76,4 +76,17 @@ public class NotificationService
|
|
| 76 |
|
| 77 |
return Result.Success();
|
| 78 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
}
|
|
|
|
| 76 |
|
| 77 |
return Result.Success();
|
| 78 |
}
|
| 79 |
+
|
| 80 |
+
public async Task<int> DeleteReadNotificationsAsync(long userId, DateTime? olderThanUtc = null)
|
| 81 |
+
{
|
| 82 |
+
var query = _db.Notifications.Where(n => n.RecipientId == userId && n.IsRead);
|
| 83 |
+
|
| 84 |
+
if (olderThanUtc.HasValue)
|
| 85 |
+
{
|
| 86 |
+
var cutoff = olderThanUtc.Value;
|
| 87 |
+
query = query.Where(n => !n.CreatedAt.HasValue || n.CreatedAt < cutoff);
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
return await query.ExecuteDeleteAsync();
|
| 91 |
+
}
|
| 92 |
}
|
TaskTrackingSystem.WebApi/Program.cs
CHANGED
|
@@ -40,6 +40,7 @@ builder.Services.AddScoped<TaskTrackingSystem.WebApi.Features.UserDevice.UserDev
|
|
| 40 |
builder.Services.AddScoped<TaskTrackingSystem.WebApi.Features.Notification.FirebaseNotificationService>();
|
| 41 |
builder.Services.AddScoped<TaskTrackingSystem.WebApi.Features.Notification.NotificationRealtimeService>();
|
| 42 |
builder.Services.AddScoped<TaskTrackingSystem.WebApi.Features.Notification.NotificationService>();
|
|
|
|
| 43 |
builder.Services.AddScoped<TaskTrackingSystem.WebApi.Infrastructure.PermissionAuthorizationService>();
|
| 44 |
builder.Services.AddScoped<IPasswordHasher<TaskTrackingSystem.Database.AppDbContextModels.User>, PasswordHasher<TaskTrackingSystem.Database.AppDbContextModels.User>>();
|
| 45 |
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
|
|
|
|
| 40 |
builder.Services.AddScoped<TaskTrackingSystem.WebApi.Features.Notification.FirebaseNotificationService>();
|
| 41 |
builder.Services.AddScoped<TaskTrackingSystem.WebApi.Features.Notification.NotificationRealtimeService>();
|
| 42 |
builder.Services.AddScoped<TaskTrackingSystem.WebApi.Features.Notification.NotificationService>();
|
| 43 |
+
builder.Services.AddHostedService<TaskTrackingSystem.WebApi.Features.Notification.NotificationCleanupHostedService>();
|
| 44 |
builder.Services.AddScoped<TaskTrackingSystem.WebApi.Infrastructure.PermissionAuthorizationService>();
|
| 45 |
builder.Services.AddScoped<IPasswordHasher<TaskTrackingSystem.Database.AppDbContextModels.User>, PasswordHasher<TaskTrackingSystem.Database.AppDbContextModels.User>>();
|
| 46 |
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
|
TaskTrackingSystem.WebApp/Components/App.razor
CHANGED
|
@@ -345,15 +345,22 @@
|
|
| 345 |
});
|
| 346 |
};
|
| 347 |
|
| 348 |
-
window.renderGanttChart = function (containerId, data) {
|
|
|
|
| 349 |
const container = document.getElementById(containerId);
|
| 350 |
if (!container) return;
|
|
|
|
|
|
|
| 351 |
Highcharts.ganttChart(containerId, {
|
| 352 |
chart: {
|
| 353 |
backgroundColor: 'transparent',
|
| 354 |
-
height:
|
| 355 |
style: {
|
| 356 |
fontFamily: 'Inter, sans-serif'
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
}
|
| 358 |
},
|
| 359 |
title: {
|
|
@@ -366,7 +373,7 @@
|
|
| 366 |
enabled: false
|
| 367 |
},
|
| 368 |
scrollbar: {
|
| 369 |
-
enabled:
|
| 370 |
},
|
| 371 |
rangeSelector: {
|
| 372 |
enabled: false
|
|
@@ -394,11 +401,15 @@
|
|
| 394 |
}
|
| 395 |
},
|
| 396 |
xAxis: [{
|
|
|
|
|
|
|
|
|
|
| 397 |
gridLineWidth: 1,
|
| 398 |
gridLineColor: '#f1f5f9',
|
| 399 |
lineColor: '#e2e8f0',
|
| 400 |
tickColor: '#e2e8f0',
|
| 401 |
labels: {
|
|
|
|
| 402 |
style: {
|
| 403 |
color: '#64748b',
|
| 404 |
fontSize: '11px'
|
|
|
|
| 345 |
});
|
| 346 |
};
|
| 347 |
|
| 348 |
+
window.renderGanttChart = function (containerId, data, options) {
|
| 349 |
+
options = options || {};
|
| 350 |
const container = document.getElementById(containerId);
|
| 351 |
if (!container) return;
|
| 352 |
+
const minWidth = options.minWidth || 1200;
|
| 353 |
+
const chartHeight = Math.max(options.height || 420, 120 + (data.length * 44));
|
| 354 |
Highcharts.ganttChart(containerId, {
|
| 355 |
chart: {
|
| 356 |
backgroundColor: 'transparent',
|
| 357 |
+
height: chartHeight,
|
| 358 |
style: {
|
| 359 |
fontFamily: 'Inter, sans-serif'
|
| 360 |
+
},
|
| 361 |
+
scrollablePlotArea: {
|
| 362 |
+
minWidth: minWidth,
|
| 363 |
+
scrollPositionX: 0
|
| 364 |
}
|
| 365 |
},
|
| 366 |
title: {
|
|
|
|
| 373 |
enabled: false
|
| 374 |
},
|
| 375 |
scrollbar: {
|
| 376 |
+
enabled: true
|
| 377 |
},
|
| 378 |
rangeSelector: {
|
| 379 |
enabled: false
|
|
|
|
| 401 |
}
|
| 402 |
},
|
| 403 |
xAxis: [{
|
| 404 |
+
type: 'datetime',
|
| 405 |
+
min: options.xAxisMin,
|
| 406 |
+
max: options.xAxisMax,
|
| 407 |
gridLineWidth: 1,
|
| 408 |
gridLineColor: '#f1f5f9',
|
| 409 |
lineColor: '#e2e8f0',
|
| 410 |
tickColor: '#e2e8f0',
|
| 411 |
labels: {
|
| 412 |
+
format: '{value:%b %Y}',
|
| 413 |
style: {
|
| 414 |
color: '#64748b',
|
| 415 |
fontSize: '11px'
|
TaskTrackingSystem.WebApp/Components/Pages/EmployeeDashboard.razor
CHANGED
|
@@ -87,7 +87,7 @@
|
|
| 87 |
<span class="rounded-full bg-slate-100 px-3 py-1 text-sm font-semibold text-slate-600">@InProgressTasks.Count</span>
|
| 88 |
</div>
|
| 89 |
|
| 90 |
-
<div class="divide-y divide-slate-100">
|
| 91 |
@if (InProgressTasks.Any())
|
| 92 |
{
|
| 93 |
@foreach (var task in InProgressTasks)
|
|
@@ -126,7 +126,7 @@
|
|
| 126 |
<span class="rounded-full bg-slate-100 px-3 py-1 text-sm font-semibold text-slate-600">@TodoUpcomingTasks.Count</span>
|
| 127 |
</div>
|
| 128 |
|
| 129 |
-
<div class="divide-y divide-slate-100">
|
| 130 |
@if (TodoUpcomingTasks.Any())
|
| 131 |
{
|
| 132 |
@foreach (var task in TodoUpcomingTasks)
|
|
@@ -165,7 +165,7 @@
|
|
| 165 |
<span class="rounded-full bg-rose-50 px-3 py-1 text-sm font-semibold text-rose-600">@OverdueTasks.Count</span>
|
| 166 |
</div>
|
| 167 |
|
| 168 |
-
<div class="divide-y divide-rose-100">
|
| 169 |
@if (OverdueTasks.Any())
|
| 170 |
{
|
| 171 |
@foreach (var task in OverdueTasks)
|
|
@@ -311,19 +311,16 @@
|
|
| 311 |
private List<TaskDto> InProgressTasks => tasks
|
| 312 |
.Where(task => task.StatusId == AppTaskStatus.InProgress)
|
| 313 |
.OrderBy(task => task.DueDate)
|
| 314 |
-
.Take(3)
|
| 315 |
.ToList();
|
| 316 |
|
| 317 |
private List<TaskDto> TodoUpcomingTasks => tasks
|
| 318 |
.Where(task => task.StatusId == AppTaskStatus.Todo || (task.StatusId != AppTaskStatus.Done && task.DueDate.Date >= DateTime.Today))
|
| 319 |
.OrderBy(task => task.DueDate)
|
| 320 |
-
.Take(3)
|
| 321 |
.ToList();
|
| 322 |
|
| 323 |
private List<TaskDto> OverdueTasks => tasks
|
| 324 |
.Where(task => task.DueDate.Date < DateTime.Today && task.StatusId != AppTaskStatus.Done)
|
| 325 |
.OrderBy(task => task.DueDate)
|
| 326 |
-
.Take(3)
|
| 327 |
.ToList();
|
| 328 |
|
| 329 |
private List<SummaryMetricViewModel> SummaryMetrics => BuildSummaryMetrics();
|
|
|
|
| 87 |
<span class="rounded-full bg-slate-100 px-3 py-1 text-sm font-semibold text-slate-600">@InProgressTasks.Count</span>
|
| 88 |
</div>
|
| 89 |
|
| 90 |
+
<div class="max-h-[420px] divide-y divide-slate-100 overflow-y-auto">
|
| 91 |
@if (InProgressTasks.Any())
|
| 92 |
{
|
| 93 |
@foreach (var task in InProgressTasks)
|
|
|
|
| 126 |
<span class="rounded-full bg-slate-100 px-3 py-1 text-sm font-semibold text-slate-600">@TodoUpcomingTasks.Count</span>
|
| 127 |
</div>
|
| 128 |
|
| 129 |
+
<div class="max-h-[420px] divide-y divide-slate-100 overflow-y-auto">
|
| 130 |
@if (TodoUpcomingTasks.Any())
|
| 131 |
{
|
| 132 |
@foreach (var task in TodoUpcomingTasks)
|
|
|
|
| 165 |
<span class="rounded-full bg-rose-50 px-3 py-1 text-sm font-semibold text-rose-600">@OverdueTasks.Count</span>
|
| 166 |
</div>
|
| 167 |
|
| 168 |
+
<div class="max-h-[320px] divide-y divide-rose-100 overflow-y-auto">
|
| 169 |
@if (OverdueTasks.Any())
|
| 170 |
{
|
| 171 |
@foreach (var task in OverdueTasks)
|
|
|
|
| 311 |
private List<TaskDto> InProgressTasks => tasks
|
| 312 |
.Where(task => task.StatusId == AppTaskStatus.InProgress)
|
| 313 |
.OrderBy(task => task.DueDate)
|
|
|
|
| 314 |
.ToList();
|
| 315 |
|
| 316 |
private List<TaskDto> TodoUpcomingTasks => tasks
|
| 317 |
.Where(task => task.StatusId == AppTaskStatus.Todo || (task.StatusId != AppTaskStatus.Done && task.DueDate.Date >= DateTime.Today))
|
| 318 |
.OrderBy(task => task.DueDate)
|
|
|
|
| 319 |
.ToList();
|
| 320 |
|
| 321 |
private List<TaskDto> OverdueTasks => tasks
|
| 322 |
.Where(task => task.DueDate.Date < DateTime.Today && task.StatusId != AppTaskStatus.Done)
|
| 323 |
.OrderBy(task => task.DueDate)
|
|
|
|
| 324 |
.ToList();
|
| 325 |
|
| 326 |
private List<SummaryMetricViewModel> SummaryMetrics => BuildSummaryMetrics();
|
TaskTrackingSystem.WebApp/Components/Pages/Home.razor
CHANGED
|
@@ -183,22 +183,41 @@
|
|
| 183 |
|
| 184 |
<div class="grid grid-cols-1 gap-5 xl:grid-cols-[minmax(0,1.25fr)_minmax(340px,0.75fr)]">
|
| 185 |
<div class="rounded-[28px] border border-slate-200 bg-white p-6 shadow-sm shadow-slate-200/60">
|
| 186 |
-
<
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
</div>
|
| 191 |
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
</div>
|
| 203 |
|
| 204 |
<div class="rounded-[28px] border border-slate-200 bg-white p-6 text-slate-900 shadow-sm shadow-slate-200/60">
|
|
@@ -236,7 +255,7 @@
|
|
| 236 |
}
|
| 237 |
</div>
|
| 238 |
|
| 239 |
-
<div class="mt-6 space-y-3">
|
| 240 |
<div class="text-xs font-semibold uppercase tracking-wider text-slate-400">Events</div>
|
| 241 |
@foreach (var item in UpcomingProjectStarts)
|
| 242 |
{
|
|
@@ -252,42 +271,47 @@
|
|
| 252 |
</div>
|
| 253 |
</div>
|
| 254 |
|
| 255 |
-
<div class="rounded-[28px] border border-slate-200 bg-white p-
|
| 256 |
-
<div class="flex items-
|
| 257 |
<div>
|
| 258 |
-
<h3 class="text-2xl font-bold text-slate-900">
|
| 259 |
-
<p class="mt-1 text-sm text-slate-500">
|
| 260 |
</div>
|
| 261 |
<a href="/projects/all" class="inline-flex items-center rounded-2xl border border-slate-200 bg-white px-4 py-2.5 text-sm font-semibold text-violet-700 shadow-sm transition-colors hover:bg-violet-50">
|
| 262 |
View all
|
| 263 |
</a>
|
| 264 |
</div>
|
| 265 |
|
| 266 |
-
<div class="mt-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
<div class="mt-3 h-2 overflow-hidden rounded-full bg-slate-200">
|
| 286 |
-
<div class="h-full rounded-full bg-violet-600" style="width: @progress.CompletionPercentage%"></div>
|
| 287 |
-
</div>
|
| 288 |
-
</div>
|
| 289 |
-
}
|
| 290 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
</div>
|
| 292 |
</div>
|
| 293 |
|
|
@@ -301,6 +325,8 @@
|
|
| 301 |
private string currentUserName = "User";
|
| 302 |
private string currentUserFullName = "User";
|
| 303 |
private bool canCreateTask = false;
|
|
|
|
|
|
|
| 304 |
|
| 305 |
private bool _dataLoaded = false;
|
| 306 |
private bool _shouldRenderCharts = false;
|
|
@@ -329,13 +355,11 @@
|
|
| 329 |
private string CalendarMonthLabel => DateTime.Today.ToString("MMMM yyyy");
|
| 330 |
private List<CalendarCell> CalendarCells => BuildCalendarCells();
|
| 331 |
private List<CalendarEventItem> UpcomingProjectStarts => BuildUpcomingProjectStarts();
|
| 332 |
-
private List<ProjectDto> FeaturedProjects =>
|
| 333 |
-
private List<
|
|
|
|
| 334 |
private List<DateTime> TimelineMonths => BuildTimelineMonths();
|
| 335 |
-
private string TimelineRangeLabel =>
|
| 336 |
-
private string TimelineColumnsStyle => $"grid-template-columns: 180px repeat({TimelineMonths.Count}, minmax(44px, 1fr));";
|
| 337 |
-
private string TimelineRowSpanStyle => $"grid-column: 2 / span {TimelineMonths.Count};";
|
| 338 |
-
private string TodayMarkerLeft => GetTimelineTodayMarkerLeft();
|
| 339 |
|
| 340 |
protected override async Task OnInitializedAsync()
|
| 341 |
{
|
|
@@ -374,6 +398,7 @@
|
|
| 374 |
taskList = await client.GetFromJsonAsync<List<TaskDto>>("Task", Serialization.CaseInsensitive) ?? new();
|
| 375 |
usersList = await client.GetFromJsonAsync<List<UserDto>>("User", Serialization.CaseInsensitive) ?? new();
|
| 376 |
projectsList = await client.GetFromJsonAsync<List<ProjectDto>>("Project", Serialization.CaseInsensitive) ?? new();
|
|
|
|
| 377 |
|
| 378 |
currentUserFullName = ResolveCurrentUserFullName();
|
| 379 |
_dataLoaded = true;
|
|
@@ -413,7 +438,7 @@
|
|
| 413 |
new { name = "Overdue", y = overdueCount, color = "#ef4444" }
|
| 414 |
};
|
| 415 |
|
| 416 |
-
var ganttData =
|
| 417 |
var progress = GetProjectProgress(p.Id);
|
| 418 |
return new {
|
| 419 |
id = p.Id.ToString(),
|
|
@@ -426,7 +451,16 @@
|
|
| 426 |
}).ToList();
|
| 427 |
|
| 428 |
await JS.InvokeVoidAsync("renderPieChart", "task-distribution-chart", pieData);
|
| 429 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 430 |
}
|
| 431 |
catch (Exception ex)
|
| 432 |
{
|
|
@@ -656,7 +690,6 @@
|
|
| 656 |
return projectsList
|
| 657 |
.Where(project => project.StartDate.Date >= DateTime.Today)
|
| 658 |
.OrderBy(project => project.StartDate)
|
| 659 |
-
.Take(4)
|
| 660 |
.Select(project => new CalendarEventItem(project.Name, project.StartDate.ToString("MMM d"), GetProjectColor(project.Id)))
|
| 661 |
.ToList();
|
| 662 |
}
|
|
@@ -735,6 +768,100 @@
|
|
| 735 |
return $"{((monthIndex + dayFraction) / n * 100):0.##}%";
|
| 736 |
}
|
| 737 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 738 |
private ProjectProgressDto GetProjectProgress(long projectId)
|
| 739 |
{
|
| 740 |
return projectProgress.FirstOrDefault(item => item.ProjectId == projectId)
|
|
|
|
| 183 |
|
| 184 |
<div class="grid grid-cols-1 gap-5 xl:grid-cols-[minmax(0,1.25fr)_minmax(340px,0.75fr)]">
|
| 185 |
<div class="rounded-[28px] border border-slate-200 bg-white p-6 shadow-sm shadow-slate-200/60">
|
| 186 |
+
<div class="flex items-center justify-between gap-4">
|
| 187 |
+
<div>
|
| 188 |
+
<h3 class="text-2xl font-bold text-slate-900">All Projects</h3>
|
| 189 |
+
<p class="mt-1 text-sm text-slate-500">A quick snapshot of the current project list.</p>
|
| 190 |
+
</div>
|
| 191 |
+
<a href="/projects/all" class="inline-flex items-center rounded-2xl border border-slate-200 bg-white px-4 py-2.5 text-sm font-semibold text-violet-700 shadow-sm transition-colors hover:bg-violet-50">
|
| 192 |
+
View all
|
| 193 |
+
</a>
|
| 194 |
</div>
|
| 195 |
|
| 196 |
+
<div class="mt-5 max-h-[560px] space-y-3 overflow-y-auto pr-1">
|
| 197 |
+
@foreach (var project in ProjectListItems)
|
| 198 |
+
{
|
| 199 |
+
var progress = GetProjectProgress(project.Id);
|
| 200 |
+
<div class="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-4">
|
| 201 |
+
<div class="flex items-start justify-between gap-4">
|
| 202 |
+
<div class="min-w-0">
|
| 203 |
+
<div class="flex flex-wrap items-center gap-2">
|
| 204 |
+
<p class="truncate text-sm font-semibold text-slate-900">@project.Name</p>
|
| 205 |
+
<span class="@GetProjectBadgeClasses(project)">@GetProjectBadgeLabel(project)</span>
|
| 206 |
+
</div>
|
| 207 |
+
<p class="mt-1 text-xs text-slate-500">@DisplayFormats.Date(project.StartDate) to @DisplayFormats.Date(project.EndDate)</p>
|
| 208 |
+
</div>
|
| 209 |
+
<div class="text-right">
|
| 210 |
+
<div class="text-sm font-semibold text-slate-900">@progress.CompletedTasksCount/@progress.TotalTasksCount tasks</div>
|
| 211 |
+
<div class="text-xs text-slate-500">@progress.CompletionPercentage%</div>
|
| 212 |
+
</div>
|
| 213 |
+
</div>
|
| 214 |
+
|
| 215 |
+
<div class="mt-3 h-2 overflow-hidden rounded-full bg-slate-200">
|
| 216 |
+
<div class="h-full rounded-full bg-violet-600" style="width: @progress.CompletionPercentage%"></div>
|
| 217 |
+
</div>
|
| 218 |
+
</div>
|
| 219 |
+
}
|
| 220 |
+
</div>
|
| 221 |
</div>
|
| 222 |
|
| 223 |
<div class="rounded-[28px] border border-slate-200 bg-white p-6 text-slate-900 shadow-sm shadow-slate-200/60">
|
|
|
|
| 255 |
}
|
| 256 |
</div>
|
| 257 |
|
| 258 |
+
<div class="mt-6 max-h-[360px] space-y-3 overflow-y-auto pr-1">
|
| 259 |
<div class="text-xs font-semibold uppercase tracking-wider text-slate-400">Events</div>
|
| 260 |
@foreach (var item in UpcomingProjectStarts)
|
| 261 |
{
|
|
|
|
| 271 |
</div>
|
| 272 |
</div>
|
| 273 |
|
| 274 |
+
<div class="rounded-[28px] border border-slate-200 bg-white p-5 text-slate-900 shadow-sm shadow-slate-200/60">
|
| 275 |
+
<div class="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
| 276 |
<div>
|
| 277 |
+
<h3 class="text-2xl font-bold text-slate-900">Project Timeline</h3>
|
| 278 |
+
<p class="mt-1 text-sm text-slate-500">Browse every project in a gantt view and adjust the month window below.</p>
|
| 279 |
</div>
|
| 280 |
<a href="/projects/all" class="inline-flex items-center rounded-2xl border border-slate-200 bg-white px-4 py-2.5 text-sm font-semibold text-violet-700 shadow-sm transition-colors hover:bg-violet-50">
|
| 281 |
View all
|
| 282 |
</a>
|
| 283 |
</div>
|
| 284 |
|
| 285 |
+
<div class="mt-4 flex flex-wrap items-end gap-3 rounded-3xl border border-slate-200 bg-slate-50 px-3 py-3">
|
| 286 |
+
<div class="flex flex-col gap-1">
|
| 287 |
+
<label class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">From</label>
|
| 288 |
+
<input type="month"
|
| 289 |
+
class="rounded-2xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm outline-none transition-colors focus:border-violet-400 focus:ring-2 focus:ring-violet-100"
|
| 290 |
+
value="@timelineStartMonthValue"
|
| 291 |
+
@onchange="OnTimelineStartMonthChanged" />
|
| 292 |
+
</div>
|
| 293 |
+
<div class="flex flex-col gap-1">
|
| 294 |
+
<label class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">To</label>
|
| 295 |
+
<input type="month"
|
| 296 |
+
class="rounded-2xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm outline-none transition-colors focus:border-violet-400 focus:ring-2 focus:ring-violet-100"
|
| 297 |
+
value="@timelineEndMonthValue"
|
| 298 |
+
@onchange="OnTimelineEndMonthChanged" />
|
| 299 |
+
</div>
|
| 300 |
+
<div class="pb-1 text-sm font-medium text-slate-500">
|
| 301 |
+
@TimelineRangeLabel
|
| 302 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
</div>
|
| 304 |
+
|
| 305 |
+
@if (TimelineProjects.Count > 0)
|
| 306 |
+
{
|
| 307 |
+
<div class="mt-4 max-h-[620px] overflow-auto pr-1">
|
| 308 |
+
<div id="project-timeline-chart" class="min-w-[1100px] w-full"></div>
|
| 309 |
+
</div>
|
| 310 |
+
}
|
| 311 |
+
else
|
| 312 |
+
{
|
| 313 |
+
<div class="flex items-center justify-center py-16 text-slate-400 text-sm">No projects to display.</div>
|
| 314 |
+
}
|
| 315 |
</div>
|
| 316 |
</div>
|
| 317 |
|
|
|
|
| 325 |
private string currentUserName = "User";
|
| 326 |
private string currentUserFullName = "User";
|
| 327 |
private bool canCreateTask = false;
|
| 328 |
+
private string timelineStartMonthValue = string.Empty;
|
| 329 |
+
private string timelineEndMonthValue = string.Empty;
|
| 330 |
|
| 331 |
private bool _dataLoaded = false;
|
| 332 |
private bool _shouldRenderCharts = false;
|
|
|
|
| 355 |
private string CalendarMonthLabel => DateTime.Today.ToString("MMMM yyyy");
|
| 356 |
private List<CalendarCell> CalendarCells => BuildCalendarCells();
|
| 357 |
private List<CalendarEventItem> UpcomingProjectStarts => BuildUpcomingProjectStarts();
|
| 358 |
+
private List<ProjectDto> FeaturedProjects => TimelineProjects.Take(5).ToList();
|
| 359 |
+
private List<ProjectDto> ProjectListItems => projectsList.OrderBy(p => p.StartDate).ThenByDescending(p => p.CreatedAt).ToList();
|
| 360 |
+
private List<ProjectDto> TimelineProjects => projectsList.OrderBy(p => p.StartDate).ThenByDescending(p => p.CreatedAt).ToList();
|
| 361 |
private List<DateTime> TimelineMonths => BuildTimelineMonths();
|
| 362 |
+
private string TimelineRangeLabel => TryGetTimelineRange(out var range) ? $"{range.Start:MMM yyyy} - {range.End:MMM yyyy}" : string.Empty;
|
|
|
|
|
|
|
|
|
|
| 363 |
|
| 364 |
protected override async Task OnInitializedAsync()
|
| 365 |
{
|
|
|
|
| 398 |
taskList = await client.GetFromJsonAsync<List<TaskDto>>("Task", Serialization.CaseInsensitive) ?? new();
|
| 399 |
usersList = await client.GetFromJsonAsync<List<UserDto>>("User", Serialization.CaseInsensitive) ?? new();
|
| 400 |
projectsList = await client.GetFromJsonAsync<List<ProjectDto>>("Project", Serialization.CaseInsensitive) ?? new();
|
| 401 |
+
InitializeTimelineRange();
|
| 402 |
|
| 403 |
currentUserFullName = ResolveCurrentUserFullName();
|
| 404 |
_dataLoaded = true;
|
|
|
|
| 438 |
new { name = "Overdue", y = overdueCount, color = "#ef4444" }
|
| 439 |
};
|
| 440 |
|
| 441 |
+
var ganttData = TimelineProjects.Select(p => {
|
| 442 |
var progress = GetProjectProgress(p.Id);
|
| 443 |
return new {
|
| 444 |
id = p.Id.ToString(),
|
|
|
|
| 451 |
}).ToList();
|
| 452 |
|
| 453 |
await JS.InvokeVoidAsync("renderPieChart", "task-distribution-chart", pieData);
|
| 454 |
+
if (ganttData.Count > 0 && TryGetTimelineRange(out var timelineRange))
|
| 455 |
+
{
|
| 456 |
+
await JS.InvokeVoidAsync("renderGanttChart", "project-timeline-chart", ganttData, new
|
| 457 |
+
{
|
| 458 |
+
height = 420,
|
| 459 |
+
minWidth = Math.Max(900, GetTimelineMonthSpan(timelineRange.Start, timelineRange.End) * 110),
|
| 460 |
+
xAxisMin = ToUnixMilliseconds(timelineRange.Start),
|
| 461 |
+
xAxisMax = ToUnixMilliseconds(timelineRange.End)
|
| 462 |
+
});
|
| 463 |
+
}
|
| 464 |
}
|
| 465 |
catch (Exception ex)
|
| 466 |
{
|
|
|
|
| 690 |
return projectsList
|
| 691 |
.Where(project => project.StartDate.Date >= DateTime.Today)
|
| 692 |
.OrderBy(project => project.StartDate)
|
|
|
|
| 693 |
.Select(project => new CalendarEventItem(project.Name, project.StartDate.ToString("MMM d"), GetProjectColor(project.Id)))
|
| 694 |
.ToList();
|
| 695 |
}
|
|
|
|
| 768 |
return $"{((monthIndex + dayFraction) / n * 100):0.##}%";
|
| 769 |
}
|
| 770 |
|
| 771 |
+
private void InitializeTimelineRange()
|
| 772 |
+
{
|
| 773 |
+
if (TimelineProjects.Count == 0)
|
| 774 |
+
{
|
| 775 |
+
var currentMonth = new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1);
|
| 776 |
+
timelineStartMonthValue = currentMonth.ToString("yyyy-MM");
|
| 777 |
+
timelineEndMonthValue = currentMonth.ToString("yyyy-MM");
|
| 778 |
+
return;
|
| 779 |
+
}
|
| 780 |
+
|
| 781 |
+
var start = new DateTime(TimelineProjects.Min(p => p.StartDate).Year, TimelineProjects.Min(p => p.StartDate).Month, 1);
|
| 782 |
+
var end = new DateTime(TimelineProjects.Max(p => p.EndDate).Year, TimelineProjects.Max(p => p.EndDate).Month, 1);
|
| 783 |
+
timelineStartMonthValue = start.ToString("yyyy-MM");
|
| 784 |
+
timelineEndMonthValue = end.ToString("yyyy-MM");
|
| 785 |
+
}
|
| 786 |
+
|
| 787 |
+
private Task OnTimelineStartMonthChanged(ChangeEventArgs args)
|
| 788 |
+
{
|
| 789 |
+
timelineStartMonthValue = args.Value?.ToString() ?? timelineStartMonthValue;
|
| 790 |
+
NormalizeTimelineRange();
|
| 791 |
+
_shouldRenderCharts = true;
|
| 792 |
+
StateHasChanged();
|
| 793 |
+
return Task.CompletedTask;
|
| 794 |
+
}
|
| 795 |
+
|
| 796 |
+
private Task OnTimelineEndMonthChanged(ChangeEventArgs args)
|
| 797 |
+
{
|
| 798 |
+
timelineEndMonthValue = args.Value?.ToString() ?? timelineEndMonthValue;
|
| 799 |
+
NormalizeTimelineRange();
|
| 800 |
+
_shouldRenderCharts = true;
|
| 801 |
+
StateHasChanged();
|
| 802 |
+
return Task.CompletedTask;
|
| 803 |
+
}
|
| 804 |
+
|
| 805 |
+
private void NormalizeTimelineRange()
|
| 806 |
+
{
|
| 807 |
+
if (!TryParseMonthValue(timelineStartMonthValue, out var start) ||
|
| 808 |
+
!TryParseMonthValue(timelineEndMonthValue, out var end))
|
| 809 |
+
{
|
| 810 |
+
return;
|
| 811 |
+
}
|
| 812 |
+
|
| 813 |
+
if (start > end)
|
| 814 |
+
{
|
| 815 |
+
(start, end) = (end, start);
|
| 816 |
+
}
|
| 817 |
+
|
| 818 |
+
timelineStartMonthValue = start.ToString("yyyy-MM");
|
| 819 |
+
timelineEndMonthValue = end.ToString("yyyy-MM");
|
| 820 |
+
}
|
| 821 |
+
|
| 822 |
+
private bool TryGetTimelineRange(out (DateTime Start, DateTime End) range)
|
| 823 |
+
{
|
| 824 |
+
if (!TryParseMonthValue(timelineStartMonthValue, out var start) ||
|
| 825 |
+
!TryParseMonthValue(timelineEndMonthValue, out var end))
|
| 826 |
+
{
|
| 827 |
+
range = default;
|
| 828 |
+
return false;
|
| 829 |
+
}
|
| 830 |
+
|
| 831 |
+
if (start > end)
|
| 832 |
+
{
|
| 833 |
+
(start, end) = (end, start);
|
| 834 |
+
}
|
| 835 |
+
|
| 836 |
+
var startOfMonth = new DateTime(start.Year, start.Month, 1);
|
| 837 |
+
var endOfMonth = new DateTime(end.Year, end.Month, DateTime.DaysInMonth(end.Year, end.Month));
|
| 838 |
+
range = (startOfMonth, endOfMonth);
|
| 839 |
+
return true;
|
| 840 |
+
}
|
| 841 |
+
|
| 842 |
+
private static bool TryParseMonthValue(string? value, out DateTime month)
|
| 843 |
+
{
|
| 844 |
+
month = default;
|
| 845 |
+
if (string.IsNullOrWhiteSpace(value))
|
| 846 |
+
{
|
| 847 |
+
return false;
|
| 848 |
+
}
|
| 849 |
+
|
| 850 |
+
return DateTime.TryParseExact($"{value}-01", "yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out month);
|
| 851 |
+
}
|
| 852 |
+
|
| 853 |
+
private static long ToUnixMilliseconds(DateTime value)
|
| 854 |
+
{
|
| 855 |
+
var utc = DateTime.SpecifyKind(value.Date, DateTimeKind.Utc);
|
| 856 |
+
return new DateTimeOffset(utc).ToUnixTimeMilliseconds();
|
| 857 |
+
}
|
| 858 |
+
|
| 859 |
+
private static int GetTimelineMonthSpan(DateTime start, DateTime end)
|
| 860 |
+
{
|
| 861 |
+
var span = ((end.Year - start.Year) * 12) + end.Month - start.Month + 1;
|
| 862 |
+
return Math.Max(span, 1);
|
| 863 |
+
}
|
| 864 |
+
|
| 865 |
private ProjectProgressDto GetProjectProgress(long projectId)
|
| 866 |
{
|
| 867 |
return projectProgress.FirstOrDefault(item => item.ProjectId == projectId)
|
TaskTrackingSystem.WebApp/Components/Pages/ManagerDashboard.razor
CHANGED
|
@@ -143,7 +143,7 @@
|
|
| 143 |
<h3 class="text-2xl font-bold text-slate-950">Team Progress</h3>
|
| 144 |
<p class="mt-1 text-sm text-slate-500">Members with active workload</p>
|
| 145 |
|
| 146 |
-
<div class="mt-6 space-y-4">
|
| 147 |
@if (TeamProgress.Any())
|
| 148 |
{
|
| 149 |
@foreach (var member in TeamProgress)
|
|
@@ -218,7 +218,7 @@
|
|
| 218 |
<h3 class="text-2xl font-bold text-slate-950">Upcoming Deadlines</h3>
|
| 219 |
<p class="mt-1 text-sm text-slate-500">Projects ending soon</p>
|
| 220 |
|
| 221 |
-
<div class="mt-6 space-y-3">
|
| 222 |
@if (UpcomingDeadlines.Any())
|
| 223 |
{
|
| 224 |
@foreach (var item in UpcomingDeadlines)
|
|
@@ -254,7 +254,7 @@
|
|
| 254 |
</a>
|
| 255 |
</div>
|
| 256 |
|
| 257 |
-
<div class="divide-y divide-slate-100">
|
| 258 |
@if (ProjectCards.Any())
|
| 259 |
{
|
| 260 |
@foreach (var project in ProjectCards)
|
|
@@ -454,7 +454,6 @@
|
|
| 454 |
return projects
|
| 455 |
.OrderByDescending(project => progressByProject.TryGetValue(project.Id, out var progress) ? progress.CompletionPercentage : 0)
|
| 456 |
.ThenBy(project => project.EndDate)
|
| 457 |
-
.Take(4)
|
| 458 |
.Select((project, index) =>
|
| 459 |
{
|
| 460 |
progressByProject.TryGetValue(project.Id, out var progress);
|
|
@@ -526,7 +525,6 @@
|
|
| 526 |
.Cast<TeamProgressViewModel>()
|
| 527 |
.OrderByDescending(item => item.Total)
|
| 528 |
.ThenBy(item => item.FullName)
|
| 529 |
-
.Take(5)
|
| 530 |
.ToList();
|
| 531 |
}
|
| 532 |
|
|
@@ -561,7 +559,6 @@
|
|
| 561 |
return projects
|
| 562 |
.Where(project => project.EndDate.Date >= DateTime.Today)
|
| 563 |
.OrderBy(project => project.EndDate)
|
| 564 |
-
.Take(5)
|
| 565 |
.Select(project =>
|
| 566 |
{
|
| 567 |
var daysLeft = (project.EndDate.Date - DateTime.Today).Days;
|
|
|
|
| 143 |
<h3 class="text-2xl font-bold text-slate-950">Team Progress</h3>
|
| 144 |
<p class="mt-1 text-sm text-slate-500">Members with active workload</p>
|
| 145 |
|
| 146 |
+
<div class="mt-6 max-h-[460px] space-y-4 overflow-y-auto pr-1">
|
| 147 |
@if (TeamProgress.Any())
|
| 148 |
{
|
| 149 |
@foreach (var member in TeamProgress)
|
|
|
|
| 218 |
<h3 class="text-2xl font-bold text-slate-950">Upcoming Deadlines</h3>
|
| 219 |
<p class="mt-1 text-sm text-slate-500">Projects ending soon</p>
|
| 220 |
|
| 221 |
+
<div class="mt-6 max-h-[460px] space-y-3 overflow-y-auto pr-1">
|
| 222 |
@if (UpcomingDeadlines.Any())
|
| 223 |
{
|
| 224 |
@foreach (var item in UpcomingDeadlines)
|
|
|
|
| 254 |
</a>
|
| 255 |
</div>
|
| 256 |
|
| 257 |
+
<div class="max-h-[760px] divide-y divide-slate-100 overflow-y-auto">
|
| 258 |
@if (ProjectCards.Any())
|
| 259 |
{
|
| 260 |
@foreach (var project in ProjectCards)
|
|
|
|
| 454 |
return projects
|
| 455 |
.OrderByDescending(project => progressByProject.TryGetValue(project.Id, out var progress) ? progress.CompletionPercentage : 0)
|
| 456 |
.ThenBy(project => project.EndDate)
|
|
|
|
| 457 |
.Select((project, index) =>
|
| 458 |
{
|
| 459 |
progressByProject.TryGetValue(project.Id, out var progress);
|
|
|
|
| 525 |
.Cast<TeamProgressViewModel>()
|
| 526 |
.OrderByDescending(item => item.Total)
|
| 527 |
.ThenBy(item => item.FullName)
|
|
|
|
| 528 |
.ToList();
|
| 529 |
}
|
| 530 |
|
|
|
|
| 559 |
return projects
|
| 560 |
.Where(project => project.EndDate.Date >= DateTime.Today)
|
| 561 |
.OrderBy(project => project.EndDate)
|
|
|
|
| 562 |
.Select(project =>
|
| 563 |
{
|
| 564 |
var daysLeft = (project.EndDate.Date - DateTime.Today).Days;
|
TaskTrackingSystem.WebApp/Components/Pages/Tasks.razor
CHANGED
|
@@ -211,7 +211,7 @@
|
|
| 211 |
<div class="grid grid-cols-2 gap-4">
|
| 212 |
<div>
|
| 213 |
<label class="block text-sm font-medium text-slate-700 mb-1.5">Project <span class="text-red-500">*</span></label>
|
| 214 |
-
<select
|
| 215 |
<option value="0">Select project...</option>
|
| 216 |
@foreach (var p in projects)
|
| 217 |
{
|
|
@@ -249,13 +249,81 @@
|
|
| 249 |
<div class="grid grid-cols-2 gap-4">
|
| 250 |
<div>
|
| 251 |
<label class="block text-sm font-medium text-slate-700 mb-1.5">Assignee</label>
|
| 252 |
-
<
|
| 253 |
-
<
|
| 254 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
{
|
| 256 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
}
|
| 258 |
-
</
|
| 259 |
</div>
|
| 260 |
<div>
|
| 261 |
<label class="block text-sm font-medium text-slate-700 mb-1.5">Estimated Hours</label>
|
|
@@ -315,6 +383,7 @@
|
|
| 315 |
private bool showModal = false;
|
| 316 |
private bool isEditing = false;
|
| 317 |
private bool isSaving = false;
|
|
|
|
| 318 |
private string? errorMessage;
|
| 319 |
|
| 320 |
// Per-field validation
|
|
@@ -336,6 +405,7 @@
|
|
| 336 |
private List<TaskDto> taskList = new();
|
| 337 |
private List<ProjectDto> projects = new();
|
| 338 |
private List<UserDto> allUsers = new();
|
|
|
|
| 339 |
|
| 340 |
private string? successMessage;
|
| 341 |
|
|
@@ -363,8 +433,10 @@
|
|
| 363 |
private TaskPriority formPriorityId = TaskPriority.Medium;
|
| 364 |
private DateTime formDueDate = DateTime.Today.AddDays(7);
|
| 365 |
private long? formAssignedTo;
|
|
|
|
| 366 |
private decimal? formEstimatedHours;
|
| 367 |
private decimal? formActualHours;
|
|
|
|
| 368 |
|
| 369 |
protected override async Task OnInitializedAsync()
|
| 370 |
{
|
|
@@ -530,7 +602,149 @@
|
|
| 530 |
canUpdateTask = canEditTask;
|
| 531 |
}
|
| 532 |
|
| 533 |
-
private
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 534 |
{
|
| 535 |
isEditing = false;
|
| 536 |
editingId = 0;
|
|
@@ -541,15 +755,18 @@
|
|
| 541 |
formPriorityId = TaskPriority.Medium;
|
| 542 |
formDueDate = DateTime.Today.AddDays(7);
|
| 543 |
formAssignedTo = null;
|
|
|
|
| 544 |
formEstimatedHours = null;
|
| 545 |
formActualHours = null;
|
| 546 |
errorMessage = null;
|
| 547 |
isTitleError = false; titleErrorMessage = "";
|
| 548 |
isProjectError = false; projectErrorMessage = "";
|
| 549 |
showModal = true;
|
|
|
|
|
|
|
| 550 |
}
|
| 551 |
|
| 552 |
-
private
|
| 553 |
{
|
| 554 |
isEditing = true;
|
| 555 |
editingId = task.Id;
|
|
@@ -560,12 +777,15 @@
|
|
| 560 |
formPriorityId = task.PriorityId;
|
| 561 |
formDueDate = task.DueDate;
|
| 562 |
formAssignedTo = task.AssignedTo;
|
|
|
|
| 563 |
formEstimatedHours = task.EstimatedHours;
|
| 564 |
formActualHours = task.ActualHours;
|
| 565 |
errorMessage = null;
|
| 566 |
isTitleError = false; titleErrorMessage = "";
|
| 567 |
isProjectError = false; projectErrorMessage = "";
|
| 568 |
showModal = true;
|
|
|
|
|
|
|
| 569 |
}
|
| 570 |
|
| 571 |
private void CloseModal()
|
|
@@ -574,6 +794,10 @@
|
|
| 574 |
errorMessage = null;
|
| 575 |
isTitleError = false; titleErrorMessage = "";
|
| 576 |
isProjectError = false; projectErrorMessage = "";
|
|
|
|
|
|
|
|
|
|
|
|
|
| 577 |
}
|
| 578 |
|
| 579 |
private void RequestSaveConfirmation()
|
|
|
|
| 211 |
<div class="grid grid-cols-2 gap-4">
|
| 212 |
<div>
|
| 213 |
<label class="block text-sm font-medium text-slate-700 mb-1.5">Project <span class="text-red-500">*</span></label>
|
| 214 |
+
<select value="@formProjectId" @onchange="OnFormProjectChanged" class="w-full rounded-lg border @(isProjectError ? "border-red-500 focus:ring-red-500" : "border-slate-200 focus:ring-slate-900") px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:border-transparent transition-all">
|
| 215 |
<option value="0">Select project...</option>
|
| 216 |
@foreach (var p in projects)
|
| 217 |
{
|
|
|
|
| 249 |
<div class="grid grid-cols-2 gap-4">
|
| 250 |
<div>
|
| 251 |
<label class="block text-sm font-medium text-slate-700 mb-1.5">Assignee</label>
|
| 252 |
+
<div class="relative">
|
| 253 |
+
<button type="button"
|
| 254 |
+
@onclick="ToggleAssigneeDropdown"
|
| 255 |
+
class="flex h-11 w-full items-center justify-between rounded-lg border border-slate-200 bg-white px-3 text-left text-sm text-slate-900 shadow-sm transition-colors hover:border-violet-300 hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-900">
|
| 256 |
+
<span class="truncate @(formAssignedTo.HasValue ? "font-medium text-slate-900" : "text-slate-400")">@SelectedAssigneeLabel</span>
|
| 257 |
+
<i data-lucide="chevron-down" class="ml-3 h-4 w-4 shrink-0 text-slate-400"></i>
|
| 258 |
+
</button>
|
| 259 |
+
|
| 260 |
+
@if (isAssigneeDropdownOpen)
|
| 261 |
{
|
| 262 |
+
<div class="absolute bottom-full left-0 z-30 mb-2 w-full rounded-2xl border border-slate-200 bg-white p-3 shadow-[0_18px_40px_rgba(15,23,42,0.16)]">
|
| 263 |
+
<div class="flex items-center justify-end">
|
| 264 |
+
<button type="button"
|
| 265 |
+
@onclick="CloseAssigneeDropdown"
|
| 266 |
+
class="inline-flex h-7 w-7 items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600"
|
| 267 |
+
aria-label="Close assignee dropdown">
|
| 268 |
+
<i data-lucide="x" class="h-4 w-4"></i>
|
| 269 |
+
</button>
|
| 270 |
+
</div>
|
| 271 |
+
<div class="relative">
|
| 272 |
+
<input @bind="assigneeSearchInput"
|
| 273 |
+
@onfocus="EnsureAssigneeDropdownOpen"
|
| 274 |
+
type="text"
|
| 275 |
+
placeholder="Search project members..."
|
| 276 |
+
class="w-full rounded-xl border border-slate-200 bg-white py-2 pl-9 pr-3 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent" />
|
| 277 |
+
<i data-lucide="search" class="absolute left-3 top-2.5 h-4 w-4 text-slate-400"></i>
|
| 278 |
+
</div>
|
| 279 |
+
|
| 280 |
+
@if (isLoadingProjectMembers)
|
| 281 |
+
{
|
| 282 |
+
<div class="mt-3 rounded-xl border border-dashed border-slate-200 bg-slate-50 px-3 py-4 text-center text-xs text-slate-500">
|
| 283 |
+
Loading project members...
|
| 284 |
+
</div>
|
| 285 |
+
}
|
| 286 |
+
else
|
| 287 |
+
{
|
| 288 |
+
<div class="mt-3 max-h-56 overflow-y-auto space-y-2 pr-1">
|
| 289 |
+
<button type="button"
|
| 290 |
+
@onclick="() => SetFormAssignee(null)"
|
| 291 |
+
class="flex w-full items-center gap-3 rounded-xl border px-3 py-3 text-left transition-colors @(formAssignedTo.HasValue ? "border-slate-200 bg-white hover:bg-slate-50" : "border-violet-600 bg-violet-50/40")">
|
| 292 |
+
<span class="flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-slate-500 text-xs font-bold shrink-0">
|
| 293 |
+
<i data-lucide="user-minus" class="h-4 w-4"></i>
|
| 294 |
+
</span>
|
| 295 |
+
<div class="min-w-0 flex-1">
|
| 296 |
+
<p class="text-sm font-semibold text-slate-900">Unassigned</p>
|
| 297 |
+
</div>
|
| 298 |
+
</button>
|
| 299 |
+
|
| 300 |
+
@foreach (var user in FilteredProjectMembers)
|
| 301 |
+
{
|
| 302 |
+
var isSelected = formAssignedTo == user.Id;
|
| 303 |
+
<button type="button"
|
| 304 |
+
@onclick="() => SetFormAssignee(user.Id)"
|
| 305 |
+
class="flex w-full items-center gap-3 rounded-xl border px-3 py-3 text-left transition-colors @(isSelected ? "border-violet-600 bg-violet-50/40" : "border-slate-100 bg-white hover:border-slate-200 hover:bg-slate-50")">
|
| 306 |
+
<span class="flex h-9 w-9 items-center justify-center rounded-full bg-violet-100 text-violet-700 text-xs font-bold shrink-0">
|
| 307 |
+
@(user.FirstName.Length > 0 ? user.FirstName[0].ToString().ToUpper() : "")@(user.LastName.Length > 0 ? user.LastName[0].ToString().ToUpper() : "")
|
| 308 |
+
</span>
|
| 309 |
+
<div class="min-w-0 flex-1">
|
| 310 |
+
<p class="truncate text-sm font-semibold text-slate-900">@user.FirstName @user.LastName</p>
|
| 311 |
+
<p class="truncate text-xs text-slate-500">@@@user.Username</p>
|
| 312 |
+
</div>
|
| 313 |
+
</button>
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
@if (!FilteredProjectMembers.Any())
|
| 317 |
+
{
|
| 318 |
+
<div class="rounded-xl border border-dashed border-slate-200 bg-slate-50 px-3 py-4 text-center text-xs text-slate-500">
|
| 319 |
+
No members match the current search.
|
| 320 |
+
</div>
|
| 321 |
+
}
|
| 322 |
+
</div>
|
| 323 |
+
}
|
| 324 |
+
</div>
|
| 325 |
}
|
| 326 |
+
</div>
|
| 327 |
</div>
|
| 328 |
<div>
|
| 329 |
<label class="block text-sm font-medium text-slate-700 mb-1.5">Estimated Hours</label>
|
|
|
|
| 383 |
private bool showModal = false;
|
| 384 |
private bool isEditing = false;
|
| 385 |
private bool isSaving = false;
|
| 386 |
+
private bool isAssigneeDropdownOpen = false;
|
| 387 |
private string? errorMessage;
|
| 388 |
|
| 389 |
// Per-field validation
|
|
|
|
| 405 |
private List<TaskDto> taskList = new();
|
| 406 |
private List<ProjectDto> projects = new();
|
| 407 |
private List<UserDto> allUsers = new();
|
| 408 |
+
private List<UserDto> projectMembers = new();
|
| 409 |
|
| 410 |
private string? successMessage;
|
| 411 |
|
|
|
|
| 433 |
private TaskPriority formPriorityId = TaskPriority.Medium;
|
| 434 |
private DateTime formDueDate = DateTime.Today.AddDays(7);
|
| 435 |
private long? formAssignedTo;
|
| 436 |
+
private string assigneeSearchInput = "";
|
| 437 |
private decimal? formEstimatedHours;
|
| 438 |
private decimal? formActualHours;
|
| 439 |
+
private bool isLoadingProjectMembers;
|
| 440 |
|
| 441 |
protected override async Task OnInitializedAsync()
|
| 442 |
{
|
|
|
|
| 602 |
canUpdateTask = canEditTask;
|
| 603 |
}
|
| 604 |
|
| 605 |
+
private IEnumerable<UserDto> FilteredProjectMembers
|
| 606 |
+
{
|
| 607 |
+
get
|
| 608 |
+
{
|
| 609 |
+
var result = projectMembers.Where(u => u.IsActive);
|
| 610 |
+
|
| 611 |
+
if (!string.IsNullOrWhiteSpace(assigneeSearchInput))
|
| 612 |
+
{
|
| 613 |
+
var search = assigneeSearchInput.Trim();
|
| 614 |
+
result = result.Where(u =>
|
| 615 |
+
$"{u.FirstName} {u.LastName}".Contains(search, StringComparison.OrdinalIgnoreCase) ||
|
| 616 |
+
(!string.IsNullOrWhiteSpace(u.Username) && u.Username.Contains(search, StringComparison.OrdinalIgnoreCase)) ||
|
| 617 |
+
(!string.IsNullOrWhiteSpace(u.Email) && u.Email.Contains(search, StringComparison.OrdinalIgnoreCase)));
|
| 618 |
+
}
|
| 619 |
+
|
| 620 |
+
return result
|
| 621 |
+
.OrderBy(u => u.FirstName)
|
| 622 |
+
.ThenBy(u => u.LastName)
|
| 623 |
+
.ToList();
|
| 624 |
+
}
|
| 625 |
+
}
|
| 626 |
+
|
| 627 |
+
private string SelectedAssigneeLabel
|
| 628 |
+
{
|
| 629 |
+
get
|
| 630 |
+
{
|
| 631 |
+
if (!formAssignedTo.HasValue)
|
| 632 |
+
{
|
| 633 |
+
return "Unassigned";
|
| 634 |
+
}
|
| 635 |
+
|
| 636 |
+
var selectedUser = projectMembers.FirstOrDefault(u => u.Id == formAssignedTo.Value);
|
| 637 |
+
return selectedUser == null
|
| 638 |
+
? "Selected member"
|
| 639 |
+
: $"{selectedUser.FirstName} {selectedUser.LastName}".Trim();
|
| 640 |
+
}
|
| 641 |
+
}
|
| 642 |
+
|
| 643 |
+
private async Task OnFormProjectChanged(ChangeEventArgs e)
|
| 644 |
+
{
|
| 645 |
+
if (long.TryParse(e.Value?.ToString(), out var projectId))
|
| 646 |
+
{
|
| 647 |
+
formProjectId = projectId;
|
| 648 |
+
}
|
| 649 |
+
else
|
| 650 |
+
{
|
| 651 |
+
formProjectId = 0;
|
| 652 |
+
}
|
| 653 |
+
|
| 654 |
+
formAssignedTo = null;
|
| 655 |
+
assigneeSearchInput = "";
|
| 656 |
+
await LoadProjectMembersAsync(formProjectId);
|
| 657 |
+
}
|
| 658 |
+
|
| 659 |
+
private void SetFormAssignee(long? userId)
|
| 660 |
+
{
|
| 661 |
+
formAssignedTo = userId;
|
| 662 |
+
errorMessage = null;
|
| 663 |
+
isAssigneeDropdownOpen = false;
|
| 664 |
+
}
|
| 665 |
+
|
| 666 |
+
private void OpenAssigneeDropdown()
|
| 667 |
+
{
|
| 668 |
+
if (formProjectId <= 0)
|
| 669 |
+
{
|
| 670 |
+
return;
|
| 671 |
+
}
|
| 672 |
+
|
| 673 |
+
isAssigneeDropdownOpen = true;
|
| 674 |
+
}
|
| 675 |
+
|
| 676 |
+
private void ToggleAssigneeDropdown()
|
| 677 |
+
{
|
| 678 |
+
if (isAssigneeDropdownOpen)
|
| 679 |
+
{
|
| 680 |
+
CloseAssigneeDropdown();
|
| 681 |
+
return;
|
| 682 |
+
}
|
| 683 |
+
|
| 684 |
+
OpenAssigneeDropdown();
|
| 685 |
+
}
|
| 686 |
+
|
| 687 |
+
private void EnsureAssigneeDropdownOpen()
|
| 688 |
+
{
|
| 689 |
+
if (formProjectId <= 0)
|
| 690 |
+
{
|
| 691 |
+
return;
|
| 692 |
+
}
|
| 693 |
+
|
| 694 |
+
isAssigneeDropdownOpen = true;
|
| 695 |
+
}
|
| 696 |
+
|
| 697 |
+
private void CloseAssigneeDropdown()
|
| 698 |
+
{
|
| 699 |
+
isAssigneeDropdownOpen = false;
|
| 700 |
+
}
|
| 701 |
+
|
| 702 |
+
private async Task LoadProjectMembersAsync(long projectId)
|
| 703 |
+
{
|
| 704 |
+
projectMembers.Clear();
|
| 705 |
+
isLoadingProjectMembers = true;
|
| 706 |
+
|
| 707 |
+
try
|
| 708 |
+
{
|
| 709 |
+
if (projectId <= 0)
|
| 710 |
+
{
|
| 711 |
+
return;
|
| 712 |
+
}
|
| 713 |
+
|
| 714 |
+
var client = ApiClient.CreateClient();
|
| 715 |
+
var membersResult = await client.GetFromJsonAsync<Result<IEnumerable<UserDto>>>($"Project/{projectId}/members", Serialization.CaseInsensitive);
|
| 716 |
+
if (membersResult?.IsSuccess == true && membersResult.Value != null)
|
| 717 |
+
{
|
| 718 |
+
projectMembers = membersResult.Value
|
| 719 |
+
.Where(user => user.IsActive)
|
| 720 |
+
.OrderBy(user => user.FirstName)
|
| 721 |
+
.ThenBy(user => user.LastName)
|
| 722 |
+
.ToList();
|
| 723 |
+
|
| 724 |
+
if (formAssignedTo.HasValue && !projectMembers.Any(user => user.Id == formAssignedTo.Value))
|
| 725 |
+
{
|
| 726 |
+
formAssignedTo = null;
|
| 727 |
+
}
|
| 728 |
+
}
|
| 729 |
+
else
|
| 730 |
+
{
|
| 731 |
+
formAssignedTo = null;
|
| 732 |
+
}
|
| 733 |
+
}
|
| 734 |
+
catch (Exception ex)
|
| 735 |
+
{
|
| 736 |
+
Console.WriteLine($"Failed to load project members for task form: {ex.Message}");
|
| 737 |
+
formAssignedTo = null;
|
| 738 |
+
}
|
| 739 |
+
finally
|
| 740 |
+
{
|
| 741 |
+
isLoadingProjectMembers = false;
|
| 742 |
+
await InvokeAsync(StateHasChanged);
|
| 743 |
+
await JS.InvokeVoidAsync("initIcons");
|
| 744 |
+
}
|
| 745 |
+
}
|
| 746 |
+
|
| 747 |
+
private async Task OpenCreateModal()
|
| 748 |
{
|
| 749 |
isEditing = false;
|
| 750 |
editingId = 0;
|
|
|
|
| 755 |
formPriorityId = TaskPriority.Medium;
|
| 756 |
formDueDate = DateTime.Today.AddDays(7);
|
| 757 |
formAssignedTo = null;
|
| 758 |
+
assigneeSearchInput = "";
|
| 759 |
formEstimatedHours = null;
|
| 760 |
formActualHours = null;
|
| 761 |
errorMessage = null;
|
| 762 |
isTitleError = false; titleErrorMessage = "";
|
| 763 |
isProjectError = false; projectErrorMessage = "";
|
| 764 |
showModal = true;
|
| 765 |
+
|
| 766 |
+
await LoadProjectMembersAsync(formProjectId);
|
| 767 |
}
|
| 768 |
|
| 769 |
+
private async Task OpenEditModal(TaskDto task)
|
| 770 |
{
|
| 771 |
isEditing = true;
|
| 772 |
editingId = task.Id;
|
|
|
|
| 777 |
formPriorityId = task.PriorityId;
|
| 778 |
formDueDate = task.DueDate;
|
| 779 |
formAssignedTo = task.AssignedTo;
|
| 780 |
+
assigneeSearchInput = "";
|
| 781 |
formEstimatedHours = task.EstimatedHours;
|
| 782 |
formActualHours = task.ActualHours;
|
| 783 |
errorMessage = null;
|
| 784 |
isTitleError = false; titleErrorMessage = "";
|
| 785 |
isProjectError = false; projectErrorMessage = "";
|
| 786 |
showModal = true;
|
| 787 |
+
|
| 788 |
+
await LoadProjectMembersAsync(formProjectId);
|
| 789 |
}
|
| 790 |
|
| 791 |
private void CloseModal()
|
|
|
|
| 794 |
errorMessage = null;
|
| 795 |
isTitleError = false; titleErrorMessage = "";
|
| 796 |
isProjectError = false; projectErrorMessage = "";
|
| 797 |
+
assigneeSearchInput = "";
|
| 798 |
+
isAssigneeDropdownOpen = false;
|
| 799 |
+
projectMembers.Clear();
|
| 800 |
+
isLoadingProjectMembers = false;
|
| 801 |
}
|
| 802 |
|
| 803 |
private void RequestSaveConfirmation()
|
TaskTrackingSystem.WebApp/Components/Partial/NotificationBell.razor
CHANGED
|
@@ -27,6 +27,12 @@
|
|
| 27 |
<p class="text-xs text-slate-500">@UnreadCount unread</p>
|
| 28 |
</div>
|
| 29 |
<div class="flex items-center gap-2">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
<button type="button" class="text-xs font-semibold text-violet-600 hover:text-violet-700" @onclick="RefreshAsync">
|
| 31 |
Refresh
|
| 32 |
</button>
|
|
@@ -85,11 +91,20 @@
|
|
| 85 |
}
|
| 86 |
</div>
|
| 87 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
@code {
|
| 89 |
private HubConnection? _hubConnection;
|
| 90 |
private bool _hubHandlersRegistered;
|
| 91 |
private bool IsOpen;
|
| 92 |
private bool IsLoading;
|
|
|
|
| 93 |
private int UnreadCount;
|
| 94 |
private List<NotificationDto> Notifications = new();
|
| 95 |
|
|
@@ -246,6 +261,45 @@
|
|
| 246 |
}
|
| 247 |
}
|
| 248 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
private async Task MarkReadAsync(NotificationDto item)
|
| 250 |
{
|
| 251 |
if (item.IsRead)
|
|
|
|
| 27 |
<p class="text-xs text-slate-500">@UnreadCount unread</p>
|
| 28 |
</div>
|
| 29 |
<div class="flex items-center gap-2">
|
| 30 |
+
@if (Notifications.Any(item => item.IsRead))
|
| 31 |
+
{
|
| 32 |
+
<button type="button" class="text-xs font-semibold text-rose-600 hover:text-rose-700" @onclick="RequestCleanupConfirmation">
|
| 33 |
+
Clear read
|
| 34 |
+
</button>
|
| 35 |
+
}
|
| 36 |
<button type="button" class="text-xs font-semibold text-violet-600 hover:text-violet-700" @onclick="RefreshAsync">
|
| 37 |
Refresh
|
| 38 |
</button>
|
|
|
|
| 91 |
}
|
| 92 |
</div>
|
| 93 |
|
| 94 |
+
<ConfirmDialog IsVisible="showCleanupConfirmDialog"
|
| 95 |
+
Title="Clear read notifications?"
|
| 96 |
+
Message="This will permanently remove all read notifications from your list."
|
| 97 |
+
ConfirmText="Clear"
|
| 98 |
+
Icon="trash-2"
|
| 99 |
+
OnConfirm="ConfirmCleanupAsync"
|
| 100 |
+
OnCancel="CloseCleanupConfirmDialog" />
|
| 101 |
+
|
| 102 |
@code {
|
| 103 |
private HubConnection? _hubConnection;
|
| 104 |
private bool _hubHandlersRegistered;
|
| 105 |
private bool IsOpen;
|
| 106 |
private bool IsLoading;
|
| 107 |
+
private bool showCleanupConfirmDialog;
|
| 108 |
private int UnreadCount;
|
| 109 |
private List<NotificationDto> Notifications = new();
|
| 110 |
|
|
|
|
| 261 |
}
|
| 262 |
}
|
| 263 |
|
| 264 |
+
private void RequestCleanupConfirmation()
|
| 265 |
+
{
|
| 266 |
+
showCleanupConfirmDialog = true;
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
private void CloseCleanupConfirmDialog()
|
| 270 |
+
{
|
| 271 |
+
showCleanupConfirmDialog = false;
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
private async Task ConfirmCleanupAsync()
|
| 275 |
+
{
|
| 276 |
+
showCleanupConfirmDialog = false;
|
| 277 |
+
await ClearReadNotificationsAsync();
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
private async Task ClearReadNotificationsAsync()
|
| 281 |
+
{
|
| 282 |
+
try
|
| 283 |
+
{
|
| 284 |
+
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
| 285 |
+
if (authState.User.Identity?.IsAuthenticated != true)
|
| 286 |
+
{
|
| 287 |
+
return;
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
var client = ApiClient.CreateClient(authState.User);
|
| 291 |
+
var response = await client.DeleteAsync("Notification/read");
|
| 292 |
+
if (response.IsSuccessStatusCode)
|
| 293 |
+
{
|
| 294 |
+
await RefreshAsync();
|
| 295 |
+
}
|
| 296 |
+
}
|
| 297 |
+
catch (Exception ex)
|
| 298 |
+
{
|
| 299 |
+
Console.WriteLine($"[DEBUG NotificationBell] Clear read notifications failed: {ex.Message}");
|
| 300 |
+
}
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
private async Task MarkReadAsync(NotificationDto item)
|
| 304 |
{
|
| 305 |
if (item.IsRead)
|
TaskTrackingSystem.WebApp/wwwroot/app.css
CHANGED
|
@@ -23,6 +23,38 @@ html, body {
|
|
| 23 |
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
| 24 |
background-color: var(--app-bg);
|
| 25 |
color: var(--text-primary);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
}
|
| 27 |
|
| 28 |
a, .btn-link {
|
|
@@ -82,6 +114,19 @@ html.dark,
|
|
| 82 |
html.dark body {
|
| 83 |
background-color: var(--app-bg);
|
| 84 |
color: var(--text-primary);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
}
|
| 86 |
|
| 87 |
html.dark .bg-white,
|
|
|
|
| 23 |
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
| 24 |
background-color: var(--app-bg);
|
| 25 |
color: var(--text-primary);
|
| 26 |
+
scrollbar-gutter: stable;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
* {
|
| 30 |
+
scrollbar-width: thin;
|
| 31 |
+
scrollbar-color: rgba(100, 116, 139, 0.72) transparent;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
*::-webkit-scrollbar {
|
| 35 |
+
width: 10px;
|
| 36 |
+
height: 10px;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
*::-webkit-scrollbar-track {
|
| 40 |
+
background: transparent;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
*::-webkit-scrollbar-thumb {
|
| 44 |
+
background-color: rgba(100, 116, 139, 0.72);
|
| 45 |
+
border: 2px solid transparent;
|
| 46 |
+
border-radius: 999px;
|
| 47 |
+
background-clip: content-box;
|
| 48 |
+
min-height: 40px;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
*::-webkit-scrollbar-thumb:hover {
|
| 52 |
+
background-color: rgba(71, 85, 105, 0.92);
|
| 53 |
+
background-clip: content-box;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
*::-webkit-scrollbar-corner {
|
| 57 |
+
background: transparent;
|
| 58 |
}
|
| 59 |
|
| 60 |
a, .btn-link {
|
|
|
|
| 114 |
html.dark body {
|
| 115 |
background-color: var(--app-bg);
|
| 116 |
color: var(--text-primary);
|
| 117 |
+
scrollbar-color: rgba(148, 163, 184, 0.82) rgba(15, 23, 42, 0.9);
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
html.dark *::-webkit-scrollbar-track {
|
| 121 |
+
background: rgba(15, 23, 42, 0.9);
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
html.dark *::-webkit-scrollbar-thumb {
|
| 125 |
+
background-color: rgba(148, 163, 184, 0.82);
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
html.dark *::-webkit-scrollbar-thumb:hover {
|
| 129 |
+
background-color: rgba(226, 232, 240, 0.9);
|
| 130 |
}
|
| 131 |
|
| 132 |
html.dark .bg-white,
|