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 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: 250,
355
  style: {
356
  fontFamily: 'Inter, sans-serif'
 
 
 
 
357
  }
358
  },
359
  title: {
@@ -366,7 +373,7 @@
366
  enabled: false
367
  },
368
  scrollbar: {
369
- enabled: false
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
- <!-- Header -->
187
- <div class="flex items-center justify-between gap-4 mb-5">
188
- <h3 class="text-2xl font-bold text-slate-900">Project Timeline</h3>
189
- <div class="text-sm font-medium text-slate-500">@TimelineRangeLabel</div>
 
 
 
 
190
  </div>
191
 
192
- @if (projectsList.Count > 0)
193
- {
194
- <div class="mt-4">
195
- <div id="project-timeline-chart" class="w-full"></div>
196
- </div>
197
- }
198
- else
199
- {
200
- <div class="flex items-center justify-center py-16 text-slate-400 text-sm">No projects to display.</div>
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-6 text-slate-900 shadow-sm shadow-slate-200/60">
256
- <div class="flex items-center justify-between gap-4">
257
  <div>
258
- <h3 class="text-2xl font-bold text-slate-900">All Projects</h3>
259
- <p class="mt-1 text-sm text-slate-500">A quick snapshot of the current project list.</p>
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-5 space-y-3">
267
- @foreach (var project in FeaturedProjects)
268
- {
269
- var progress = GetProjectProgress(project.Id);
270
- <div class="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-4">
271
- <div class="flex items-start justify-between gap-4">
272
- <div class="min-w-0">
273
- <div class="flex flex-wrap items-center gap-2">
274
- <p class="truncate text-sm font-semibold text-slate-900">@project.Name</p>
275
- <span class="@GetProjectBadgeClasses(project)" >@GetProjectBadgeLabel(project)</span>
276
- </div>
277
- <p class="mt-1 text-xs text-slate-500">@DisplayFormats.Date(project.StartDate) to @DisplayFormats.Date(project.EndDate)</p>
278
- </div>
279
- <div class="text-right">
280
- <div class="text-sm font-semibold text-slate-900">@progress.CompletedTasksCount/@progress.TotalTasksCount tasks</div>
281
- <div class="text-xs text-slate-500">@progress.CompletionPercentage%</div>
282
- </div>
283
- </div>
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 => projectsList.OrderBy(p => p.StartDate).ThenByDescending(p => p.CreatedAt).Take(5).ToList();
333
- private List<TimelineRow> TimelineRows => BuildTimelineRows();
 
334
  private List<DateTime> TimelineMonths => BuildTimelineMonths();
335
- private string TimelineRangeLabel => TimelineMonths.Count > 0 ? $"{TimelineMonths.First():MMM yyyy} - {TimelineMonths.Last():MMM yyyy}" : string.Empty;
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 = FeaturedProjects.Select(p => {
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
- await JS.InvokeVoidAsync("renderGanttChart", "project-timeline-chart", ganttData);
 
 
 
 
 
 
 
 
 
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 @bind="formProjectId" 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,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
- <select @bind="formAssignedTo" 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">
253
- <option value="">Unassigned</option>
254
- @foreach (var u in allUsers)
 
 
 
 
 
 
255
  {
256
- <option value="@u.Id">@u.FirstName @u.LastName (@@@u.Username)</option>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
  }
258
- </select>
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 void OpenCreateModal()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 void OpenEditModal(TaskDto task)
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,