Files
TaskTracker/TaskTracker.Api/Pages/Board.cshtml.cs

324 lines
10 KiB
C#

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using TaskTracker.Core.Entities;
using TaskTracker.Core.Enums;
using TaskTracker.Core.Interfaces;
namespace TaskTracker.Api.Pages;
public record ColumnViewModel(WorkTaskStatus Status, string Label, string Color, List<WorkTask> Tasks);
[IgnoreAntiforgeryToken]
public class BoardModel : PageModel
{
private readonly ITaskRepository _taskRepo;
public BoardModel(ITaskRepository taskRepo)
{
_taskRepo = taskRepo;
}
public List<ColumnViewModel> Columns { get; set; } = new();
public string? ActiveCategory { get; set; }
public bool HasSubtasksFilter { get; set; }
public List<string> AllCategories { get; set; } = new();
private static readonly (WorkTaskStatus Status, string Label, string Color)[] ColumnConfig =
[
(WorkTaskStatus.Pending, "Pending", "#64748b"),
(WorkTaskStatus.Active, "Active", "#3b82f6"),
(WorkTaskStatus.Paused, "Paused", "#eab308"),
(WorkTaskStatus.Completed, "Completed", "#22c55e"),
];
public static readonly Dictionary<string, string> CategoryColors = new()
{
["Development"] = "#6366f1",
["Research"] = "#06b6d4",
["Communication"] = "#8b5cf6",
["DevOps"] = "#f97316",
["Documentation"] = "#14b8a6",
["Design"] = "#ec4899",
["Testing"] = "#3b82f6",
["General"] = "#64748b",
["Email"] = "#f59e0b",
["Engineering"] = "#6366f1",
["LaserCutting"] = "#ef4444",
["Unknown"] = "#475569",
};
public static string GetCategoryColor(string? category)
{
if (string.IsNullOrEmpty(category)) return CategoryColors["Unknown"];
return CategoryColors.TryGetValue(category, out var color) ? color : CategoryColors["Unknown"];
}
public static string FormatElapsed(DateTime? startedAt, DateTime? completedAt)
{
if (startedAt is null) return "--";
var start = startedAt.Value;
var end = completedAt ?? DateTime.UtcNow;
var mins = (int)(end - start).TotalMinutes;
if (mins < 60) return $"{mins}m";
var hours = mins / 60;
var remainder = mins % 60;
if (hours < 24) return $"{hours}h {remainder}m";
var days = hours / 24;
return $"{days}d {hours % 24}h";
}
public async Task<IActionResult> OnGetAsync(string? category, bool hasSubtasks = false)
{
ActiveCategory = category;
HasSubtasksFilter = hasSubtasks;
await LoadBoardDataAsync();
return Page();
}
public async Task<IActionResult> OnPutStartAsync(int id)
{
var task = await _taskRepo.GetByIdAsync(id);
if (task is null) return NotFound();
var active = await _taskRepo.GetActiveTaskAsync();
if (active is not null && active.Id != id)
{
active.Status = WorkTaskStatus.Paused;
await _taskRepo.UpdateAsync(active);
}
task.Status = WorkTaskStatus.Active;
task.StartedAt ??= DateTime.UtcNow;
await _taskRepo.UpdateAsync(task);
return await ReturnBoardContentAsync();
}
public async Task<IActionResult> OnPutPauseAsync(int id)
{
var task = await _taskRepo.GetByIdAsync(id);
if (task is null) return NotFound();
task.Status = WorkTaskStatus.Paused;
await _taskRepo.UpdateAsync(task);
return await ReturnBoardContentAsync();
}
public async Task<IActionResult> OnPutResumeAsync(int id)
{
var active = await _taskRepo.GetActiveTaskAsync();
if (active is not null)
{
active.Status = WorkTaskStatus.Paused;
await _taskRepo.UpdateAsync(active);
}
var task = await _taskRepo.GetByIdAsync(id);
if (task is null) return NotFound();
task.Status = WorkTaskStatus.Active;
task.StartedAt ??= DateTime.UtcNow;
await _taskRepo.UpdateAsync(task);
return await ReturnBoardContentAsync();
}
public async Task<IActionResult> OnPutCompleteAsync(int id)
{
var task = await _taskRepo.GetByIdAsync(id);
if (task is null) return NotFound();
var incompleteSubtasks = task.SubTasks
.Count(st => st.Status != WorkTaskStatus.Completed && st.Status != WorkTaskStatus.Abandoned);
if (incompleteSubtasks > 0)
{
return BadRequest($"Cannot complete: {incompleteSubtasks} subtask(s) still incomplete.");
}
task.Status = WorkTaskStatus.Completed;
task.CompletedAt = DateTime.UtcNow;
await _taskRepo.UpdateAsync(task);
return await ReturnBoardContentAsync();
}
public async Task<IActionResult> OnDeleteAbandonAsync(int id)
{
await _taskRepo.DeleteAsync(id);
return await ReturnBoardContentAsync();
}
public async Task<IActionResult> OnPostCreateTaskAsync(string title, string? category)
{
if (string.IsNullOrWhiteSpace(title))
return BadRequest("Title is required.");
var task = new WorkTask
{
Title = title.Trim(),
Category = category,
Status = WorkTaskStatus.Pending,
};
await _taskRepo.CreateAsync(task);
return await ReturnBoardContentAsync();
}
// Load task detail panel
public async Task<IActionResult> OnGetTaskDetailAsync(int id)
{
var task = await _taskRepo.GetByIdAsync(id);
if (task is null) return NotFound();
return Partial("Partials/_TaskDetail", task);
}
// Update task fields (inline edit)
public async Task<IActionResult> OnPutUpdateTaskAsync(int id, string? title, string? description, string? category, int? estimatedMinutes)
{
var task = await _taskRepo.GetByIdAsync(id);
if (task is null) return NotFound();
if (title is not null) task.Title = title;
if (description is not null) task.Description = description;
if (category is not null) task.Category = category;
if (estimatedMinutes.HasValue) task.EstimatedMinutes = estimatedMinutes;
await _taskRepo.UpdateAsync(task);
return Partial("Partials/_TaskDetail", task);
}
// Add subtask
public async Task<IActionResult> OnPostAddSubtaskAsync(int id, string title)
{
var parent = await _taskRepo.GetByIdAsync(id);
if (parent is null) return NotFound();
var subtask = new WorkTask
{
Title = title,
ParentTaskId = id,
Status = WorkTaskStatus.Pending
};
await _taskRepo.CreateAsync(subtask);
// Reload parent to get updated subtask list
parent = await _taskRepo.GetByIdAsync(id);
return Partial("Partials/_SubtaskList", parent);
}
// Complete subtask
public async Task<IActionResult> OnPutCompleteSubtaskAsync(int id)
{
var task = await _taskRepo.GetByIdAsync(id);
if (task is null) return NotFound();
task.Status = WorkTaskStatus.Completed;
task.CompletedAt = DateTime.UtcNow;
await _taskRepo.UpdateAsync(task);
// Return parent's subtask list
if (task.ParentTaskId.HasValue)
{
var parent = await _taskRepo.GetByIdAsync(task.ParentTaskId.Value);
if (parent is not null)
return Partial("Partials/_SubtaskList", parent);
}
return Content("");
}
// Add note
public async Task<IActionResult> OnPostAddNoteAsync(int id, string content)
{
var task = await _taskRepo.GetByIdAsync(id);
if (task is null) return NotFound();
task.Notes.Add(new TaskNote
{
Content = content,
Type = NoteType.General,
CreatedAt = DateTime.UtcNow
});
await _taskRepo.UpdateAsync(task);
return Partial("Partials/_NotesList", task);
}
// Search tasks (for Ctrl+K modal)
public async Task<IActionResult> OnGetSearchAsync(string? q)
{
var allTasks = await _taskRepo.GetAllAsync();
List<WorkTask> results;
if (string.IsNullOrWhiteSpace(q))
{
results = allTasks
.Where(t => t.Status is WorkTaskStatus.Active or WorkTaskStatus.Paused or WorkTaskStatus.Pending)
.OrderByDescending(t => t.Status == WorkTaskStatus.Active)
.Take(8)
.ToList();
}
else
{
results = allTasks
.Where(t =>
t.Title.Contains(q, StringComparison.OrdinalIgnoreCase) ||
(t.Description?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false) ||
(t.Category?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false))
.Take(10)
.ToList();
}
return Partial("Partials/_SearchResults", results);
}
private async Task<IActionResult> ReturnBoardContentAsync()
{
await LoadBoardDataAsync();
return Partial("Partials/_KanbanBoard", this);
}
private async Task LoadBoardDataAsync()
{
// Load all tasks with subtasks for category list
var allTasks = await _taskRepo.GetAllAsync(includeSubTasks: true);
// Collect all distinct categories from all tasks (unfiltered)
AllCategories = allTasks
.Where(t => !string.IsNullOrEmpty(t.Category))
.Select(t => t.Category!)
.Distinct()
.OrderBy(c => c)
.ToList();
// Filter to top-level only
var tasks = allTasks.Where(t => t.ParentTaskId == null).ToList();
// Apply category filter
if (!string.IsNullOrEmpty(ActiveCategory))
{
tasks = tasks.Where(t =>
string.Equals(t.Category, ActiveCategory, StringComparison.OrdinalIgnoreCase)).ToList();
}
// Apply hasSubtasks filter
if (HasSubtasksFilter)
{
tasks = tasks.Where(t => t.SubTasks.Count > 0).ToList();
}
// Group by status into columns
var tasksByStatus = tasks.ToLookup(t => t.Status);
Columns = ColumnConfig
.Select(cfg => new ColumnViewModel(
cfg.Status,
cfg.Label,
cfg.Color,
tasksByStatus[cfg.Status].ToList()))
.ToList();
}
}