User commited on
Commit
8a8894d
Β·
1 Parent(s): f608ab4

feat: remove file attachment

Browse files
TaskTrackingSystem.Database/AppDbContextModels/AppDbContext.cs CHANGED
@@ -20,7 +20,6 @@ public partial class AppDbContext : DbContext
20
 
21
  public virtual DbSet<Comment> Comments { get; set; }
22
 
23
- public virtual DbSet<FileAttachment> FileAttachments { get; set; }
24
 
25
  public virtual DbSet<Menu> Menus { get; set; }
26
 
@@ -98,27 +97,10 @@ public partial class AppDbContext : DbContext
98
  .HasConstraintName("FK_Comments_Users");
99
  });
100
 
101
- modelBuilder.Entity<FileAttachment>(entity =>
102
- {
103
- entity.HasIndex(e => e.TaskId, "IX_FileAttachments_TaskId");
104
-
105
- entity.Property(e => e.CreatedAt)
106
- .HasDefaultValueSql("(getdate())")
107
- .HasColumnType("datetime");
108
- entity.Property(e => e.FileName).HasMaxLength(255);
109
- entity.Property(e => e.FileType).HasMaxLength(100);
110
- entity.Property(e => e.UpdatedAt).HasColumnType("datetime");
111
 
112
- entity.HasOne(d => d.Task).WithMany(p => p.FileAttachments)
113
- .HasForeignKey(d => d.TaskId)
114
- .OnDelete(DeleteBehavior.ClientSetNull)
115
- .HasConstraintName("FK_FileAttachments_Tasks");
116
- });
117
 
118
  modelBuilder.Entity<Menu>(entity =>
119
  {
120
- entity.HasIndex(e => e.ParentMenuId, "IX_Menus_ParentMenuId");
121
-
122
  entity.HasIndex(e => e.MenuCode, "UQ_Menus_MenuCode").IsUnique();
123
 
124
  entity.Property(e => e.CreatedAt)
 
20
 
21
  public virtual DbSet<Comment> Comments { get; set; }
22
 
 
23
 
24
  public virtual DbSet<Menu> Menus { get; set; }
25
 
 
97
  .HasConstraintName("FK_Comments_Users");
98
  });
99
 
 
 
 
 
 
 
 
 
 
 
100
 
 
 
 
 
 
101
 
