feat(web): add Board page with Kanban columns and task cards
Server-rendered Kanban board with 4 status columns (Pending, Active, Paused, Completed), task cards with category colors and elapsed time, filter bar with category chips and subtask toggle, and inline create task form. All handlers support htmx partial updates for status transitions (start, pause, resume, complete, abandon, create). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
11
TaskTracker.Api/Pages/Partials/_CreateTaskForm.cshtml
Normal file
11
TaskTracker.Api/Pages/Partials/_CreateTaskForm.cshtml
Normal file
@@ -0,0 +1,11 @@
|
||||
<form hx-post="/board?handler=CreateTask"
|
||||
hx-target="#board-content"
|
||||
hx-select="#board-content"
|
||||
hx-swap="outerHTML"
|
||||
class="create-task-form mt-2">
|
||||
<input type="text"
|
||||
name="title"
|
||||
placeholder="New task..."
|
||||
class="input"
|
||||
autocomplete="off" />
|
||||
</form>
|
||||
55
TaskTracker.Api/Pages/Partials/_FilterBar.cshtml
Normal file
55
TaskTracker.Api/Pages/Partials/_FilterBar.cshtml
Normal file
@@ -0,0 +1,55 @@
|
||||
@using TaskTracker.Api.Pages
|
||||
@model BoardModel
|
||||
|
||||
<div class="filter-bar flex items-center gap-2 mb-4 flex-wrap">
|
||||
@{
|
||||
var isAllActive = string.IsNullOrEmpty(Model.ActiveCategory) && !Model.HasSubtasksFilter;
|
||||
}
|
||||
|
||||
<a href="/board"
|
||||
hx-get="/board"
|
||||
hx-target="#board-content"
|
||||
hx-select="#board-content"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
class="filter-chip @(isAllActive ? "filter-chip--active" : "")">
|
||||
All
|
||||
</a>
|
||||
|
||||
@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}" : "";
|
||||
|
||||
<a href="/board?category=@Uri.EscapeDataString(cat)"
|
||||
hx-get="/board?category=@Uri.EscapeDataString(cat)"
|
||||
hx-target="#board-content"
|
||||
hx-select="#board-content"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
class="filter-chip @(isActive ? "filter-chip--active" : "")"
|
||||
style="@bgStyle">
|
||||
<span class="filter-chip-dot" style="background: @catColor"></span>
|
||||
@cat
|
||||
</a>
|
||||
}
|
||||
|
||||
<span class="filter-separator"></span>
|
||||
|
||||
@{
|
||||
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");
|
||||
}
|
||||
|
||||
<a href="@subtaskUrl"
|
||||
hx-get="@subtaskUrl"
|
||||
hx-target="#board-content"
|
||||
hx-select="#board-content"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
class="filter-chip @(Model.HasSubtasksFilter ? "filter-chip--active" : "")">
|
||||
Has subtasks
|
||||
</a>
|
||||
</div>
|
||||
12
TaskTracker.Api/Pages/Partials/_KanbanBoard.cshtml
Normal file
12
TaskTracker.Api/Pages/Partials/_KanbanBoard.cshtml
Normal file
@@ -0,0 +1,12 @@
|
||||
@using TaskTracker.Api.Pages
|
||||
@model BoardModel
|
||||
|
||||
<div id="board-content" class="board-page">
|
||||
<partial name="Partials/_FilterBar" model="Model" />
|
||||
<div id="kanban-board" class="kanban-grid">
|
||||
@foreach (var col in Model.Columns)
|
||||
{
|
||||
<partial name="Partials/_KanbanColumn" model="col" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
33
TaskTracker.Api/Pages/Partials/_KanbanColumn.cshtml
Normal file
33
TaskTracker.Api/Pages/Partials/_KanbanColumn.cshtml
Normal file
@@ -0,0 +1,33 @@
|
||||
@using TaskTracker.Api.Pages
|
||||
@using TaskTracker.Core.Enums
|
||||
@model ColumnViewModel
|
||||
|
||||
<div class="kanban-column">
|
||||
<div class="kanban-column-header">
|
||||
<span class="kanban-column-dot" style="background: @Model.Color"></span>
|
||||
<span class="kanban-column-title">@Model.Label</span>
|
||||
<span class="kanban-column-count">@Model.Tasks.Count</span>
|
||||
</div>
|
||||
<div class="kanban-column-bar" style="background: @(Model.Color)33"></div>
|
||||
<div class="kanban-column-body"
|
||||
data-status="@Model.Status"
|
||||
id="column-@Model.Status">
|
||||
@if (Model.Tasks.Count > 0)
|
||||
{
|
||||
@foreach (var task in Model.Tasks)
|
||||
{
|
||||
<partial name="Partials/_TaskCard" model="task" />
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="kanban-column-empty">
|
||||
<span>No tasks</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (Model.Status == WorkTaskStatus.Pending)
|
||||
{
|
||||
<partial name="Partials/_CreateTaskForm" />
|
||||
}
|
||||
</div>
|
||||
55
TaskTracker.Api/Pages/Partials/_TaskCard.cshtml
Normal file
55
TaskTracker.Api/Pages/Partials/_TaskCard.cshtml
Normal file
@@ -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;
|
||||
}
|
||||
|
||||
<div class="@cardClasses"
|
||||
id="task-@Model.Id"
|
||||
data-task-id="@Model.Id"
|
||||
hx-get="/board?handler=TaskDetail&id=@Model.Id"
|
||||
hx-target="#detail-panel"
|
||||
hx-swap="innerHTML">
|
||||
<div class="task-card-title">
|
||||
@if (isActive)
|
||||
{
|
||||
<span class="live-dot"></span>
|
||||
}
|
||||
@Model.Title
|
||||
</div>
|
||||
<div class="task-card-meta">
|
||||
@if (!string.IsNullOrEmpty(Model.Category))
|
||||
{
|
||||
<span class="task-card-category-dot" style="background: @catColor"></span>
|
||||
<span>@Model.Category</span>
|
||||
}
|
||||
@if (Model.StartedAt is not null)
|
||||
{
|
||||
<span class="task-card-elapsed ml-auto">
|
||||
<svg class="icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
@elapsed
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
@if (totalSubtasks > 0)
|
||||
{
|
||||
<div class="task-card-subtasks">
|
||||
@foreach (var st in Model.SubTasks)
|
||||
{
|
||||
var isDone = st.Status == WorkTaskStatus.Completed || st.Status == WorkTaskStatus.Abandoned;
|
||||
<span class="task-card-subtask-dot @(isDone ? "task-card-subtask-dot--done" : "")"></span>
|
||||
}
|
||||
<span class="text-xs text-secondary ml-auto">@completedSubtasks/@totalSubtasks</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
Reference in New Issue
Block a user