User
fix: overall UI
f6d34ff
Raw
History Blame Contribute Delete
45.4 kB
@page "/tasks"
@page "/tasks/all"
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@rendermode @(new InteractiveServerRenderMode(prerender: false))
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@inject ApiClientService ApiClient
@inject IJSRuntime JS
@inject AuthenticationStateProvider AuthStateProvider
@inject MenuAuthorizationService MenuAuthorization
@inject NavigationManager Navigation
<PageTitle>Tasks</PageTitle>
<SuccessAlert Message="@successMessage" />
<div class="space-y-6">
<!-- Page Header -->
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h2 class="text-3xl font-bold tracking-tight text-slate-900">Tasks</h2>
<p class="text-slate-500 mt-1">@(isAdmin ? "View and manage all tasks across projects." : "Your assigned tasks.")</p>
</div>
<div class="flex items-center space-x-4">
@if (canCreateTask)
{
<button @onclick="OpenCreateModal" 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 (isLoading)
{
<LoadingSpinner Message="Loading tasks..." />
}
else
{
<!-- Search and Filters Panel -->
<div class="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<div class="flex flex-wrap items-center gap-4">
<div class="relative flex-1 min-w-[200px]">
<input @bind="searchInput" type="text" placeholder="Search tasks by 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-violet-600 focus:border-transparent transition-all" />
</div>
<div class="w-44 shrink-0">
<select @bind="filterProjectIdInput" 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-violet-600 focus:border-transparent transition-all">
<option value="0">All Projects</option>
@foreach (var p in projects)
{
<option value="@p.Id">@p.Name</option>
}
</select>
</div>
<div class="w-40 shrink-0">
<select @bind="filterStatusIdInput" 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-violet-600 focus:border-transparent transition-all">
<option value="">All Statuses</option>
<option value="@AppTaskStatus.Todo">To Do</option>
<option value="@AppTaskStatus.InProgress">In Progress</option>
<option value="@AppTaskStatus.Done">Done</option>
</select>
</div>
<div class="w-40 shrink-0">
<select @bind="filterPriorityIdInput" 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-violet-600 focus:border-transparent transition-all">
<option value="">All Priorities</option>
<option value="@TaskPriority.Low">Low</option>
<option value="@TaskPriority.Medium">Medium</option>
<option value="@TaskPriority.High">High</option>
</select>
</div>
<button @onclick="ApplySearch" 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 shrink-0">
<i data-lucide="search" class="h-4 w-4 mr-2"></i>
Search
</button>
<button @onclick="ResetSearch" 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 shrink-0">
<i data-lucide="rotate-ccw" class="h-4 w-4 mr-2 text-slate-500"></i>
Reset
</button>
</div>
</div>
<!-- Tasks Table (List View) -->
<div class="rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden mt-4">
<table class="w-full table-fixed">
<colgroup>
<col class="w-[12%]" />
<col class="w-[22%]" />
<col class="w-[11%]" />
<col class="w-[11%]" />
<col class="w-[14%]" />
<col class="w-[16%]" />
<col class="w-[14%]" />
<col class="w-[10%]" />
</colgroup>
<thead>
<tr class="border-b border-slate-100 bg-slate-50/60">
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Task Code</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Task</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Status</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Priority</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Due Date</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Project</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Assignee</th>
<th class="px-6 py-3 text-right text-xs font-semibold text-slate-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
@if (PagedTasks.Any())
{
@foreach (var entry in PagedTasks.Select((task, index) => (task, index)))
{
var task = entry.task;
<tr class="hover:bg-slate-50/50 transition-colors">
<td class="px-6 py-4 text-left">
<span class="font-mono text-[11px] font-semibold text-indigo-700">@GetTaskCode(task.Id)</span>
</td>
<td class="px-6 py-4 text-left">
<a href="/tasks/@task.Id/details" class="text-sm font-semibold text-slate-900 hover:text-violet-600 transition-colors">@task.Title</a>
@if (!string.IsNullOrEmpty(task.Description))
{
<p class="mt-0.5 text-xs text-slate-400 truncate max-w-full">@task.Description</p>
}
</td>
<td class="px-6 py-4 text-left">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium @GetStatusBadge(task.StatusId)">
@GetStatusLabel(task.StatusId)
</span>
</td>
<td class="px-6 py-4 text-left">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium @GetPriorityBadge(task.PriorityId)">
@GetPriorityLabel(task.PriorityId)
</span>
</td>
<td class="px-6 py-4 text-sm text-slate-600 text-left whitespace-nowrap">@DisplayFormats.Date(task.DueDate)</td>
<td class="px-6 py-4 text-sm text-slate-500 text-left break-words">
@{ var proj = projects.FirstOrDefault(p => p.Id == task.ProjectId); }
@(proj?.Name ?? $"#{task.ProjectId}")
</td>
<td class="px-6 py-4 text-sm text-slate-500 font-medium text-left">
@{ var user = allUsers.FirstOrDefault(u => u.Id == task.AssignedTo); }
@if (user != null)
{
<div class="flex items-center gap-2">
<span class="flex h-6 w-6 items-center justify-center rounded-full bg-violet-100 text-violet-700 text-[10px] font-bold">
@(user.FirstName.Length > 0 ? user.FirstName[0].ToString().ToUpper() : "")@(user.LastName.Length > 0 ? user.LastName[0].ToString().ToUpper() : "")
</span>
<span class="text-xs text-slate-700 font-semibold">@user.FirstName @user.LastName</span>
</div>
}
else
{
<span class="text-xs text-slate-400 italic">Unassigned</span>
}
</td>
<td class="px-6 py-4 text-right">
<div class="flex items-center justify-end space-x-2">
@if (canEditTask)
{
<button @onclick="() => OpenEditModal(task)" class="rounded-lg p-1.5 text-slate-400 hover:bg-amber-50 hover:text-amber-600 transition-all" title="Edit">
<i data-lucide="pencil" class="h-4 w-4"></i>
</button>
}
@if (canDeleteTask)
{
<button @onclick="() => RequestDeleteConfirmation(task.Id)" class="rounded-lg p-1.5 text-slate-400 hover:bg-rose-50 hover:text-rose-600 transition-all" title="Delete">
<i data-lucide="trash-2" class="h-4 w-4"></i>
</button>
}
</div>
</td>
</tr>
}
}
else
{
<EmptyState ColSpan="8" Icon="check-square" Title="No tasks yet" Subtitle="Create your first task to get started." />
}
</tbody>
</table>
<!-- Pagination -->
<Pagination CurrentPage="currentPage"
TotalPages="TotalPages"
PageSize="pageSize"
TotalCount="TotalCount"
OnPageChanged="HandlePageChanged"
OnPageSizeChanged="HandlePageSizeChanged" />
</div>
}
</div>
@* ===== Create / Edit Modal ===== *@
@if (showModal)
{
<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">@(isEditing ? "Edit Task" : "New Task")</h3>
<button @onclick="CloseModal" 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">Title <span class="text-red-500">*</span></label>
<div class="relative">
<input @bind="formTitle" type="text" placeholder="Task title" class="w-full rounded-lg border @(isTitleError ? "border-red-500 focus:ring-red-500" : "border-slate-200 focus:ring-slate-900") px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:border-transparent transition-all @(isTitleError ? "pr-10" : "")" />
@if (isTitleError)
{
<svg class="absolute right-3 top-2.5 h-4 w-4 text-red-500" 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"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
}
</div>
@if (isTitleError)
{
<p class="mt-1 text-xs text-red-500">@titleErrorMessage</p>
}
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">Description</label>
<textarea @bind="formDescription" rows="3" placeholder="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">Project <span class="text-red-500">*</span></label>
<select value="@formProjectId" @onchange="OnFormProjectChanged" class="w-full rounded-lg border @(isProjectError ? "border-red-500 focus:ring-red-500" : "border-slate-200 focus:ring-slate-900") px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:border-transparent transition-all">
<option value="0">Select project...</option>
@foreach (var p in projects)
{
<option value="@p.Id">@p.Name</option>
}
</select>
@if (isProjectError)
{
<p class="mt-1 text-xs text-red-500">@projectErrorMessage</p>
}
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">Status</label>
<select @bind="formStatusId" 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="@AppTaskStatus.Todo">To Do</option>
<option value="@AppTaskStatus.InProgress">In Progress</option>
<option value="@AppTaskStatus.Done">Done</option>
</select>
</div>
</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="formPriorityId" 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="formDueDate" 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>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">Assignee</label>
<div class="relative">
<button type="button"
@onclick="ToggleAssigneeDropdown"
class="flex h-11 w-full items-center justify-between rounded-lg border border-slate-200 bg-white px-3 text-left text-sm text-slate-900 shadow-sm transition-colors hover:border-violet-300 hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-900">
<span class="truncate @(formAssignedTo.HasValue ? "font-medium text-slate-900" : "text-slate-400")">@SelectedAssigneeLabel</span>
<i data-lucide="chevron-down" class="ml-3 h-4 w-4 shrink-0 text-slate-400"></i>
</button>
@if (isAssigneeDropdownOpen)
{
<div class="absolute bottom-full left-0 z-30 mb-2 w-full rounded-2xl border border-slate-200 bg-white p-3 shadow-lg">
<div class="flex items-center justify-end">
<button type="button"
@onclick="CloseAssigneeDropdown"
class="inline-flex h-7 w-7 items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600"
aria-label="Close assignee dropdown">
<i data-lucide="x" class="h-4 w-4"></i>
</button>
</div>
<div class="relative">
<input @bind="assigneeSearchInput"
@onfocus="EnsureAssigneeDropdownOpen"
type="text"
placeholder="Search project members..."
class="w-full rounded-xl border border-slate-200 bg-white py-2 pl-9 pr-3 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent" />
<i data-lucide="search" class="absolute left-3 top-2.5 h-4 w-4 text-slate-400"></i>
</div>
@if (isLoadingProjectMembers)
{
<div class="mt-3 rounded-xl border border-dashed border-slate-200 bg-slate-50 px-3 py-4 text-center text-xs text-slate-500">
Loading project members...
</div>
}
else
{
<div class="mt-3 max-h-56 overflow-y-auto space-y-2 pr-1">
<button type="button"
@onclick="() => SetFormAssignee(null)"
class="flex w-full items-center gap-3 rounded-xl border px-3 py-3 text-left transition-colors @(formAssignedTo.HasValue ? "border-slate-200 bg-white hover:bg-slate-50" : "border-violet-600 bg-violet-50/40")">
<span class="flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-slate-500 text-xs font-bold shrink-0">
<i data-lucide="user-minus" class="h-4 w-4"></i>
</span>
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-slate-900">Unassigned</p>
</div>
</button>
@foreach (var user in FilteredProjectMembers)
{
var isSelected = formAssignedTo == user.Id;
<button type="button"
@onclick="() => SetFormAssignee(user.Id)"
class="flex w-full items-center gap-3 rounded-xl border px-3 py-3 text-left transition-colors @(isSelected ? "border-violet-600 bg-violet-50/40" : "border-slate-100 bg-white hover:border-slate-200 hover:bg-slate-50")">
<span class="flex h-9 w-9 items-center justify-center rounded-full bg-violet-100 text-violet-700 text-xs font-bold shrink-0">
@(user.FirstName.Length > 0 ? user.FirstName[0].ToString().ToUpper() : "")@(user.LastName.Length > 0 ? user.LastName[0].ToString().ToUpper() : "")
</span>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-slate-900">@user.FirstName @user.LastName</p>
<p class="truncate text-xs text-slate-500">@@@user.Username</p>
</div>
</button>
}
@if (!FilteredProjectMembers.Any())
{
<div class="rounded-xl border border-dashed border-slate-200 bg-slate-50 px-3 py-4 text-center text-xs text-slate-500">
No members match the current search.
</div>
}
</div>
}
</div>
}
</div>
</div>
</div>
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="rounded-lg bg-rose-50 p-3 border border-rose-200">
<p class="text-xs text-rose-600 font-medium flex items-center gap-1.5">
<svg class="h-4 w-4 shrink-0" 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"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
@errorMessage
</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="CloseModal" class="rounded-lg px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-100 transition-colors">Cancel</button>
<button @onclick="RequestSaveConfirmation" 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 ? "Saving..." : (isEditing ? "Update" : "Create"))
</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]
[SupplyParameterFromQuery(Name = "taskId")]
public long? TaskIdParam { get; set; }
private bool isLoading = true;
private bool isAdmin = false;
private bool isManager = false;
private bool canCreateTask = false;
private bool canEditTask = false;
private bool canDeleteTask = false;
private bool canUpdateTask = false;
private long currentUserId = 0;
private bool showModal = false;
private bool isEditing = false;
private bool isSaving = false;
private bool isAssigneeDropdownOpen = false;
private string? errorMessage;
// Per-field validation
private bool isTitleError = false;
private string titleErrorMessage = "";
private bool isProjectError = false;
private string projectErrorMessage = "";
// Confirm dialog state
private bool showConfirmDialog = false;
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> taskList = new();
private List<ProjectDto> projects = new();
private List<UserDto> allUsers = new();
private List<UserDto> projectMembers = new();
private string? successMessage;
// Search and Filters state
private string searchInput = "";
private long filterProjectIdInput = 0;
private AppTaskStatus? filterStatusIdInput = null;
private TaskPriority? filterPriorityIdInput = null;
// Pagination state
private int currentPage = 1;
private int pageSize = 10;
private int totalCount = 0;
private int totalPages = 0;
private int TotalPages => totalPages;
private int TotalCount => totalCount;
private IEnumerable<TaskDto> PagedTasks => taskList;
// Form fields
private long editingId;
private string formTitle = "";
private string? formDescription;
private long formProjectId;
private AppTaskStatus formStatusId = AppTaskStatus.Todo;
private TaskPriority formPriorityId = TaskPriority.Medium;
private DateTime formDueDate = DateTime.Today.AddDays(7);
private long? formAssignedTo;
private string assigneeSearchInput = "";
private bool isLoadingProjectMembers;
protected override async Task OnInitializedAsync()
{
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);
await LoadTaskAccessAsync(user);
var client = ApiClient.CreateClient();
try
{
var tasksTask = LoadTasksPageAsync(client);
var projectsTask = client.GetFromJsonAsync<List<ProjectDto>>("Project", Serialization.CaseInsensitive);
var usersTask = client.GetFromJsonAsync<List<UserDto>>("User", Serialization.CaseInsensitive);
await System.Threading.Tasks.Task.WhenAll(tasksTask, projectsTask, usersTask);
taskList = tasksTask.Result?.Items ?? new();
totalCount = tasksTask.Result?.TotalCount ?? 0;
totalPages = tasksTask.Result?.TotalPages ?? 0;
currentPage = tasksTask.Result?.Page ?? currentPage;
pageSize = tasksTask.Result?.PageSize ?? pageSize;
projects = projectsTask.Result ?? new();
allUsers = usersTask.Result ?? new();
}
catch (Exception ex)
{
Console.WriteLine($"Load error: {ex.Message}");
}
finally
{
if (TaskIdParam.HasValue && TaskIdParam > 0)
{
await FocusTaskAsync(TaskIdParam.Value);
}
}
isLoading = false;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await JS.InvokeVoidAsync("initIcons");
}
private async Task<PagedResult<TaskDto>?> LoadTasksPageAsync(HttpClient? client = null)
{
client ??= ApiClient.CreateClient();
var response = await client.GetFromJsonAsync<PagedResult<TaskDto>>(BuildTasksUrl(), Serialization.CaseInsensitive);
if (response != null)
{
return response;
}
return null;
}
// Pagination handlers
private async Task HandlePageChanged(int page)
{
currentPage = page;
await LoadTasks();
}
private async Task HandlePageSizeChanged(int size)
{
pageSize = size;
currentPage = 1;
await LoadTasks();
}
private async Task ApplySearch()
{
currentPage = 1;
await LoadTasks();
}
private async Task ResetSearch()
{
searchInput = "";
filterProjectIdInput = 0;
filterStatusIdInput = null;
filterPriorityIdInput = null;
currentPage = 1;
await LoadTasks();
}
private async Task LoadTasks()
{
var client = ApiClient.CreateClient();
var result = await LoadTasksPageAsync(client);
taskList = result?.Items ?? new();
totalCount = result?.TotalCount ?? 0;
totalPages = result?.TotalPages ?? 0;
currentPage = result?.Page ?? currentPage;
pageSize = result?.PageSize ?? pageSize;
}
private string BuildTasksUrl()
{
var query = new List<string>
{
$"page={currentPage}",
$"limit={pageSize}"
};
if (!string.IsNullOrWhiteSpace(searchInput))
{
query.Add($"search={Uri.EscapeDataString(searchInput.Trim())}");
}
if (filterProjectIdInput > 0)
{
query.Add($"projectId={filterProjectIdInput}");
}
if (filterStatusIdInput.HasValue)
{
query.Add($"statusId={(int)filterStatusIdInput.Value}");
}
if (filterPriorityIdInput.HasValue)
{
query.Add($"priorityId={(int)filterPriorityIdInput.Value}");
}
if (!(isAdmin || isManager) && currentUserId > 0)
{
query.Add("assignedOnly=true");
}
return $"Task?{string.Join("&", query)}";
}
private async Task FocusTaskAsync(long taskId)
{
try
{
var client = ApiClient.CreateClient();
var task = await client.GetFromJsonAsync<TaskDto>($"Task/{taskId}", Serialization.CaseInsensitive);
if (task == null)
{
return;
}
searchInput = task.Title;
currentPage = 1;
await LoadTasks();
}
catch (Exception ex)
{
Console.WriteLine($"Focus task error: {ex.Message}");
}
}
private async Task LoadTaskAccessAsync(System.Security.Claims.ClaimsPrincipal user)
{
canCreateTask = isAdmin || await MenuAuthorization.HasAccessCodeAsync(user, "Tasks_Create");
canEditTask = isAdmin || await MenuAuthorization.HasAccessCodeAsync(user, "Tasks_Update");
canDeleteTask = isAdmin || await MenuAuthorization.HasAccessCodeAsync(user, "Tasks_Delete");
canUpdateTask = canEditTask;
}
private IEnumerable<UserDto> FilteredProjectMembers
{
get
{
var result = projectMembers.Where(u => u.IsActive);
if (!string.IsNullOrWhiteSpace(assigneeSearchInput))
{
var search = assigneeSearchInput.Trim();
result = result.Where(u =>
$"{u.FirstName} {u.LastName}".Contains(search, StringComparison.OrdinalIgnoreCase) ||
(!string.IsNullOrWhiteSpace(u.Username) && u.Username.Contains(search, StringComparison.OrdinalIgnoreCase)) ||
(!string.IsNullOrWhiteSpace(u.Email) && u.Email.Contains(search, StringComparison.OrdinalIgnoreCase)));
}
return result
.OrderBy(u => u.FirstName)
.ThenBy(u => u.LastName)
.ToList();
}
}
private string SelectedAssigneeLabel
{
get
{
if (!formAssignedTo.HasValue)
{
return "Unassigned";
}
var selectedUser = projectMembers.FirstOrDefault(u => u.Id == formAssignedTo.Value);
return selectedUser == null
? "Selected member"
: $"{selectedUser.FirstName} {selectedUser.LastName}".Trim();
}
}
private async Task OnFormProjectChanged(ChangeEventArgs e)
{
if (long.TryParse(e.Value?.ToString(), out var projectId))
{
formProjectId = projectId;
}
else
{
formProjectId = 0;
}
formAssignedTo = null;
assigneeSearchInput = "";
await LoadProjectMembersAsync(formProjectId);
}
private void SetFormAssignee(long? userId)
{
formAssignedTo = userId;
errorMessage = null;
isAssigneeDropdownOpen = false;
}
private void OpenAssigneeDropdown()
{
if (formProjectId <= 0)
{
return;
}
isAssigneeDropdownOpen = true;
}
private void ToggleAssigneeDropdown()
{
if (isAssigneeDropdownOpen)
{
CloseAssigneeDropdown();
return;
}
OpenAssigneeDropdown();
}
private void EnsureAssigneeDropdownOpen()
{
if (formProjectId <= 0)
{
return;
}
isAssigneeDropdownOpen = true;
}
private void CloseAssigneeDropdown()
{
isAssigneeDropdownOpen = false;
}
private async Task LoadProjectMembersAsync(long projectId)
{
projectMembers.Clear();
isLoadingProjectMembers = true;
try
{
if (projectId <= 0)
{
return;
}
var client = ApiClient.CreateClient();
var membersResult = await client.GetFromJsonAsync<Result<IEnumerable<UserDto>>>($"Project/{projectId}/members", Serialization.CaseInsensitive);
if (membersResult?.IsSuccess == true && membersResult.Value != null)
{
projectMembers = membersResult.Value
.Where(user => user.IsActive)
.OrderBy(user => user.FirstName)
.ThenBy(user => user.LastName)
.ToList();
if (formAssignedTo.HasValue && !projectMembers.Any(user => user.Id == formAssignedTo.Value))
{
formAssignedTo = null;
}
}
else
{
formAssignedTo = null;
}
}
catch (Exception ex)
{
Console.WriteLine($"Failed to load project members for task form: {ex.Message}");
formAssignedTo = null;
}
finally
{
isLoadingProjectMembers = false;
await InvokeAsync(StateHasChanged);
await JS.InvokeVoidAsync("initIcons");
}
}
private async Task OpenCreateModal()
{
isEditing = false;
editingId = 0;
formTitle = "";
formDescription = null;
formProjectId = projects.FirstOrDefault()?.Id ?? 0;
formStatusId = AppTaskStatus.Todo;
formPriorityId = TaskPriority.Medium;
formDueDate = DateTime.Today.AddDays(7);
formAssignedTo = null;
assigneeSearchInput = "";
errorMessage = null;
isTitleError = false; titleErrorMessage = "";
isProjectError = false; projectErrorMessage = "";
showModal = true;
await LoadProjectMembersAsync(formProjectId);
}
private async Task OpenEditModal(TaskDto task)
{
isEditing = true;
editingId = task.Id;
formTitle = task.Title;
formDescription = task.Description;
formProjectId = task.ProjectId;
formStatusId = task.StatusId;
formPriorityId = task.PriorityId;
formDueDate = task.DueDate;
formAssignedTo = task.AssignedTo;
assigneeSearchInput = "";
errorMessage = null;
isTitleError = false; titleErrorMessage = "";
isProjectError = false; projectErrorMessage = "";
showModal = true;
await LoadProjectMembersAsync(formProjectId);
}
private void CloseModal()
{
showModal = false;
errorMessage = null;
isTitleError = false; titleErrorMessage = "";
isProjectError = false; projectErrorMessage = "";
assigneeSearchInput = "";
isAssigneeDropdownOpen = false;
projectMembers.Clear();
isLoadingProjectMembers = false;
}
private void RequestSaveConfirmation()
{
isTitleError = false; titleErrorMessage = "";
isProjectError = false; projectErrorMessage = "";
bool hasError = false;
if (string.IsNullOrWhiteSpace(formTitle))
{
isTitleError = true;
titleErrorMessage = "Task title is required.";
hasError = true;
}
if (formProjectId == 0)
{
isProjectError = true;
projectErrorMessage = "Please select a project.";
hasError = true;
}
if (hasError) return;
if (isEditing)
{
confirmTitle = "Update Task?";
confirmMessage = "Are you sure you want to update this task?";
confirmButtonText = "Update";
confirmIcon = "pencil";
confirmIconBg = "bg-amber-50";
confirmIconText = "text-amber-500";
confirmButtonClass = "bg-violet-600 hover:bg-violet-700";
}
else
{
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 = SaveTask;
showConfirmDialog = true;
}
private void RequestDeleteConfirmation(long id)
{
confirmTitle = "Delete Task?";
confirmMessage = "Are you sure you want to delete this task? This action cannot be undone.";
confirmButtonText = "Delete";
confirmIcon = "trash-2";
confirmIconBg = "bg-rose-50";
confirmIconText = "text-rose-500";
confirmButtonClass = "bg-violet-600 hover:bg-violet-700";
confirmAction = () => DeleteTask(id);
showConfirmDialog = true;
}
private async Task HandleConfirmAction()
{
showConfirmDialog = false;
if (confirmAction != null)
await confirmAction();
}
private void CloseConfirmDialog()
{
showConfirmDialog = false;
}
private async Task SaveTask()
{
if (string.IsNullOrWhiteSpace(formTitle))
{
errorMessage = ResultMessages.TaskTitleRequired;
return;
}
if (formProjectId == 0)
{
errorMessage = ResultMessages.SelectProjectRequired;
return;
}
isSaving = true;
errorMessage = null;
var client = ApiClient.CreateClient();
try
{
if (isEditing)
{
var dto = new UpdateTaskDto
{
Title = formTitle,
Description = formDescription,
StatusId = formStatusId,
PriorityId = formPriorityId,
DueDate = formDueDate,
AssignedTo = formAssignedTo
};
var response = await client.PutAsJsonAsync($"Task/{editingId}", dto);
var res = await response.Content.ReadFromJsonAsync<Result>(Serialization.CaseInsensitive);
if (res == null || !res.IsSuccess)
{
errorMessage = res?.ErrorMessage ?? ResultMessages.FailedToUpdateTask;
return;
}
}
else
{
var dto = new CreateTaskDto
{
Title = formTitle,
Description = formDescription,
ProjectId = formProjectId,
StatusId = formStatusId,
PriorityId = formPriorityId,
DueDate = formDueDate,
AssignedTo = formAssignedTo
};
var response = await client.PostAsJsonAsync("Task", dto);
var res = await response.Content.ReadFromJsonAsync<Result<TaskDto>>(Serialization.CaseInsensitive);
if (res == null || !res.IsSuccess)
{
errorMessage = res?.ErrorMessage ?? ResultMessages.FailedToCreateTask;
return;
}
}
CloseModal();
await LoadTasks();
successMessage = isEditing ? "Task updated successfully!" : "Task created successfully!";
}
catch (Exception ex)
{
errorMessage = ex.Message;
}
finally
{
isSaving = false;
}
}
private async Task DeleteTask(long id)
{
var client = ApiClient.CreateClient();
try
{
var response = await client.DeleteAsync($"Task/{id}");
var res = await response.Content.ReadFromJsonAsync<Result>(Serialization.CaseInsensitive);
if (res == null || !res.IsSuccess)
{
await JS.InvokeVoidAsync("alert", res?.ErrorMessage ?? "Failed to delete task.");
return;
}
await LoadTasks();
if (currentPage > TotalPages && TotalPages > 0)
currentPage = TotalPages;
successMessage = "Task deleted successfully!";
}
catch (Exception ex)
{
Console.WriteLine($"Delete error: {ex.Message}");
}
}
private string GetStatusBadge(AppTaskStatus statusId) => statusId switch
{
AppTaskStatus.Todo => "bg-slate-100 text-slate-700",
AppTaskStatus.InProgress => "bg-blue-100 text-blue-700",
AppTaskStatus.Done => "bg-emerald-100 text-emerald-700",
_ => "bg-slate-100 text-slate-600"
};
private string GetStatusLabel(AppTaskStatus statusId) => statusId switch
{
AppTaskStatus.Todo => "To Do",
AppTaskStatus.InProgress => "In Progress",
AppTaskStatus.Done => "Done",
_ => "Unknown"
};
private static string GetTaskCode(long taskId) => $"TSK-{taskId:D4}";
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",
_ => "-"
};
}