102
  modelBuilder.Entity<Menu>(entity =>
103
  {
 
 
104
  entity.HasIndex(e => e.MenuCode, "UQ_Menus_MenuCode").IsUnique();
105
 
106
  entity.Property(e => e.CreatedAt)
TaskTrackingSystem.Database/AppDbContextModels/FileAttachment.cs DELETED
@@ -1,31 +0,0 @@
1
- ο»Ώusing System;
2
- using System.Collections.Generic;
3
-
4
- namespace TaskTrackingSystem.Database.AppDbContextModels;
5
-
6
- public partial class FileAttachment
7
- {
8
- public long Id { get; set; }
9
-
10
- public long TaskId { get; set; }
11
-
12
- public string FileName { get; set; } = null!;
13
-
14
- public string FileType { get; set; } = null!;
15
-
16
- public string FilePath { get; set; } = null!;
17
-
18
- public long FileSizeInBytes { get; set; }
19
-
20
- public DateTime? CreatedAt { get; set; }
21
-
22
- public long? CreatedBy { get; set; }
23
-
24
- public DateTime? UpdatedAt { get; set; }
25
-
26
- public long? UpdatedBy { get; set; }
27
-
28
- public bool IsDeleted { get; set; }
29
-
30
- public virtual Task Task { get; set; } = null!;
31
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
TaskTrackingSystem.Database/AppDbContextModels/Task.cs CHANGED
@@ -42,8 +42,6 @@ public partial class Task
42
 
43
  public virtual ICollection<Comment> Comments { get; set; } = new List<Comment>();
44
 
45
- public virtual ICollection<FileAttachment> FileAttachments { get; set; } = new List<FileAttachment>();
46
-
47
  public virtual Project Project { get; set; } = null!;
48
 
49
  public virtual ICollection<TaskHistory> TaskHistories { get; set; } = new List<TaskHistory>();
 
42
 
43
  public virtual ICollection<Comment> Comments { get; set; } = new List<Comment>();
44
 
 
 
45
  public virtual Project Project { get; set; } = null!;
46
 
47
  public virtual ICollection<TaskHistory> TaskHistories { get; set; } = new List<TaskHistory>();
TaskTrackingSystem.Shared/Models/Attachment/FileAttachmentDto.cs DELETED
@@ -1,16 +0,0 @@
1
- using System;
2
-
3
- namespace TaskTrackingSystem.Shared.Models.Attachment
4
- {
5
- public class FileAttachmentDto
6
- {
7
- public long Id { get; set; }
8
- public long TaskId { get; set; }
9
- public string FileName { get; set; } = null!;
10
- public string FileType { get; set; } = null!;
11
- public long FileSizeInBytes { get; set; }
12
- public DateTime CreatedAt { get; set; }
13
- public long? CreatedBy { get; set; }
14
- public string CreatedByName { get; set; } = null!;
15
- }
16
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
TaskTrackingSystem.WebApi/Features/Task/TaskDetailsController.cs CHANGED
@@ -10,7 +10,6 @@ using System.Threading.Tasks;
10
  using TaskTrackingSystem.Database.AppDbContextModels;
11
  using TaskTrackingSystem.Shared;
12
  using TaskTrackingSystem.Shared.Models.Comment;
13
- using TaskTrackingSystem.Shared.Models.Attachment;
14
  using TaskTrackingSystem.WebApi.Infrastructure;
15
 
16
  namespace TaskTrackingSystem.WebApi.Features.Task
@@ -143,157 +142,5 @@ namespace TaskTrackingSystem.WebApi.Features.Task
143
 
144
  return Ok(Result.Success(200));
145
  }
146
-
147
- // ─── ATTACHMENTS ──────────────────────────────────────────────────────
148
-
149
- [HttpGet("tasks/{taskId}/attachments")]
150
- public async Task<ActionResult<IEnumerable<FileAttachmentDto>>> GetAttachments(long taskId)
151
- {
152
- var taskExists = await _db.Tasks.AnyAsync(t => t.Id == taskId && !t.IsDeleted);
153
- if (!taskExists)
154
- {
155
- return NotFound(new { message = $"Task with ID {taskId} not found." });
156
- }
157
-
158
- var attachments = await _db.FileAttachments
159
- .Where(f => f.TaskId == taskId && !f.IsDeleted)
160
- .OrderBy(f => f.CreatedAt)
161
- .Select(f => new FileAttachmentDto
162
- {
163
- Id = f.Id,
164
- TaskId = f.TaskId,
165
- FileName = f.FileName,
166
- FileType = f.FileType,
167
- FileSizeInBytes = f.FileSizeInBytes,
168
- CreatedAt = f.CreatedAt ?? DateTime.UtcNow,
169
- CreatedBy = f.CreatedBy,
170
- CreatedByName = _db.Users
171
- .Where(u => u.Id == f.CreatedBy)
172
- .Select(u => $"{u.FirstName} {u.LastName}")
173
- .FirstOrDefault() ?? "Unknown"
174
- })
175
- .ToListAsync();
176
-
177
- return Ok(attachments);
178
- }
179
-
180
- [HttpPost("tasks/{taskId}/attachments")]
181
- public async Task<ActionResult<Result<FileAttachmentDto>>> UploadAttachment(long taskId, IFormFile file)
182
- {
183
- if (file == null || file.Length == 0)
184
- {
185
- return BadRequest(Result<FileAttachmentDto>.Failure("No file was uploaded.", 400));
186
- }
187
-
188
- var task = await _db.Tasks.FirstOrDefaultAsync(t => t.Id == taskId && !t.IsDeleted);
189
- if (task == null)
190
- {
191
- return NotFound(Result<FileAttachmentDto>.Failure($"Task with ID {taskId} not found.", 404));
192
- }
193
-
194
- var userId = User.GetUserId();
195
- var user = await _db.Users.FirstOrDefaultAsync(u => u.Id == userId);
196
- if (user == null)
197
- {
198
- return Unauthorized();
199
- }
200
-
201
- // Create uploads directory
202
- var uploadsFolder = Path.Combine(Directory.GetCurrentDirectory(), "Uploads");
203
- if (!Directory.Exists(uploadsFolder))
204
- {
205
- Directory.CreateDirectory(uploadsFolder);
206
- }
207
-
208
- // Generate unique name
209
- var uniqueFileName = $"{Guid.NewGuid()}_{file.FileName}";
210
- var filePath = Path.Combine(uploadsFolder, uniqueFileName);
211
-
212
- // Save file physically
213
- using (var stream = new FileStream(filePath, FileMode.Create))
214
- {
215
- await file.CopyToAsync(stream);
216
- }
217
-
218
- var attachment = new FileAttachment
219
- {
220
- TaskId = taskId,
221
- FileName = file.FileName,
222
- FileType = file.ContentType,
223
- FilePath = uniqueFileName, // Store the unique disk filename as FilePath
224
- FileSizeInBytes = file.Length,
225
- CreatedAt = DateTime.UtcNow,
226
- CreatedBy = userId,
227
- IsDeleted = false
228
- };
229
-
230
- _db.FileAttachments.Add(attachment);
231
- await _db.SaveChangesAsync();
232
-
233
- var resultDto = new FileAttachmentDto
234
- {
235
- Id = attachment.Id,
236
- TaskId = attachment.TaskId,
237
- FileName = attachment.FileName,
238
- FileType = attachment.FileType,
239
- FileSizeInBytes = attachment.FileSizeInBytes,
240
- CreatedAt = attachment.CreatedAt ?? DateTime.UtcNow,
241
- CreatedBy = attachment.CreatedBy,
242
- CreatedByName = $"{user.FirstName} {user.LastName}"
243
- };
244
-
245
- return StatusCode(201, Result<FileAttachmentDto>.Success(resultDto, 201));
246
- }
247
-
248
- [HttpGet("attachments/{attachmentId}/download")]
249
- [AllowAnonymous] // Allow download endpoint to stream, we can check custom token validation or basic checks if desired, or let Blazor fetch it
250
- public async Task<IActionResult> DownloadAttachment(long attachmentId)
251
- {
252
- var attachment = await _db.FileAttachments.FirstOrDefaultAsync(f => f.Id == attachmentId && !f.IsDeleted);
253
- if (attachment == null)
254
- {
255
- return NotFound();
256
- }
257
-
258
- var uploadsFolder = Path.Combine(Directory.GetCurrentDirectory(), "Uploads");
259
- var filePath = Path.Combine(uploadsFolder, attachment.FilePath);
260
-
261
- if (!System.IO.File.Exists(filePath))
262
- {
263
- return NotFound();
264
- }
265
-
266
- var bytes = await System.IO.File.ReadAllBytesAsync(filePath);
267
- return File(bytes, attachment.FileType, attachment.FileName);
268
- }
269
-
270
- [HttpDelete("attachments/{attachmentId}")]
271
- public async Task<ActionResult<Result>> DeleteAttachment(long attachmentId)
272
- {
273
- var attachment = await _db.FileAttachments.FirstOrDefaultAsync(f => f.Id == attachmentId && !f.IsDeleted);
274
- if (attachment == null)
275
- {
276
- return NotFound(Result.Failure("Attachment not found.", 404));
277
- }
278
-
279
- var userId = User.GetUserId();
280
- var isAdmin = User.IsAdmin();
281
-
282
- // Only attachment creator or Admin can delete
283
- if (attachment.CreatedBy != userId && !isAdmin)
284
- {
285
- return Forbid();
286
- }
287
-
288
- attachment.IsDeleted = true;
289
- attachment.UpdatedAt = DateTime.UtcNow;
290
- attachment.UpdatedBy = userId;
291
-
292
- _db.FileAttachments.Update(attachment);
293
- await _db.SaveChangesAsync();
294
-
295
- // Optional: delete physical file or leave it for archiving. We'll leave it as soft-deleted in DB.
296
- return Ok(Result.Success(200));
297
- }
298
  }
299
  }
 
10
  using TaskTrackingSystem.Database.AppDbContextModels;
11
  using TaskTrackingSystem.Shared;
12
  using TaskTrackingSystem.Shared.Models.Comment;
 
13
  using TaskTrackingSystem.WebApi.Infrastructure;
14
 
15
  namespace TaskTrackingSystem.WebApi.Features.Task
 
142
 
143
  return Ok(Result.Success(200));
144
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  }
146
  }
