324 lines
10 KiB
C#
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();
|
|
}
|
|
}
|