User
fix: overall UI
f6d34ff
Raw
History Blame Contribute Delete
25.7 kB
@page "/board"
@page "/projects/{ProjectId:long}/tasks"
@using Microsoft.AspNetCore.Authorization
@using TaskTrackingSystem.Shared.Models.Project
@rendermode @(new InteractiveServerRenderMode(prerender: false))
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@inject ApiClientService ApiClient
@inject NavigationManager Navigation
@inject IJSRuntime JS
@inject AuthenticationStateProvider AuthStateProvider
@inject MenuAuthorizationService MenuAuthorization
<PageTitle>Kanban Board</PageTitle>
<SuccessAlert Message="@successMessage" />
<div class="space-y-6">
<!-- Page Header -->
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
@if (ProjectId > 0)
{
<button @onclick="GoBack" class="rounded-lg p-2 text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition-all">
<i data-lucide="arrow-left" class="h-5 w-5"></i>
</button>
}
<div>
<h2 class="text-3xl font-bold tracking-tight text-slate-900">Kanban Board</h2>
<p class="text-slate-500 mt-1 font-medium">@(project != null ? project.Name : (ProjectId > 0 ? $"Project #{ProjectId}" : "All Projects"))</p>
</div>
</div>
<div class="flex flex-wrap items-center gap-3">
<select value="@selectedProjectId" @onchange="OnProjectFilterChanged" class="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent transition-all">
<option value="0">All Projects</option>
@foreach (var p in projects)
{
<option value="@p.Id">@p.Name</option>
}
</select>
<button @onclick="ToggleArchivedView" class="inline-flex items-center rounded-lg border border-slate-200 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50 transition-colors shadow-sm">
<i data-lucide="@(showArchivedTasks ? "layout-grid" : "archive")" class="h-4 w-4 mr-2 text-slate-500"></i>
@(showArchivedTasks ? "Back to Board" : "Archived Tasks")
</button>
@if (canUpdateTask && !showArchivedTasks)
{
<button @onclick="RequestArchiveDoneConfirmation" class="inline-flex items-center rounded-lg border border-slate-200 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50 transition-colors shadow-sm">
<i data-lucide="archive" class="h-4 w-4 mr-2 text-slate-500"></i>
Archive Done
</button>
}
@if (canCreateTask && !showArchivedTasks)
{
<button @onclick="OpenCreateTaskModal" class="inline-flex items-center rounded-lg bg-violet-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-violet-700 transition-colors shadow-sm">
<i data-lucide="plus" class="h-4 w-4 mr-2"></i>
New Task
</button>
}
</div>
</div>
@if (!string.IsNullOrWhiteSpace(boardError))
{
<div class="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-medium text-rose-700">
@boardError
</div>
}
@if (isLoading)
{
<div class="flex items-center justify-center py-20">
<div class="flex items-center space-x-3 text-slate-500">
<svg class="animate-spin h-5 w-5" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
<span class="text-sm font-medium">Loading board...</span>
</div>
</div>
}
else
{
<!-- Kanban Columns -->
<div class="mb-2 text-sm text-slate-500">
@(showArchivedTasks ? "Viewing archived tasks." : "Drag tasks between columns to update their status.")
</div>
<div class="grid grid-cols-1 @(showArchivedTasks ? "md:grid-cols-1" : "md:grid-cols-3") gap-6">
@foreach (var column in VisibleColumns)
{
var tasksInColumn = VisibleTasks.Where(t => t.StatusId == column.StatusId).ToList();
<div @key="column.StatusId" class="flex flex-col rounded-xl border border-slate-200 bg-slate-50/50 overflow-hidden min-h-[400px]">
<!-- Column Header -->
<div class="flex items-center justify-between px-4 py-3 bg-white border-b border-slate-100">
<div class="flex items-center space-x-2">
<span class="h-2.5 w-2.5 rounded-full @column.DotColor"></span>
<h3 class="text-sm font-semibold text-slate-900">@column.Name</h3>
</div>
<span class="flex h-6 w-6 items-center justify-center rounded-full bg-slate-100 text-xs font-semibold text-slate-600">@tasksInColumn.Count</span>
</div>
<!-- Cards -->
<div class="flex-1 p-3 space-y-3 overflow-y-auto transition-colors @((!showArchivedTasks && dropTargetStatus == column.StatusId) ? "bg-slate-100" : "")"
@ondrop="() => HandleDrop(column.StatusId)"
@ondragenter="() => HandleDragEnter(column.StatusId)"
@ondragleave="HandleDragLeave"
@ondragover="HandleDragOver"
@ondragover:preventDefault="true">
@if (tasksInColumn.Any())
{
@foreach (var task in tasksInColumn)
{
<div @key="task.Id" class="select-none rounded-lg border border-slate-200 bg-white p-4 shadow-sm hover:shadow-md transition-shadow @((canUpdateTask && !showArchivedTasks) ? "cursor-grab active:cursor-grabbing" : "cursor-default")"
data-task-id="@task.Id"
draggable="@((canUpdateTask && !showArchivedTasks).ToString().ToLowerInvariant())"
@ondragstart="() => HandleDragStart(task)"
@ondragend="HandleDragEnd">
<div class="flex items-start justify-between">
<h4 class="text-sm font-semibold text-slate-900">@task.Title</h4>
<span class="text-xs px-2 py-0.5 rounded-full font-medium @GetPriorityBadge(task.PriorityId)">@GetPriorityLabel(task.PriorityId)</span>
</div>
@if (!string.IsNullOrEmpty(task.Description))
{
<p class="text-xs text-slate-500 mt-1.5 line-clamp-2">@task.Description</p>
}
<div class="flex items-center justify-between mt-3 pt-3 border-t border-slate-50">
<span class="text-xs text-slate-400">Due @DisplayFormats.Date(task.DueDate)</span>
@if (showArchivedTasks)
{
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-slate-500">Archived</span>
}
</div>
</div>
}
}
else
{
<div class="flex items-center justify-center py-8 h-full">
<p class="text-xs text-slate-400">Drop tasks here</p>
</div>
}
</div>
</div>
}
</div>
}
</div>
@* ===== Create Task Modal ===== *@
@if (showTaskModal)
{
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div class="bg-white rounded-2xl shadow-lg w-full max-w-lg mx-4 overflow-hidden">
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-100">
<h3 class="text-lg font-semibold text-slate-900">New Task</h3>
<button @onclick="CloseTaskModal" class="rounded-lg p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition-all">
<i data-lucide="x" class="h-5 w-5"></i>
</button>
</div>
<div class="p-6 space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">Project</label>
<select @bind="taskProjectId" disabled="@(ProjectId > 0)" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent transition-all disabled:bg-slate-50 disabled:text-slate-500">
<option value="0">Select project...</option>
@foreach (var p in projects)
{
<option value="@p.Id">@p.Name</option>
}
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">Title</label>
<input @bind="taskTitle" type="text" placeholder="Task title" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent transition-all" />
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">Description</label>
<textarea @bind="taskDescription" rows="3" placeholder="Task description" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent transition-all"></textarea>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">Priority</label>
<select @bind="taskPriorityId" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent transition-all">
<option value="@TaskPriority.Low">Low</option>
<option value="@TaskPriority.Medium">Medium</option>
<option value="@TaskPriority.High">High</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">Due Date</label>
<input @bind="taskDueDate" type="date" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent transition-all" />
</div>
</div>
@if (!string.IsNullOrEmpty(taskError))
{
<div class="p-3 rounded-lg bg-red-50 border border-red-100">
<p class="text-sm text-red-600">@taskError</p>
</div>
}
</div>
<div class="flex items-center justify-end space-x-3 px-6 py-4 border-t border-slate-100 bg-slate-50/50">
<button @onclick="CloseTaskModal" class="rounded-lg px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-100 transition-colors">Cancel</button>
<button @onclick="RequestCreateConfirmation" disabled="@isSaving" class="rounded-lg bg-violet-600 px-4 py-2 text-sm font-medium text-white hover:bg-violet-700 disabled:opacity-50 transition-colors">
@(isSaving ? "Creating..." : "Create Task")
</button>
</div>
</div>
</div>
}
@* ===== Confirm Dialog ===== *@
<ConfirmDialog IsVisible="showConfirmDialog"
Title="@confirmTitle"
Message="@confirmMessage"
ConfirmText="@confirmButtonText"
Icon="@confirmIcon"
IconBgClass="@confirmIconBg"
IconTextClass="@confirmIconText"
ButtonClass="@confirmButtonClass"
OnConfirm="HandleConfirmAction"
OnCancel="CloseConfirmDialog" />
@code {
[Parameter] public long ProjectId { get; set; }
private bool isLoading = true;
private bool isAdmin = false;
private bool isManager = false;
private bool canCreateTask = false;
private bool canUpdateTask = false;
private bool showArchivedTasks = false;
private long currentUserId = 0;
private ProjectDto? project;
private bool showTaskModal = false;
private bool isSaving = false;
private string? taskError;
private string? boardError;
// Confirm dialog state
private bool showConfirmDialog = false;
private string? successMessage;
private string confirmTitle = "";
private string confirmMessage = "";
private string confirmButtonText = "";
private string confirmIcon = "";
private string confirmIconBg = "";
private string confirmIconText = "";
private string confirmButtonClass = "";
private Func<Task>? confirmAction;
private List<TaskDto> tasks = new();
private List<ProjectDto> projects = new();
private long selectedProjectId = 0;
// Task form
private long taskProjectId;
private string taskTitle = "";
private string? taskDescription;
private TaskPriority taskPriorityId = TaskPriority.Medium;
private DateTime taskDueDate = DateTime.Today.AddDays(7);
private List<KanbanColumn> columns = new()
{
new(AppTaskStatus.Todo, "To Do", "bg-slate-400"),
new(AppTaskStatus.InProgress, "In Progress", "bg-blue-500"),
new(AppTaskStatus.Done, "Done", "bg-emerald-500")
};
private TaskDto? draggedTask;
private AppTaskStatus? dropTargetStatus;
private IEnumerable<TaskDto> VisibleTasks =>
selectedProjectId > 0
? tasks.Where(t => t.ProjectId == selectedProjectId)
: tasks;
private IEnumerable<KanbanColumn> VisibleColumns =>
showArchivedTasks
? columns.Where(column => column.StatusId == AppTaskStatus.Done)
: columns;
private Task HandleDragStart(TaskDto task)
{
if (!canUpdateTask)
{
return Task.CompletedTask;
}
if (showArchivedTasks)
{
return Task.CompletedTask;
}
draggedTask = task;
dropTargetStatus = null;
return Task.CompletedTask;
}
private void HandleDragOver()
{
// Allow dropping
}
private void HandleDragEnter(AppTaskStatus statusId)
{
if (draggedTask != null)
{
dropTargetStatus = statusId;
}
}
private void HandleDragLeave()
{
dropTargetStatus = null;
}
private async Task HandleDrop(AppTaskStatus newStatusId)
{
var taskToMove = draggedTask;
if (taskToMove != null && taskToMove.StatusId != newStatusId)
{
await MoveTask(taskToMove.Id, newStatusId);
}
draggedTask = null;
dropTargetStatus = null;
}
private Task HandleDragEnd()
{
draggedTask = null;
dropTargetStatus = null;
return Task.CompletedTask;
}
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var user = authState.User;
isAdmin = user.IsInRole("Admin");
isManager = user.IsInRole("Manager");
canCreateTask = isAdmin || await MenuAuthorization.HasAccessCodeAsync(user, "Tasks_Create");
canUpdateTask = isAdmin || await MenuAuthorization.HasAccessCodeAsync(user, "Tasks_Update");
selectedProjectId = ProjectId > 0 ? ProjectId : 0;
await LoadTasks();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await JS.InvokeVoidAsync("initIcons");
}
private async Task LoadTasks()
{
isLoading = true;
var client = ApiClient.CreateClient();
try
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var user = authState.User;
isAdmin = user.IsInRole("Admin");
isManager = user.IsInRole("Manager");
var idStr = user.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
if (idStr != null) long.TryParse(idStr, out currentUserId);
var tasksTask = client.GetFromJsonAsync<List<TaskDto>>(showArchivedTasks ? "Task/archived" : "Task", Serialization.CaseInsensitive);
var projectsTask = client.GetFromJsonAsync<List<ProjectDto>>("Project", Serialization.CaseInsensitive);
await System.Threading.Tasks.Task.WhenAll(tasksTask, projectsTask);
var allTasks = tasksTask.Result ?? new();
projects = projectsTask.Result ?? new();
project = selectedProjectId > 0 ? projects.FirstOrDefault(p => p.Id == selectedProjectId) : null;
// Filter for non-admins and non-managers
tasks = (!(isAdmin || isManager) && currentUserId > 0)
? allTasks.Where(t => t.AssignedTo == currentUserId).ToList()
: allTasks;
}
catch (Exception ex)
{
Console.WriteLine($"Load tasks error: {ex.Message}");
}
finally
{
isLoading = false;
}
}
private async Task MoveTask(long taskId, AppTaskStatus newStatusId)
{
if (!canUpdateTask)
{
return;
}
var client = ApiClient.CreateClient();
var task = tasks.FirstOrDefault(t => t.Id == taskId);
var previousStatus = task?.StatusId;
if (task != null)
{
task.StatusId = newStatusId;
boardError = null;
StateHasChanged();
}
try
{
var response = await client.PatchAsync($"Task/{taskId}/status?statusId={(long)newStatusId}", null);
if (!response.IsSuccessStatusCode)
{
if (task != null && previousStatus.HasValue)
{
task.StatusId = previousStatus.Value;
}
boardError = "Failed to move task. Please try again.";
}
}
catch (Exception ex)
{
if (task != null && previousStatus.HasValue)
{
task.StatusId = previousStatus.Value;
}
await LoadTasks();
Console.WriteLine($"Move task error: {ex.Message}");
boardError = "Failed to move task. Please try again.";
}
}
private void GoBack() => Navigation.NavigateTo("/projects/all");
private async Task ToggleArchivedView()
{
showArchivedTasks = !showArchivedTasks;
draggedTask = null;
dropTargetStatus = null;
boardError = null;
await LoadTasks();
}
private void OnProjectFilterChanged(ChangeEventArgs e)
{
selectedProjectId = long.TryParse(e.Value?.ToString(), out var value) ? value : 0;
project = selectedProjectId > 0 ? projects.FirstOrDefault(p => p.Id == selectedProjectId) : null;
taskProjectId = selectedProjectId;
boardError = null;
}
private void OpenCreateTaskModal()
{
taskProjectId = selectedProjectId > 0 ? selectedProjectId : projects.FirstOrDefault()?.Id ?? 0;
taskTitle = "";
taskDescription = null;
taskPriorityId = TaskPriority.Medium;
taskDueDate = DateTime.Today.AddDays(7);
taskError = null;
showTaskModal = true;
}
private void CloseTaskModal()
{
showTaskModal = false;
taskError = null;
}
private void RequestCreateConfirmation()
{
if (string.IsNullOrWhiteSpace(taskTitle))
{
taskError = "Task title is required.";
return;
}
if (taskProjectId == 0)
{
taskError = "Please select a project.";
return;
}
confirmTitle = "Create Task?";
confirmMessage = "Are you sure you want to create this task?";
confirmButtonText = "Create";
confirmIcon = "plus-circle";
confirmIconBg = "bg-emerald-50";
confirmIconText = "text-emerald-500";
confirmButtonClass = "bg-violet-600 hover:bg-violet-700";
confirmAction = CreateTask;
showConfirmDialog = true;
}
private async Task HandleConfirmAction()
{
showConfirmDialog = false;
if (confirmAction != null)
await confirmAction();
}
private void CloseConfirmDialog()
{
showConfirmDialog = false;
}
private async Task CreateTask()
{
if (string.IsNullOrWhiteSpace(taskTitle))
{
taskError = "Task title is required.";
return;
}
isSaving = true;
taskError = null;
var client = ApiClient.CreateClient();
try
{
var dto = new CreateTaskDto
{
Title = taskTitle,
Description = taskDescription,
ProjectId = taskProjectId,
StatusId = AppTaskStatus.Todo,
PriorityId = taskPriorityId,
DueDate = taskDueDate
};
var response = await client.PostAsJsonAsync("Task", dto);
if (response.IsSuccessStatusCode)
{
CloseTaskModal();
await LoadTasks();
selectedProjectId = taskProjectId;
project = projects.FirstOrDefault(p => p.Id == selectedProjectId);
successMessage = "Task created successfully!";
}
else
{
taskError = "Failed to create task.";
}
}
catch (Exception ex)
{
taskError = ex.Message;
}
finally
{
isSaving = false;
}
}
private void RequestArchiveDoneConfirmation()
{
var doneCount = VisibleTasks.Count(t => t.StatusId == AppTaskStatus.Done);
if (doneCount == 0)
{
successMessage = "No Done tasks to archive.";
return;
}
confirmTitle = "Archive Done Tasks?";
confirmMessage = selectedProjectId > 0
? $"Archive {doneCount} Done task(s) in this project? Archived tasks will be hidden from active views."
: $"Archive {doneCount} Done task(s) across all visible projects? Archived tasks will be hidden from active views.";
confirmButtonText = "Archive";
confirmIcon = "archive";
confirmIconBg = "bg-slate-100";
confirmIconText = "text-slate-600";
confirmButtonClass = "bg-slate-900 hover:bg-slate-800";
confirmAction = ArchiveDoneTasks;
showConfirmDialog = true;
}
private async Task ArchiveDoneTasks()
{
var client = ApiClient.CreateClient();
boardError = null;
var url = selectedProjectId > 0
? $"Task/archive-done?projectId={selectedProjectId}"
: "Task/archive-done";
try
{
var response = await client.PatchAsync(url, null);
var result = await response.Content.ReadFromJsonAsync<Result<int>>(Serialization.CaseInsensitive);
if (response.IsSuccessStatusCode && result?.IsSuccess == true)
{
await LoadTasks();
successMessage = result.Value == 1
? "Archived 1 Done task."
: $"Archived {result?.Value ?? 0} Done tasks.";
return;
}
boardError = result?.ErrorMessage ?? "Failed to archive Done tasks.";
}
catch (Exception ex)
{
Console.WriteLine($"Archive done tasks error: {ex.Message}");
boardError = "Failed to archive Done tasks.";
}
}
private string GetPriorityBadge(TaskPriority priorityId) => priorityId switch
{
TaskPriority.Low => "bg-slate-100 text-slate-600",
TaskPriority.Medium => "bg-amber-100 text-amber-700",
TaskPriority.High => "bg-red-100 text-red-700",
_ => "bg-slate-100 text-slate-600"
};
private string GetPriorityLabel(TaskPriority priorityId) => priorityId switch
{
TaskPriority.Low => "Low",
TaskPriority.Medium => "Medium",
TaskPriority.High => "High",
_ => "-"
};
private record KanbanColumn(AppTaskStatus StatusId, string Name, string DotColor);
}