TaskTrackingSystem.WebApp/Components/Pages/Backlog.razor CHANGED
@@ -147,7 +147,7 @@
147
  <div class="bg-white border border-slate-200 rounded-2xl p-12 shadow-sm text-center flex flex-col items-center justify-center min-h-[600px] text-slate-400">
148
  <i data-lucide="layers" class="h-12 w-12 text-slate-300 mb-3 animate-pulse"></i>
149
  <h4 class="text-sm font-bold text-slate-700">Select a task from the backlog</h4>
150
- <p class="text-xs text-slate-400 mt-1 max-w-sm">Click any task on the left list to view details, update status, post comments, or upload file attachments inline.</p>
151
  </div>
152
  }
153
  </div>
 
147
  <div class="bg-white border border-slate-200 rounded-2xl p-12 shadow-sm text-center flex flex-col items-center justify-center min-h-[600px] text-slate-400">
148
  <i data-lucide="layers" class="h-12 w-12 text-slate-300 mb-3 animate-pulse"></i>
149
  <h4 class="text-sm font-bold text-slate-700">Select a task from the backlog</h4>
150
+ <p class="text-xs text-slate-400 mt-1 max-w-sm">Click any task on the left list to view details, update status, or post comments inline.</p>
151
  </div>
152
  }
153
  </div>
TaskTrackingSystem.WebApp/Components/Pages/TaskDetails.razor CHANGED
@@ -5,7 +5,6 @@
5
  @using TaskTrackingSystem.Shared
6
  @using TaskTrackingSystem.Shared.Models.Task
7
  @using TaskTrackingSystem.Shared.Models.Comment
8
- @using TaskTrackingSystem.Shared.Models.Attachment
9
  @using TaskTrackingSystem.Shared.Models.Project
10
  @using TaskTrackingSystem.Shared.Models.User
11
  @using TaskTrackingSystem.WebApp.Components.Partial
@@ -247,144 +246,70 @@
247
  </div>
248
  </div>
249
 
250
- <!-- Right Grid: Collaboration Hub (Comments & Attachments) -->
251
  <div class="space-y-6">
252
  <div class="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden flex flex-col h-[580px]">
