diff --git a/TaskTracker.Api/Pages/Board.cshtml b/TaskTracker.Api/Pages/Board.cshtml new file mode 100644 index 0000000..45f4be3 --- /dev/null +++ b/TaskTracker.Api/Pages/Board.cshtml @@ -0,0 +1,12 @@ +@page +@model TaskTracker.Api.Pages.BoardModel + +
+ +
+ @foreach (var col in Model.Columns) + { + + } +
+
diff --git a/TaskTracker.Api/Pages/Board.cshtml.cs b/TaskTracker.Api/Pages/Board.cshtml.cs new file mode 100644 index 0000000..8252812 --- /dev/null +++ b/TaskTracker.Api/Pages/Board.cshtml.cs @@ -0,0 +1,216 @@ +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 Tasks); + +[IgnoreAntiforgeryToken] +public class BoardModel : PageModel +{ + private readonly ITaskRepository _taskRepo; + + public BoardModel(ITaskRepository taskRepo) + { + _taskRepo = taskRepo; + } + + public List Columns { get; set; } = new(); + public string? ActiveCategory { get; set; } + public bool HasSubtasksFilter { get; set; } + public List 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 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 OnGetAsync(string? category, bool hasSubtasks = false) + { + ActiveCategory = category; + HasSubtasksFilter = hasSubtasks; + + await LoadBoardDataAsync(); + + return Page(); + } + + public async Task 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 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 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 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 OnDeleteAbandonAsync(int id) + { + await _taskRepo.DeleteAsync(id); + return await ReturnBoardContentAsync(); + } + + public async Task 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(); + } + + private async Task 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(); + } +} diff --git a/TaskTracker.Api/Pages/Partials/_CreateTaskForm.cshtml b/TaskTracker.Api/Pages/Partials/_CreateTaskForm.cshtml new file mode 100644 index 0000000..d2600cc --- /dev/null +++ b/TaskTracker.Api/Pages/Partials/_CreateTaskForm.cshtml @@ -0,0 +1,11 @@ +
+ +
diff --git a/TaskTracker.Api/Pages/Partials/_FilterBar.cshtml b/TaskTracker.Api/Pages/Partials/_FilterBar.cshtml new file mode 100644 index 0000000..5c18795 --- /dev/null +++ b/TaskTracker.Api/Pages/Partials/_FilterBar.cshtml @@ -0,0 +1,55 @@ +@using TaskTracker.Api.Pages +@model BoardModel + +
+ @{ + var isAllActive = string.IsNullOrEmpty(Model.ActiveCategory) && !Model.HasSubtasksFilter; + } + + + All + + + @foreach (var cat in Model.AllCategories) + { + var isActive = string.Equals(Model.ActiveCategory, cat, StringComparison.OrdinalIgnoreCase); + var catColor = BoardModel.GetCategoryColor(cat); + var bgStyle = isActive ? $"background: {catColor}33; border-color: {catColor}66; color: {catColor}" : ""; + + + + @cat + + } + + + + @{ + var subtaskUrl = Model.HasSubtasksFilter + ? (string.IsNullOrEmpty(Model.ActiveCategory) ? "/board" : $"/board?category={Uri.EscapeDataString(Model.ActiveCategory!)}") + : (string.IsNullOrEmpty(Model.ActiveCategory) ? "/board?hasSubtasks=true" : $"/board?category={Uri.EscapeDataString(Model.ActiveCategory!)}&hasSubtasks=true"); + } + + + Has subtasks + +
diff --git a/TaskTracker.Api/Pages/Partials/_KanbanBoard.cshtml b/TaskTracker.Api/Pages/Partials/_KanbanBoard.cshtml new file mode 100644 index 0000000..fcfcd25 --- /dev/null +++ b/TaskTracker.Api/Pages/Partials/_KanbanBoard.cshtml @@ -0,0 +1,12 @@ +@using TaskTracker.Api.Pages +@model BoardModel + +
+ +
+ @foreach (var col in Model.Columns) + { + + } +
+
diff --git a/TaskTracker.Api/Pages/Partials/_KanbanColumn.cshtml b/TaskTracker.Api/Pages/Partials/_KanbanColumn.cshtml new file mode 100644 index 0000000..10b80ff --- /dev/null +++ b/TaskTracker.Api/Pages/Partials/_KanbanColumn.cshtml @@ -0,0 +1,33 @@ +@using TaskTracker.Api.Pages +@using TaskTracker.Core.Enums +@model ColumnViewModel + +
+
+ + @Model.Label + @Model.Tasks.Count +
+
+
+ @if (Model.Tasks.Count > 0) + { + @foreach (var task in Model.Tasks) + { + + } + } + else + { +
+ No tasks +
+ } +
+ @if (Model.Status == WorkTaskStatus.Pending) + { + + } +
diff --git a/TaskTracker.Api/Pages/Partials/_TaskCard.cshtml b/TaskTracker.Api/Pages/Partials/_TaskCard.cshtml new file mode 100644 index 0000000..0729030 --- /dev/null +++ b/TaskTracker.Api/Pages/Partials/_TaskCard.cshtml @@ -0,0 +1,55 @@ +@using TaskTracker.Api.Pages +@using TaskTracker.Core.Enums +@model TaskTracker.Core.Entities.WorkTask + +@{ + var isActive = Model.Status == WorkTaskStatus.Active; + var cardClasses = "task-card card-glow" + (isActive ? " task-card--active" : ""); + var catColor = BoardModel.GetCategoryColor(Model.Category); + var elapsed = BoardModel.FormatElapsed(Model.StartedAt, Model.CompletedAt); + var completedSubtasks = Model.SubTasks.Count(st => st.Status == WorkTaskStatus.Completed || st.Status == WorkTaskStatus.Abandoned); + var totalSubtasks = Model.SubTasks.Count; +} + +
+
+ @if (isActive) + { + + } + @Model.Title +
+
+ @if (!string.IsNullOrEmpty(Model.Category)) + { + + @Model.Category + } + @if (Model.StartedAt is not null) + { + + + + + + @elapsed + + } +
+ @if (totalSubtasks > 0) + { +
+ @foreach (var st in Model.SubTasks) + { + var isDone = st.Status == WorkTaskStatus.Completed || st.Status == WorkTaskStatus.Abandoned; + + } + @completedSubtasks/@totalSubtasks +
+ } +
diff --git a/TaskTracker.Api/wwwroot/css/site.css b/TaskTracker.Api/wwwroot/css/site.css index 4fa0cbb..e94ccf1 100644 --- a/TaskTracker.Api/wwwroot/css/site.css +++ b/TaskTracker.Api/wwwroot/css/site.css @@ -1334,6 +1334,70 @@ body::before { } +/* ============================================================ + FILTER BAR + ============================================================ */ + +.filter-bar { + padding: 0 4px; +} + +.filter-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 12px; + border-radius: 9999px; + font-size: 12px; + font-weight: 500; + color: var(--color-text-secondary); + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--color-border); + cursor: pointer; + transition: all 0.15s; + text-decoration: none; + white-space: nowrap; +} + +.filter-chip:hover { + background: rgba(255, 255, 255, 0.08); + color: var(--color-text-primary); + border-color: var(--color-border-hover); +} + +.filter-chip--active { + background: rgba(139, 92, 246, 0.15); + border-color: rgba(139, 92, 246, 0.3); + color: var(--color-accent); +} + +.filter-chip-dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +.filter-separator { + width: 1px; + height: 20px; + background: var(--color-border); + margin: 0 4px; +} + +.board-page { + display: flex; + flex-direction: column; + height: 100%; + gap: 0; +} + +.board-page .kanban-grid { + flex: 1; + min-height: 0; +} + + /* ============================================================ CREATE TASK FORM (inline in kanban column) ============================================================ */ @@ -1346,7 +1410,7 @@ body::before { } .create-task-form .input { - margin-bottom: 8px; + margin-bottom: 0; } .create-task-form-actions {