User
add: language switch
8c88a89
Raw
History Blame Contribute Delete
28 kB
@page "/tasks/assign"
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using TaskTrackingSystem.Shared.Models.Task
@using TaskTrackingSystem.Shared.Models.Project
@using TaskTrackingSystem.Shared.Models.Role
@rendermode @(new InteractiveServerRenderMode(prerender: false))
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@inject ApiClientService ApiClient
@inject IJSRuntime JS
@inject NavigationManager Navigation
@inject AuthenticationStateProvider AuthStateProvider
@inject MenuAuthorizationService MenuAuthorization
<PageTitle>@AppLocalization.PageTitle("taskAssignment", "Task Assignment")</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">@AppLocalization.PageTitle("taskAssignment", "Task Assignment")</h2>
<p class="text-slate-500 mt-1">Assign tasks to project members and track task owners.</p>
</div>
</div>
@if (isLoading)
{
<LoadingSpinner Message="@AppLocalization.Text("common.loadingTasksAndUsers", "Loading tasks and users...")" />
}
else
{
<!-- Project Filter Bar -->
<div class="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<div class="flex items-center gap-4">
<label class="text-sm font-semibold text-slate-700">Filter by Project:</label>
<div class="w-64">
<select @bind="selectedProjectIdInput"
class="w-full rounded-lg border border-slate-200 px-3 py-1.5 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>
<button @onclick="ApplyProjectFilter" class="inline-flex items-center rounded-lg bg-violet-600 px-4 py-1.5 text-xs font-medium text-white hover:bg-violet-700 transition-colors shadow-sm">
<i data-lucide="search" class="h-3.5 w-3.5 mr-1.5"></i> Search
</button>
<button @onclick="ResetProjectFilter" class="inline-flex items-center rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50 transition-colors shadow-sm">
<i data-lucide="rotate-ccw" class="h-3.5 w-3.5 mr-1.5 text-slate-500"></i> Reset
</button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Left Panel: Tasks List -->
<div class="lg:col-span-1 rounded-xl border border-slate-200 bg-white shadow-sm flex flex-col h-[550px] overflow-hidden">
<div class="p-4 border-b border-slate-100 bg-slate-50/60">
<h3 class="font-bold text-slate-800 text-sm mb-3">Tasks</h3>
<div class="relative flex gap-2">
<div class="relative flex-1">
<input @bind="taskSearchInput" type="text" placeholder="@AppLocalization.Text("common.searchTasks", "Search tasks...")"
class="w-full rounded-lg border border-slate-200 pl-9 pr-3 py-1.5 text-xs text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" />
<i data-lucide="search" class="absolute left-3 top-2.5 h-3.5 w-3.5 text-slate-400"></i>
</div>
<button @onclick="ApplyTaskSearch" class="inline-flex items-center rounded-lg bg-violet-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-violet-700 transition-colors shrink-0">
<i data-lucide="search" class="h-3.5 w-3.5 mr-1.5"></i> Search
</button>
<button @onclick="ResetTaskSearch" class="inline-flex items-center rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50 transition-colors shrink-0">
<i data-lucide="rotate-ccw" class="h-3.5 w-3.5 mr-1.5 text-slate-500"></i> Reset
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto divide-y divide-slate-100">
@if (FilteredTasks.Any())
{
@foreach (var task in FilteredTasks)
{
var isSelected = SelectedTask?.Id == task.Id;
var proj = projects.FirstOrDefault(p => p.Id == task.ProjectId);
<button @onclick="() => SelectTask(task)"
class="w-full text-left p-4 hover:bg-slate-50/60 transition-colors flex flex-col gap-1.5 @(isSelected ? "bg-violet-50/60 border-l-4 border-violet-600 pl-3" : "")">
<div class="flex items-center justify-between">
<span class="text-xs font-mono font-bold text-slate-400">TSK-@task.Id</span>
<span class="inline-flex items-center px-1.5 py-0.5 rounded-full text-[9px] font-semibold @GetStatusBadge(task.StatusId)">
@GetStatusLabel(task.StatusId)
</span>
</div>
<span class="text-sm font-semibold text-slate-900 @(isSelected ? "text-violet-700" : "")">@task.Title</span>
<div class="flex items-center justify-between text-[10px] text-slate-500">
<span>@(proj?.Name ?? AppLocalization.Text("common.noProject", "No Project"))</span>
<span class="flex items-center">
<i data-lucide="user" class="h-3 w-3 mr-1"></i>
@{ var assignee = allUsers.FirstOrDefault(u => u.Id == task.AssignedTo); }
@(assignee != null ? $"{assignee.FirstName} {assignee.LastName}" : AppLocalization.Text("common.unassigned", "Unassigned"))
</span>
</div>
</button>
}
}
else
{
<div class="p-8 text-center text-slate-400 text-xs">@AppLocalization.Text("common.noTasksFound", "No tasks found.")</div>
}
</div>
</div>
<!-- Right Panel: Assignee Picker -->
<div class="lg:col-span-2 rounded-xl border border-slate-200 bg-white shadow-sm flex flex-col h-[550px] overflow-hidden">
@if (SelectedTask == null)
{
<div class="flex-1 flex flex-col items-center justify-center p-8 text-center bg-slate-50/30">
<div class="h-12 w-12 rounded-full bg-slate-100 flex items-center justify-center text-slate-400 mb-3">
<i data-lucide="check-square" class="h-6 w-6"></i>
</div>
<h4 class="text-sm font-bold text-slate-800">No Task Selected</h4>
<p class="text-xs text-slate-500 mt-1 max-w-sm">@AppLocalization.Text("task.selectTaskPrompt", "Select a task from the left panel to assign it to a team member.")</p>
</div>
}
else
{
<!-- Selected Task Details -->
<div class="p-6 border-b border-slate-100 bg-slate-50">
<div class="flex items-center gap-2 mb-2">
<span class="text-[10px] font-bold text-violet-600 uppercase tracking-wider">@AppLocalization.Text("common.currentlyAssigningTask", "Currently Assigning Task")</span>
<span class="text-[10px] font-mono font-semibold text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded">TSK-@SelectedTask.Id</span>
</div>
<h3 class="text-xl font-bold text-slate-900">@SelectedTask.Title</h3>
@if (!string.IsNullOrEmpty(SelectedTask.Description))
{
<p class="text-xs text-slate-500 mt-1 line-clamp-2">@SelectedTask.Description</p>
}
<div class="flex flex-wrap items-center gap-3 mt-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold @GetStatusBadge(SelectedTask.StatusId)">
Status: @GetStatusLabel(SelectedTask.StatusId)
</span>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold @GetPriorityBadge(SelectedTask.PriorityId)">
Priority: @GetPriorityLabel(SelectedTask.PriorityId)
</span>
<span class="text-xs text-slate-500">
<i data-lucide="calendar" class="h-3 w-3 inline mr-1 text-slate-400"></i>
Due: @DisplayFormats.Date(SelectedTask.DueDate)
</span>
</div>
</div>
<!-- Filter & User Selection -->
<div class="px-6 py-3 border-b border-slate-100 bg-slate-50/30 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div class="relative max-w-xs flex-1 flex gap-2">
<div class="relative flex-1">
<input @bind="userSearchInput" type="text" placeholder="@AppLocalization.Text("common.searchTeamMembers", "Search team members...")"
class="w-full rounded-lg border border-slate-200 pl-9 pr-3 py-1.5 text-xs text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" />
<i data-lucide="search" class="absolute left-3 top-2.5 h-3.5 w-3.5 text-slate-400"></i>
</div>
<button @onclick="ApplyUserSearch" class="inline-flex items-center rounded-lg bg-violet-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-violet-700 transition-colors shrink-0">
<i data-lucide="search" class="h-3.5 w-3.5 mr-1.5"></i> Search
</button>
<button @onclick="ResetUserSearch" class="inline-flex items-center rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50 transition-colors shrink-0">
<i data-lucide="rotate-ccw" class="h-3.5 w-3.5 mr-1.5 text-slate-500"></i> Reset
</button>
</div>
@if (isAdminUser)
{
<div class="w-40 shrink-0">
<select @bind="selectedRoleId" class="w-full rounded-lg border border-slate-200 px-3 py-1.5 text-xs text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all">
<option value="0">@AppLocalization.Text("common.allRoles", "All Roles")</option>
@foreach (var r in roles)
{
<option value="@r.Id">@r.Name</option>
}
</select>
</div>
}
else
{
<div class="text-xs font-semibold text-violet-700 bg-violet-50 border border-violet-100 px-3 py-1.5 rounded-lg shrink-0">
Employees in this project
</div>
}
<div class="text-xs font-semibold text-slate-600 shrink-0">
@AppLocalization.Text("common.selected", "Selected"): <span class="text-violet-700 font-bold">@(TargetAssigneeId.HasValue ? allUsers.FirstOrDefault(u => u.Id == TargetAssigneeId)?.Username ?? AppLocalization.Text("common.selected", "Selected") : AppLocalization.Text("common.unassigned", "Unassigned"))</span>
</div>
</div>
<!-- Members picker -->
<div class="flex-1 overflow-y-auto p-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<!-- Unassigned option -->
<label class="flex items-center gap-3 p-3 rounded-xl border border-dashed border-slate-200 bg-white hover:bg-slate-50/50 cursor-pointer transition-all select-none @(!TargetAssigneeId.HasValue ? "border-violet-600 bg-violet-50/10" : "")">
<input type="radio" name="assigneeRadio" checked="@(!TargetAssigneeId.HasValue)"
@onchange="() => SetTargetAssignee(null)"
class="h-4 w-4 text-violet-600 focus:ring-violet-500" />
<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-800">@AppLocalization.Text("common.unassigned", "Unassigned")</p>
<p class="text-xs text-slate-400">@AppLocalization.Text("common.removeOwnerFromTask", "Remove owner from this task")</p>
</div>
</label>
@foreach (var user in FilteredUsers)
{
var isSelected = TargetAssigneeId == user.Id;
<label class="flex items-center gap-3 p-3 rounded-xl border border-slate-100 bg-white hover:bg-slate-50/50 hover:border-slate-200 cursor-pointer transition-all shadow-sm select-none @(isSelected ? "border-violet-600 bg-violet-50/10" : "")">
<input type="radio" name="assigneeRadio" checked="@isSelected"
@onchange="() => SetTargetAssignee(user.Id)"
class="h-4 w-4 text-violet-600 focus:ring-violet-500" />
<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="text-sm font-semibold text-slate-800 truncate">@user.FirstName @user.LastName</p>
<p class="text-xs text-slate-400 truncate">@@@user.Username</p>
</div>
</label>
}
</div>
</div>
<!-- Action bar -->
<div class="p-6 border-t border-slate-100 bg-slate-50/50 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
@if (!string.IsNullOrEmpty(statusMessage))
{
<p class="text-sm font-medium @(isError ? "text-red-600" : "text-emerald-700")">
<i data-lucide="@(isError ? "alert-circle" : "check-circle")" class="h-4 w-4 inline mr-1.5"></i>
@statusMessage
</p>
}
</div>
@if (canSaveAssignments)
{
<button @onclick="RequestSaveConfirmation" disabled="@isSaving"
class="w-full sm:w-auto inline-flex items-center justify-center rounded-lg bg-violet-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-violet-700 disabled:opacity-50 transition-colors shadow-sm">
<i data-lucide="user-check" class="h-4 w-4 mr-2"></i>
@(isSaving ? AppLocalization.Text("common.loadingSpinner", "Loading...") : AppLocalization.Text("common.saveAssignment", "Save Assignment"))
</button>
}
</div>
}
</div>
</div>
}
</div>
<ConfirmDialog IsVisible="showConfirmDialog"
Title="@AppLocalization.Text("task.saveAssignmentTitle", "Save Assignment?")"
Message="@AppLocalization.Text("task.saveAssignmentConfirm", "Are you sure you want to save this task assignment?")"
ConfirmText="@AppLocalization.Text("issue.yesSave", "Yes, Save")"
Icon="user-check"
OnConfirm="ConfirmSaveAssignment"
OnCancel="CloseConfirmDialog" />
@code {
private bool isLoading = true;
private bool isSaving = false;
private bool canSaveAssignments = false;
private bool showConfirmDialog = false;
private string? statusMessage;
private string? successMessage;
private bool isError = false;
private List<TaskDto> tasks = new();
private List<ProjectDto> projects = new();
private List<UserDto> allUsers = new();
private List<UserDto> projectMembers = new();
private List<RoleDto> roles = new();
private long? adminRoleId;
private long? employeeRoleId;
private bool isAdminUser;
private long selectedProjectId = 0;
private long selectedProjectIdInput = 0;
private long selectedRoleId = 0;
private string taskSearchInput = "";
private string taskSearch = "";
private string userSearchInput = "";
private string userSearch = "";
private TaskDto? SelectedTask { get; set; }
private long? TargetAssigneeId { get; set; }
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
isAdminUser = authState.User.IsInRole("Admin");
canSaveAssignments = authState.User.IsInRole("Admin") || await MenuAuthorization.HasAccessCodeAsync(authState.User, "Tasks_Update");
var client = ApiClient.CreateClient();
try
{
var tasksTask = client.GetFromJsonAsync<List<TaskDto>>("Task", Serialization.CaseInsensitive);
var projectsTask = client.GetFromJsonAsync<List<ProjectDto>>("Project", Serialization.CaseInsensitive);
var usersTask = client.GetFromJsonAsync<List<UserDto>>("User", Serialization.CaseInsensitive);
var rolesTask = client.GetFromJsonAsync<List<RoleDto>>("Role", Serialization.CaseInsensitive);
await Task.WhenAll(tasksTask, projectsTask, usersTask, rolesTask);
tasks = tasksTask.Result ?? new();
projects = projectsTask.Result ?? new();
allUsers = usersTask.Result ?? new();
var allLoadedRoles = rolesTask.Result ?? new();
var adminRole = allLoadedRoles.FirstOrDefault(r => string.Equals(r.Name, "Admin", StringComparison.OrdinalIgnoreCase));
var employeeRole = allLoadedRoles.FirstOrDefault(r => string.Equals(r.Name, "Employee", StringComparison.OrdinalIgnoreCase));
adminRoleId = adminRole?.Id;
employeeRoleId = employeeRole?.Id;
roles = allLoadedRoles.Where(r => !string.Equals(r.Name, "Admin", StringComparison.OrdinalIgnoreCase)).ToList();
if (!isAdminUser && employeeRoleId.HasValue)
{
selectedRoleId = employeeRoleId.Value;
}
}
catch (Exception ex)
{
Console.WriteLine($"Error loading initial data: {ex.Message}");
}
finally
{
isLoading = false;
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await JS.InvokeVoidAsync("initIcons");
}
private IEnumerable<TaskDto> FilteredTasks
{
get
{
var result = tasks.AsEnumerable();
if (selectedProjectId > 0)
{
result = result.Where(t => t.ProjectId == selectedProjectId);
}
if (!string.IsNullOrWhiteSpace(taskSearch))
{
result = result.Where(t => t.Title.Contains(taskSearch, StringComparison.OrdinalIgnoreCase)
|| (t.Description != null && t.Description.Contains(taskSearch, StringComparison.OrdinalIgnoreCase)));
}
return result;
}
}
private IEnumerable<UserDto> FilteredUsers
{
get
{
var result = projectMembers.Where(u => u.IsActive);
if (adminRoleId.HasValue)
{
result = result.Where(u => u.RoleId != adminRoleId.Value);
}
if (isAdminUser && selectedRoleId > 0)
{
result = result.Where(u => u.RoleId == selectedRoleId);
}
else if (!isAdminUser && employeeRoleId.HasValue)
{
result = result.Where(u => u.RoleId == employeeRoleId.Value);
}
if (!string.IsNullOrWhiteSpace(userSearch))
{
result = result.Where(u => $"{u.FirstName} {u.LastName}".Contains(userSearch, StringComparison.OrdinalIgnoreCase)
|| u.Username.Contains(userSearch, StringComparison.OrdinalIgnoreCase));
}
return result;
}
}
private void ApplyTaskSearch()
{
taskSearch = taskSearchInput;
}
private void ApplyUserSearch()
{
userSearch = userSearchInput;
}
private void ApplyProjectFilter()
{
selectedProjectId = selectedProjectIdInput;
SelectedTask = null;
TargetAssigneeId = null;
projectMembers.Clear();
statusMessage = null;
}
private void ResetProjectFilter()
{
selectedProjectIdInput = 0;
selectedProjectId = 0;
SelectedTask = null;
TargetAssigneeId = null;
projectMembers.Clear();
statusMessage = null;
}
private void ResetTaskSearch()
{
taskSearchInput = "";
taskSearch = "";
}
private void ResetUserSearch()
{
userSearchInput = "";
userSearch = "";
selectedRoleId = 0;
}
private async Task SelectTask(TaskDto task)
{
SelectedTask = task;
TargetAssigneeId = task.AssignedTo;
statusMessage = null;
await LoadProjectMembersAsync(task.ProjectId);
}
private void SetTargetAssignee(long? userId)
{
TargetAssigneeId = userId;
statusMessage = null;
}
private void RequestSaveConfirmation()
{
if (SelectedTask == null) return;
showConfirmDialog = true;
}
private void CloseConfirmDialog()
{
showConfirmDialog = false;
}
private async Task ConfirmSaveAssignment()
{
showConfirmDialog = false;
await SaveAssignment();
}
private async Task LoadProjectMembersAsync(long projectId)
{
projectMembers.Clear();
if (projectId <= 0)
{
return;
}
var client = ApiClient.CreateClient();
try
{
var membersResult = await client.GetFromJsonAsync<Result<IEnumerable<UserDto>>>($"Project/{projectId}/members", Serialization.CaseInsensitive);
if (membersResult?.IsSuccess == true && membersResult.Value != null)
{
projectMembers = membersResult.Value.ToList();
}
}
catch (Exception ex)
{
Console.WriteLine($"Error loading project members: {ex.Message}");
statusMessage = "Failed to load project members.";
isError = true;
}
finally
{
await InvokeAsync(StateHasChanged);
await JS.InvokeVoidAsync("initIcons");
}
}
private async Task SaveAssignment()
{
if (SelectedTask == null) return;
isSaving = true;
statusMessage = null;
var client = ApiClient.CreateClient();
try
{
var dto = new UpdateTaskDto
{
Title = SelectedTask.Title,
Description = SelectedTask.Description,
StatusId = SelectedTask.StatusId,
PriorityId = SelectedTask.PriorityId,
DueDate = SelectedTask.DueDate,
AssignedTo = TargetAssigneeId
};
var response = await client.PutAsJsonAsync($"Task/{SelectedTask.Id}", dto);
if (response.IsSuccessStatusCode)
{
SelectedTask.AssignedTo = TargetAssigneeId;
statusMessage = "Task assignee updated successfully!";
successMessage = "Assignment saved successfully!";
isError = false;
Navigation.NavigateTo("/tasks/all");
}
else
{
statusMessage = "Failed to update assignee.";
isError = true;
}
}
catch (Exception ex)
{
statusMessage = $"Error: {ex.Message}";
isError = true;
}
finally
{
isSaving = false;
await JS.InvokeVoidAsync("initIcons");
}
}
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 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",
_ => "-"
};
}