253
- <!-- Hub Tabs -->
254
- <div class="flex border-b border-slate-100 bg-slate-50/50 p-1">
255
- <button @onclick='() => SetTab("comments")' class="flex-1 py-2 rounded-lg text-xs font-bold flex items-center justify-center gap-2 transition-all @(currentTab == "comments" ? "bg-white text-slate-800 shadow-sm" : "text-slate-500 hover:text-slate-800")">
256
- <i data-lucide="message-square" class="h-4 w-4"></i>
257
- <span>Comments (@comments.Count)</span>
258
- </button>
259
- <button @onclick='() => SetTab("files")' class="flex-1 py-2 rounded-lg text-xs font-bold flex items-center justify-center gap-2 transition-all @(currentTab == "files" ? "bg-white text-slate-800 shadow-sm" : "text-slate-500 hover:text-slate-800")">
260
- <i data-lucide="paperclip" class="h-4 w-4"></i>
261
- <span>Files (@attachments.Count)</span>
262
- </button>
263
  </div>
264
 
265
- <!-- Hub Body -->
266
  <div class="flex-1 overflow-y-auto p-4 min-h-0">
267
- @if (currentTab == "comments")
268
- {
269
- <!-- Comments List -->
270
- <div class="space-y-4 font-sans">
271
- @if (comments.Any())
272
- {
273
- @foreach (var c in comments)
274
- {
275
- <div class="flex items-start gap-3">
276
- <span class="flex h-7 w-7 items-center justify-center rounded-full bg-violet-100 text-violet-700 text-xs font-bold shrink-0 mt-0.5">
277
- @(c.UserFullName.Length > 0 ? c.UserFullName[0].ToString().ToUpper() : "U")
278
- </span>
279
- <div class="flex-1 min-w-0 bg-slate-50 rounded-xl p-3 border border-slate-100">
280
- <div class="flex items-center justify-between gap-2">
281
- <span class="text-xs font-bold text-slate-800 truncate">@c.UserFullName</span>
282
- <span class="text-[9px] text-slate-400 shrink-0 font-sans">@c.CreatedAt.ToString("g")</span>
283
- </div>
284
- <p class="text-xs text-slate-400 mt-0.5">@c.UserRoleName</p>
285
- <div class="text-xs text-slate-700 mt-2 whitespace-pre-wrap break-words">@c.Message</div>
286
- </div>
287
- @if (c.UserId == currentUserId || isAdmin)
288
- {
289
- <button @onclick="() => ConfirmDeleteComment(c.Id)" class="p-1 text-slate-300 hover:text-rose-500 rounded-lg transition-colors shrink-0" title="Delete comment">
290
- <i data-lucide="trash-2" class="h-3.5 w-3.5"></i>
291
- </button>
292
- }
293
- </div>
294
- }
295
- }
296
- else
297
- {
298
- <div class="text-center py-12 text-slate-400 space-y-2">
299
- <i data-lucide="messages-square" class="h-8 w-8 mx-auto text-slate-300"></i>
300
- <p class="text-xs">No comments posted yet.</p>
301
- <p class="text-[10px]">Start the conversation below!</p>
302
- </div>
303
- }
304
- </div>
305
- }
306
- else
307
- {
308
- <!-- Attachments List -->
309
- <div class="space-y-3 font-sans">
310
- @if (attachments.Any())
311
  {
312
- @foreach (var file in attachments)
313
- {
314
- <div class="flex items-center justify-between p-2.5 rounded-xl border border-slate-200 bg-white hover:bg-slate-50/50 transition-colors gap-3">
315
- <div class="flex items-center gap-2.5 min-w-0">
316
- <span class="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-100 text-slate-500 shrink-0">
317
- <i data-lucide="@GetFileIcon(file.FileType)" class="h-4 w-4"></i>
318
- </span>
319
- <div class="min-w-0">
320
- <p class="text-xs font-semibold text-slate-800 truncate" title="@file.FileName">@file.FileName</p>
321
- <p class="text-[10px] text-slate-400 mt-0.5 font-sans">@FormatBytes(file.FileSizeInBytes) Ò€’ by @file.CreatedByName</p>
322
- </div>
323
- </div>
324
- <div class="flex items-center gap-1.5 shrink-0">
325
- <a href="@($"{ApiClient.BaseUrl}/api/TaskDetails/attachments/{file.Id}/download")" target="_blank" class="p-1.5 text-slate-400 hover:text-violet-600 hover:bg-violet-50 rounded-lg transition-all" title="Download">
326
- <i data-lucide="download" class="h-4 w-4"></i>
327
- </a>
328
- @if (file.CreatedBy == currentUserId || isAdmin)
329
- {
330
- <button @onclick="() => ConfirmDeleteAttachment(file.Id)" class="p-1.5 text-slate-300 hover:text-rose-500 hover:bg-rose-50 rounded-lg transition-all" title="Delete file">
331
- <i data-lucide="trash-2" class="h-4 w-4"></i>
332
- </button>
333
- }
334
  </div>
 
 
335
  </div>
336
- }
337
- }
338
- else
339
- {
340
- <div class="text-center py-8 text-slate-400 space-y-2">
341
- <i data-lucide="file-up" class="h-8 w-8 mx-auto text-slate-300"></i>
342
- <p class="text-xs">No attachments found.</p>
343
  </div>
344
  }
345
- </div>
346
- }
 
 
 
 
 
 
 
 
347
  </div>
