User
add: language switch
8c88a89
Raw
History Blame Contribute Delete
23.3 kB
@page "/projects/assign"
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@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("projectAssignment", "Project Assignment")</PageTitle>
<SuccessAlert Message="@successMessage" />
<div class="space-y-6">
<!-- Page Header -->
<div class="flex items-center justify-between">
<div>
<h2 class="text-3xl font-bold tracking-tight text-slate-900">@AppLocalization.PageTitle("projectAssignment", "Project Assignment")</h2>
<p class="text-slate-500 mt-1">@AppLocalization.Text("project.assignDescription", "Assign team members to projects to control their task visibility.")</p>
</div>
</div>
@if (isLoading)
{
<LoadingSpinner Message="@AppLocalization.Text("common.loadingProjectsAndUsers", "Loading projects and users...")" />
}
else
{
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Left panel: Projects list -->
<div class="lg:col-span-1 rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden flex flex-col h-[600px]">
<div class="p-4 border-b border-slate-100 bg-slate-50/60">
<h3 class="font-bold text-slate-800 text-sm mb-3">@AppLocalization.Text("common.selectProject", "Select Project")</h3>
<div class="relative flex gap-2">
<div class="relative flex-1">
<input @bind="projectSearchInput" type="text" placeholder="@AppLocalization.Text("common.searchProjects", "Search projects...")"
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="ApplyProjectSearch" 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> @AppLocalization.Text("action.search", "Search")
</button>
<button @onclick="ResetProjectSearch" 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> @AppLocalization.Text("action.reset", "Reset")
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto divide-y divide-slate-100">
@if (FilteredProjects.Any())
{
@foreach (var proj in FilteredProjects)
{
var isSelected = SelectedProject?.Id == proj.Id;
<button @onclick="() => SelectProject(proj)"
class="w-full text-left p-4 hover:bg-slate-50/60 transition-colors flex flex-col gap-1 @(isSelected ? "bg-violet-50/60 border-l-4 border-violet-600 pl-3" : "")">
<div class="flex items-center justify-between">
<span class="text-sm font-semibold text-slate-900 @(isSelected ? "text-violet-700" : "")">@proj.Name</span>
<span class="text-[10px] text-slate-400">@DisplayFormats.Date(proj.StartDate) - @DisplayFormats.Date(proj.EndDate)</span>
</div>
@if (!string.IsNullOrEmpty(proj.Description))
{
<p class="text-xs text-slate-500 line-clamp-1">@proj.Description</p>
}
</button>
}
}
else
{
<div class="p-8 text-center text-slate-400 text-xs">@AppLocalization.Text("common.noProjectsFound", "No projects found.")</div>
}
</div>
</div>
<!-- Right panel: Assignment controls -->
<div class="lg:col-span-2 rounded-xl border border-slate-200 bg-white shadow-sm flex flex-col h-[600px] overflow-hidden">
@if (SelectedProject == 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="folder-kanban" class="h-6 w-6"></i>
</div>
<h4 class="text-sm font-bold text-slate-800">@AppLocalization.Text("common.noProjectSelected", "No Project Selected")</h4>
<p class="text-xs text-slate-500 mt-1 max-w-sm">@AppLocalization.Text("project.selectProjectPrompt", "Please select a project from the left panel to manage user assignments.")</p>
</div>
}
else
{
<!-- Selected Project Header -->
<div class="p-6 border-b border-slate-100 bg-slate-50 flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<span class="text-[10px] font-bold text-violet-600 uppercase tracking-wider">@AppLocalization.Text("common.currentlyManaging", "Currently Managing")</span>
<h3 class="text-xl font-bold text-slate-900">@SelectedProject.Name</h3>
@if (!string.IsNullOrEmpty(SelectedProject.Description))
{
<p class="text-xs text-slate-500 mt-1">@SelectedProject.Description</p>
}
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="text-xs text-slate-500 bg-slate-100 px-3 py-1 rounded-full border border-slate-200">
@DisplayFormats.Date(SelectedProject.StartDate) - @DisplayFormats.Date(SelectedProject.EndDate)
</span>
</div>
</div>
<!-- Filter & Selection Stats -->
<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> @AppLocalization.Text("action.search", "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> @AppLocalization.Text("action.reset", "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">
@AppLocalization.Text("project.employeesOnly", "Employees only")
</div>
}
<div class="flex items-center gap-3 shrink-0">
<button @onclick="SelectAllUsers" class="text-xs font-semibold text-violet-600 hover:text-violet-800 transition-colors">@AppLocalization.Text("common.selectAll", "Select All")</button>
<span class="text-slate-300">|</span>
<button @onclick="DeselectAllUsers" class="text-xs font-semibold text-slate-600 hover:text-slate-800 transition-colors">@AppLocalization.Text("common.deselectAll", "Deselect All")</button>
<span class="text-slate-300">|</span>
<span class="text-xs font-bold text-slate-600">@SelectedUserIds.Count @AppLocalization.Text("common.selected", "selected")</span>
</div>
</div>
<!-- Users list checklist -->
<div class="flex-1 overflow-y-auto p-6 space-y-2">
@if (isLoadingMembers)
{
<div class="flex justify-center py-12">
<LoadingSpinner Message="@AppLocalization.Text("common.loadingMembers", "Loading members...")" />
</div>
}
else
{
@if (FilteredUsers.Any())
{
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
@foreach (var user in FilteredUsers)
{
var isChecked = SelectedUserIds.Contains(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 @(isChecked ? "border-violet-100 bg-violet-50/10" : "")">
<input type="checkbox" checked="@isChecked"
@onchange="(e) => ToggleUserSelection(user.Id, (bool)(e.Value ?? false))"
class="h-4 w-4 rounded border-slate-300 accent-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>
}
else
{
<div class="text-center text-slate-400 py-12 text-sm">@AppLocalization.Text("common.noTeamMembersFound", "No team members match your search criteria.")</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 || isLoadingMembers)"
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.saveAssignmentChanges", "Save Assignment Changes"))
</button>
}
</div>
}
</div>
</div>
}
</div>
<ConfirmDialog IsVisible="showConfirmDialog"
Title="@AppLocalization.Text("project.saveAssignmentTitle", "Save Assignment?")"
Message="@AppLocalization.Text("project.saveAssignmentConfirm", "Are you sure you want to save these project member assignments?")"
ConfirmText="@AppLocalization.Text("issue.yesSave", "Yes, Save")"
Icon="user-check"
OnConfirm="ConfirmSaveAssignments"
OnCancel="CloseConfirmDialog" />
@code {
private bool isLoading = true;
private bool isLoadingMembers = false;
private bool isSaving = false;
private bool canSaveAssignments = false;
private bool showConfirmDialog = false;
private string? statusMessage;
private string? successMessage;
private bool isError = false;
private List<ProjectDto> projects = new();
private List<UserDto> allUsers = new();
private List<RoleDto> roles = new();
private long? adminRoleId;
private long? employeeRoleId;
private bool isAdminUser;
private ProjectDto? SelectedProject { get; set; }
private HashSet<long> SelectedUserIds { get; set; } = new();
private string projectSearchInput = "";
private string projectSearch = "";
private string userSearchInput = "";
private string userSearch = "";
private long selectedRoleId = 0;
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, "Projects_Update");
var client = ApiClient.CreateClient();
try
{
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(projectsTask, usersTask, rolesTask);
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<ProjectDto> FilteredProjects =>
string.IsNullOrWhiteSpace(projectSearch)
? projects
: projects.Where(p => p.Name.Contains(projectSearch, StringComparison.OrdinalIgnoreCase));
private IEnumerable<UserDto> FilteredUsers
{
get
{
var result = allUsers.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 ApplyProjectSearch()
{
projectSearch = projectSearchInput;
}
private void ApplyUserSearch()
{
userSearch = userSearchInput;
}
private void ResetProjectSearch()
{
projectSearchInput = "";
projectSearch = "";
}
private void ResetUserSearch()
{
userSearchInput = "";
userSearch = "";
selectedRoleId = isAdminUser ? 0 : employeeRoleId ?? 0;
}
private async Task SelectProject(ProjectDto project)
{
SelectedProject = project;
SelectedUserIds.Clear();
statusMessage = null;
isLoadingMembers = true;
StateHasChanged();
var client = ApiClient.CreateClient();
try
{
var membersResult = await client.GetFromJsonAsync<Result<IEnumerable<UserDto>>>($"Project/{project.Id}/members", Serialization.CaseInsensitive);
if (membersResult?.IsSuccess == true && membersResult.Value != null)
{
SelectedUserIds = new HashSet<long>(membersResult.Value.Select(u => u.Id));
}
}
catch (Exception ex)
{
Console.WriteLine($"Error loading project members: {ex.Message}");
statusMessage = "Failed to load project members.";
isError = true;
}
finally
{
isLoadingMembers = false;
StateHasChanged();
await JS.InvokeVoidAsync("initIcons");
}
}
private void ToggleUserSelection(long userId, bool isChecked)
{
if (isChecked) SelectedUserIds.Add(userId);
else SelectedUserIds.Remove(userId);
statusMessage = null;
}
private void SelectAllUsers()
{
foreach (var u in FilteredUsers)
{
SelectedUserIds.Add(u.Id);
}
statusMessage = null;
}
private void DeselectAllUsers()
{
foreach (var u in FilteredUsers)
{
SelectedUserIds.Remove(u.Id);
}
statusMessage = null;
}
private void RequestSaveConfirmation()
{
if (SelectedProject == null) return;
showConfirmDialog = true;
}
private void CloseConfirmDialog()
{
showConfirmDialog = false;
}
private async Task ConfirmSaveAssignments()
{
showConfirmDialog = false;
await SaveAssignments();
}
private async Task SaveAssignments()
{
if (SelectedProject == null) return;
isSaving = true;
statusMessage = null;
var client = ApiClient.CreateClient();
try
{
var dto = new AssignMembersDto { UserIds = SelectedUserIds.ToList() };
var response = await client.PostAsJsonAsync($"Project/{SelectedProject.Id}/members", dto);
if (response.IsSuccessStatusCode)
{
statusMessage = "Project assignments saved successfully!";
successMessage = "Assignment saved successfully!";
isError = false;
Navigation.NavigateTo("/projects/all");
}
else
{
statusMessage = "Failed to save assignment. Please try again.";
isError = true;
}
}
catch (Exception ex)
{
statusMessage = $"Error: {ex.Message}";
isError = true;
}
finally
{
isSaving = false;
await JS.InvokeVoidAsync("initIcons");
}
}
}