348
 
349
- <!-- Hub Footer (Inputs) (Free for everyone) -->
350
  <div class="p-4 border-t border-slate-100 bg-slate-50/50">
351
- @if (currentTab == "comments")
352
- {
353
- <div class="flex items-start gap-2">
354
- <div class="flex-1">
355
- <textarea @bind="newCommentMessage" rows="2" placeholder="Write a comment..." class="w-full rounded-xl border border-slate-200 px-3 py-2 text-xs text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" disabled="@isSavingComment"></textarea>
356
- @if (!string.IsNullOrEmpty(commentError))
357
- {
358
- <p class="text-[10px] text-red-500 mt-1 font-sans">@commentError</p>
359
- }
360
- </div>
361
- <button @onclick="PostComment" disabled="@(isSavingComment || string.IsNullOrWhiteSpace(newCommentMessage))" class="rounded-xl bg-violet-600 text-white p-2.5 hover:bg-violet-700 disabled:opacity-50 transition-all shadow-sm shrink-0">
362
- <i data-lucide="send" class="h-4 w-4"></i>
363
- </button>
364
- </div>
365
- }
366
- else
367
- {
368
- <div>
369
- <InputFile OnChange="OnInputFileChange" class="hidden" id="fileUpload" disabled="@isUploading" />
370
- <label for="fileUpload" class="cursor-pointer flex flex-col items-center justify-center border-2 border-dashed border-slate-200 rounded-xl p-4 hover:border-violet-500 hover:bg-violet-50/20 transition-all">
371
- @if (isUploading)
372
- {
373
- <LoadingSpinner Message="Uploading file..." />
374
- }
375
- else
376
- {
377
- <i data-lucide="upload-cloud" class="h-7 w-7 text-slate-400 mb-1.5"></i>
378
- <span class="text-xs font-semibold text-slate-600">Click to upload file</span>
379
- <span class="text-[9px] text-slate-400 mt-0.5">Maximum size: 10MB</span>
380
- }
381
- </label>
382
- @if (!string.IsNullOrEmpty(uploadError))
383
  {
384
- <p class="text-[10px] text-red-500 mt-1.5 font-sans">@uploadError</p>
385
  }
386
  </div>
387
- }
 
 
 
388
  </div>
389
  </div>
390
  </div>
@@ -392,7 +317,6 @@
392
  }
393
  </div>
394
 
395
- @* Confirm Actions dialog *@
396
  <ConfirmDialog IsVisible="showConfirm"
397
  Title="@confirmTitle"
398
  Message="@confirmMessage"
@@ -410,13 +334,13 @@
410
  [Parameter] public EventCallback OnTaskUpdated { get; set; }
411
  [SupplyParameterFromQuery(Name = "tab")]
412
  public string? TabQuery { get; set; }
 
413
 
414
  private bool isLoading = true;
415
  private TaskDto? task;
416
  private ProjectDto? project;
417
  private UserDto? assignee;
418
  private List<CommentDto> comments = new();
419
- private List<FileAttachmentDto> attachments = new();
420
  private List<UserDto> allUsers = new();
421
 
422
  private bool isAdmin = false;
@@ -427,22 +351,15 @@
427
 
428
  private List<string> scopes = new();
429
 
430
- private string currentTab = "comments";
431
-
432
  // Comments Form
433
  private string newCommentMessage = "";
434
  private bool isSavingComment = false;
435
  private string? commentError;
436
 
437
- // Attachments Form
438
- private bool isUploading = false;
439
- private string? uploadError;
440
-
441
  // Confirm dialog
442
  private bool showConfirm = false;
443
  private string confirmTitle = "";
444
  private string confirmMessage = "";
445
- private Func<Task>? confirmAction;
446
 
447
  // Inline edit states
448
  private bool isEditingDescription = false;
@@ -480,11 +397,6 @@
480
  assignee = null;
481
  await LoadAllData();
482
  }
483
-
484
- if (!string.IsNullOrWhiteSpace(TabQuery))
485
- {
486
- currentTab = TabQuery.Equals("files", StringComparison.OrdinalIgnoreCase) ? "files" : "comments";
487
- }
488
  }
489
 
490
  protected override async Task OnAfterRenderAsync(bool firstRender)
@@ -492,15 +404,6 @@
492
  await JS.InvokeVoidAsync("initIcons");
493
  }
494
 
495
- private void SetTab(string tab)
496
- {
497
- currentTab = tab;
498
- _ = InvokeAsync(async () => {
499
- await Task.Delay(50);
500
- await JS.InvokeVoidAsync("initIcons");
501
- });
502
- }
503
-
504
  private async Task LoadAllData()
505
  {
506
  isLoading = true;
@@ -509,15 +412,13 @@
509
  {
510
  var taskUrl = $"Task/{TaskId}";
511
  var commentsUrl = $"TaskDetails/tasks/{TaskId}/comments";
512
- var attachmentsUrl = $"TaskDetails/tasks/{TaskId}/attachments";
513
  var usersUrl = "User";
514
 
515
  var taskResponseTask = client.GetAsync(taskUrl);
516
  var commentsResponseTask = client.GetAsync(commentsUrl);
517
- var attachmentsResponseTask = client.GetAsync(attachmentsUrl);
518
  var usersResponseTask = client.GetAsync(usersUrl);
519
 
520
- await Task.WhenAll(taskResponseTask, commentsResponseTask, attachmentsResponseTask, usersResponseTask);
521
 
522
  var taskResponse = taskResponseTask.Result;
523
  if (taskResponse.IsSuccessStatusCode)
@@ -531,12 +432,6 @@
531
  comments = await commentsResponse.Content.ReadFromJsonAsync<List<CommentDto>>(Serialization.CaseInsensitive) ?? new();
532
  }
533
 
534
- var attachmentsResponse = attachmentsResponseTask.Result;
535
- if (attachmentsResponse.IsSuccessStatusCode)
536
- {
537
- attachments = await attachmentsResponse.Content.ReadFromJsonAsync<List<FileAttachmentDto>>(Serialization.CaseInsensitive) ?? new();
538
- }
539
-
540
  var usersResponse = usersResponseTask.Result;
541
  if (usersResponse.IsSuccessStatusCode)
542
  {
@@ -591,7 +486,7 @@
591
  }
592
  }
593
 
594
- // Ò”€Ò”€Ò”€ INLINE MUTATIONS Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€
595
 
596
  private async Task OnStatusChanged(ChangeEventArgs e)
597
  {
@@ -736,7 +631,7 @@
736
  isSavingTitle = false;
737
  }
738
 
739
- // Ò”€Ò”€Ò”€ COMMENTS ACTIONS Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€
740
 
741
  private async Task PostComment()
742
  {
@@ -750,7 +645,7 @@
750
  var response = await client.PostAsJsonAsync($"TaskDetails/tasks/{TaskId}/comments", new CreateCommentDto { Message = newCommentMessage });
751
  var res = await response.Content.ReadFromJsonAsync<Result<CommentDto>>(Serialization.CaseInsensitive);
752
 
753
- if (response.IsSuccessStatusCode && res != null && res.IsSuccess)
754
  {
755
  comments.Add(res.Value);
756
  newCommentMessage = "";
@@ -798,75 +693,7 @@
798
  }
799
  }
800
 
801
- // Ò”€Ò”€Ò”€ ATTACHMENTS ACTIONS Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€
802
-
803
- private async Task OnInputFileChange(InputFileChangeEventArgs e)
804
- {
805
- var file = e.File;
806
- if (file == null) return;
807
-
808
- isUploading = true;
809
- uploadError = null;
810
-
811
- try
812
- {
813
- var content = new MultipartFormDataContent();
814
- var fileContent = new StreamContent(file.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024));
815
- fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(file.ContentType);
816
- content.Add(fileContent, "file", file.Name);
817
-
818
- var client = ApiClient.CreateClient();
819
- var response = await client.PostAsync($"TaskDetails/tasks/{TaskId}/attachments", content);
820
- var res = await response.Content.ReadFromJsonAsync<Result<FileAttachmentDto>>(Serialization.CaseInsensitive);
821
-
822
- if (response.IsSuccessStatusCode && res != null && res.IsSuccess)
823
- {
824
- attachments.Add(res.Value);
825
- await JS.InvokeVoidAsync("initIcons");
826
- }
827
- else
828
- {
829
- uploadError = res?.ErrorMessage ?? "Failed to upload file.";
830
- }
831
- }
832
- catch (Exception ex)
833
- {
834
- uploadError = ex.Message;
835
- }
836
- finally
837
- {
838
- isUploading = false;
839
- }
840
- }
841
-
842
- private void ConfirmDeleteAttachment(long attachmentId)
843
- {
844
- confirmTitle = "Delete File?";
845
- confirmMessage = "Are you sure you want to delete this file attachment?";
846
- confirmAction = () => DeleteAttachment(attachmentId);
847
- showConfirm = true;
848
- }
849
-
850
- private async Task DeleteAttachment(long attachmentId)
851
- {
852
- var client = ApiClient.CreateClient();
853
- try
854
- {
855
- var response = await client.DeleteAsync($"TaskDetails/attachments/{attachmentId}");
856
- var res = await response.Content.ReadFromJsonAsync<Result>(Serialization.CaseInsensitive);
857
- if (response.IsSuccessStatusCode && res != null && res.IsSuccess)
858
- {
859
- var idx = attachments.FindIndex(f => f.Id == attachmentId);
860
- if (idx >= 0) attachments.RemoveAt(idx);
861
- }
862
- }
863
- catch (Exception ex)
864
- {
865
- Console.WriteLine($"Delete file error: {ex.Message}");
866
- }
867
- }
868
-
869
- // Ò”€Ò”€Ò”€ CONFIRM DIALOG LOGIC Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€
870
 
871
  private async Task ExecuteConfirmAction()
872
  {
@@ -884,7 +711,7 @@
884
  confirmAction = null;
885
  }
886
 
887
- // Ò”€Ò”€Ò”€ DISPLAY UTILITIES Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€
888
 
889
  private string GetStatusLabel(AppTaskStatus statusId) => statusId switch
890
  {
@@ -917,25 +744,4 @@
917
  TaskPriority.High => "bg-rose-50 text-rose-700 border border-rose-100",
918
  _ => "bg-slate-50 text-slate-600"
919
  };
920
-
921
- private string GetFileIcon(string fileType)
922
- {
923
- if (fileType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) return "image";
924
- if (fileType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase)) return "file-text";
925
- if (fileType.Contains("word") || fileType.Contains("officedocument.wordprocessingml")) return "file-text";
926
- if (fileType.Contains("excel") || fileType.Contains("officedocument.spreadsheetml")) return "file-spreadsheet";
927
- return "file";
928
- }
929
-
930
- private string FormatBytes(long bytes)
931
- {
932
- string[] suffix = { "B", "KB", "MB", "GB", "TB" };
933
- int i;
934
- double dblBytes = bytes;
935
- for (i = 0; i < suffix.Length && bytes >= 1024; i++, bytes /= 1024)
936
- {
937
- dblBytes = bytes / 1024.0;
938
- }
939
- return $"{dblBytes:0.##} {suffix[i]}";
940
- }
941
  }
 
5
  @using TaskTrackingSystem.Shared
6
  @using TaskTrackingSystem.Shared.Models.Task
7
  @using TaskTrackingSystem.Shared.Models.Comment
 
8
  @using TaskTrackingSystem.Shared.Models.Project
9
  @using TaskTrackingSystem.Shared.Models.User
10
  @using TaskTrackingSystem.WebApp.Components.Partial
 
246
  </div>
247
  </div>
248
 
249
+ <!-- Right Grid: Comments -->
250
  <div class="space-y-6">
251
  <div class="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden flex flex-col h-[580px]">
252
+ <!-- Header -->
253
+ <div class="border-b border-slate-100 bg-slate-50/50 p-4">
254
+ <h3 class="text-sm font-bold text-slate-800 flex items-center gap-2">
255
+ <i data-lucide="message-square" class="h-4 w-4 text-violet-600"></i>
256
+ Comments (@comments.Count)
257
+ </h3>
 
 
 
 
258
  </div>
259
 
260
+ <!-- Comments Body -->
261
  <div class="flex-1 overflow-y-auto p-4 min-h-0">
262
+ <div class="space-y-4 font-sans">
263
+ @if (comments.Any())
264
+ {
265
+ @foreach (var c in comments)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
266
  {
267
+ <div class="flex items-start gap-3">
268
+ <span class="flex h-7 w-7 items-center justify-center rounded-full bg-violet-100 text-violet-700 text-xs font-bold shrink-0 mt-0.5">
269
+ @(c.UserFullName.Length > 0 ? c.UserFullName[0].ToString().ToUpper() : "U")
270
+ </span>
271
+ <div class="flex-1 min-w-0 bg-slate-50 rounded-xl p-3 border border-slate-100">
272
+ <div class="flex items-center justify-between gap-2">
273
+ <span class="text-xs font-bold text-slate-800 truncate">@c.UserFullName</span>
274
+ <span class="text-[9px] text-slate-400 shrink-0 font-sans">@c.CreatedAt.ToString("g")</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
275
  </div>
276
+ <p class="text-xs text-slate-400 mt-0.5">@c.UserRoleName</p>
277
+ <div class="text-xs text-slate-700 mt-2 whitespace-pre-wrap break-words">@c.Message</div>
278
  </div>
279
+ @if (c.UserId == currentUserId || isAdmin)
280
+ {
281
+ <button @onclick="() => ConfirmDeleteComment(c.Id)" class="p-1 text-slate-300 hover:text-rose-500 rounded-lg transition-colors shrink-0" title="Delete comment">
282
+ <i data-lucide="trash-2" class="h-3.5 w-3.5"></i>
283
+ </button>
284
+ }
 
285
  </div>
286
  }
287
+ }
288
+ else
289
+ {
290
+ <div class="text-center py-12 text-slate-400 space-y-2">
291
+ <i data-lucide="messages-square" class="h-8 w-8 mx-auto text-slate-300"></i>
292
+ <p class="text-xs">No comments posted yet.</p>
293
+ <p class="text-[10px]">Start the conversation below!</p>
294
+ </div>
295
+ }
296
+ </div>
297
  </div>
298
 
299
+ <!-- Footer (Inputs) -->
300
  <div class="p-4 border-t border-slate-100 bg-slate-50/50">
301
+ <div class="flex items-start gap-2">
302
+ <div class="flex-1">
303
+ <textarea @bind="newCommentMessage" rows="2" placeholder="Write a comment..." class="w-full rounded-xl border border-slate-200 px-3 py-2 text-xs text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" disabled="@isSavingComment"></textarea>
304
+ @if (!string.IsNullOrEmpty(commentError))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
305
  {
306
+ <p class="text-[10px] text-red-500 mt-1 font-sans">@commentError</p>
307
  }
308
  </div>
309
+ <button @onclick="PostComment" disabled="@(isSavingComment || string.IsNullOrWhiteSpace(newCommentMessage))" class="rounded-xl bg-violet-600 text-white p-2.5 hover:bg-violet-700 disabled:opacity-50 transition-all shadow-sm shrink-0">
310
+ <i data-lucide="send" class="h-4 w-4"></i>
311
+ </button>
312
+ </div>
313
  </div>
314
  </div>
315
  </div>
 
317
  }
318
  </div>
319
 
 
320
  <ConfirmDialog IsVisible="showConfirm"
321
  Title="@confirmTitle"
322
  Message="@confirmMessage"
 
334
  [Parameter] public EventCallback OnTaskUpdated { get; set; }
335
  [SupplyParameterFromQuery(Name = "tab")]
336
  public string? TabQuery { get; set; }
337
+ private Func<Task>? confirmAction;
338
 
339
  private bool isLoading = true;
340
  private TaskDto? task;
341
  private ProjectDto? project;
342
  private UserDto? assignee;
343
  private List<CommentDto> comments = new();
 
344
  private List<UserDto> allUsers = new();
345
 
346
  private bool isAdmin = false;
 
351
 
352
  private List<string> scopes = new();
353
 
 
 
354
  // Comments Form
355
  private string newCommentMessage = "";
356
  private bool isSavingComment = false;
357
  private string? commentError;
358
 
 
 
 
 
359
  // Confirm dialog
360
  private bool showConfirm = false;
361
  private string confirmTitle = "";
362
  private string confirmMessage = "";
 
363
 
364
  // Inline edit states
365
  private bool isEditingDescription = false;
 
397
  assignee = null;
398
  await LoadAllData();
399
  }
 
 
 
 
 
400
  }
401
 
402
  protected override async Task OnAfterRenderAsync(bool firstRender)
 
404
  await JS.InvokeVoidAsync("initIcons");
405
  }
406
 
 
 
 
 
 
 
 
 
 
407
  private async Task LoadAllData()
408
  {
409
  isLoading = true;
 
412
  {
413
  var taskUrl = $"Task/{TaskId}";
414
  var commentsUrl = $"TaskDetails/tasks/{TaskId}/comments";
 
415
  var usersUrl = "User";
416
 
417
  var taskResponseTask = client.GetAsync(taskUrl);
418
  var commentsResponseTask = client.GetAsync(commentsUrl);
 
419
  var usersResponseTask = client.GetAsync(usersUrl);
420
 
421
+ await Task.WhenAll(taskResponseTask, commentsResponseTask, usersResponseTask);
422
 
423
  var taskResponse = taskResponseTask.Result;
424
  if (taskResponse.IsSuccessStatusCode)
 
432
  comments = await commentsResponse.Content.ReadFromJsonAsync<List<CommentDto>>(Serialization.CaseInsensitive) ?? new();
433
  }
434
 
 
 
 
 
 
 
435
  var usersResponse = usersResponseTask.Result;
436
  if (usersResponse.IsSuccessStatusCode)
437
  {
 
486
  }
487
  }
488
 
489
+ // ─── INLINE MUTATIONS ──────────────────────────────────────────────────────
490
 
491
  private async Task OnStatusChanged(ChangeEventArgs e)
492
  {
 
631
  isSavingTitle = false;
632
  }
633
 
634
+ // ─── COMMENTS ACTIONS ──────────────────────────────────────────────────────
635
 
636
  private async Task PostComment()
637
  {
 
645
  var response = await client.PostAsJsonAsync($"TaskDetails/tasks/{TaskId}/comments", new CreateCommentDto { Message = newCommentMessage });
646
  var res = await response.Content.ReadFromJsonAsync<Result<CommentDto>>(Serialization.CaseInsensitive);
647
 
648
+ if (response.IsSuccessStatusCode && res != null && res.IsSuccess && res.Value != null)
649
  {
650
  comments.Add(res.Value);
651
  newCommentMessage = "";
 
693
  }
694
  }
695
 
696
+ // β€”β€”β€” CONFIRM DIALOG LOGIC β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
697
 
698
  private async Task ExecuteConfirmAction()
699
  {
 
711
  confirmAction = null;
712
  }
713
 
714
+ // β€”β€”β€” DISPLAY UTILITIES β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”
715
 
716
  private string GetStatusLabel(AppTaskStatus statusId) => statusId switch
717
  {
 
744
  TaskPriority.High => "bg-rose-50 text-rose-700 border border-rose-100",
745
  _ => "bg-slate-50 text-slate-600"
746
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
747
  }