Spaces:
Running
Running
task-tracking-system / TaskTrackingSystem.WebApp /Components /Pages /Features /Projects /Projects.razor
| @page "/projects" | |
| @page "/projects/all" | |
| @using Microsoft.AspNetCore.Authorization | |
| @using Microsoft.AspNetCore.Components.Authorization | |
| @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>Projects</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">Projects</h2> | |
| <p class="text-slate-500 mt-1">@(isAdmin ? "Manage all projects." : "Your assigned projects.")</p> | |
| </div> | |
| @if (canCreateProject) | |
| { | |
| <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 Project | |
| </button> | |
| } | |
| </div> | |
| @if (isLoading) | |
| { | |
| <LoadingSpinner Message="Loading projects..." /> | |
| } | |
| else | |
| { | |
| <!-- Search Panel --> | |
| <div class="rounded-xl border border-slate-200 bg-white p-4 shadow-sm mb-4"> | |
| <div class="flex items-center gap-3"> | |
| <div class="relative flex-1 max-w-md"> | |
| <input @bind="searchInput" type="text" placeholder="Search projects..." | |
| 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> | |
| <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"> | |
| <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"> | |
| <i data-lucide="rotate-ccw" class="h-4 w-4 mr-2 text-slate-500"></i> | |
| Reset | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Projects Table --> | |
| <div class="rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden"> | |
| <table class="w-full"> | |
| <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">No.</th> | |
| <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Name</th> | |
| <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Start Date</th> | |
| <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">End Date</th> | |
| <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Created</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 (PagedProjects.Any()) | |
| { | |
| @foreach (var entry in PagedProjects.Select((project, index) => (project, index))) | |
| { | |
| var project = entry.project; | |
| <tr class="hover:bg-slate-50/50 transition-colors"> | |
| <td class="px-6 py-4 text-sm font-semibold text-slate-500">@((currentPage - 1) * pageSize + entry.index + 1)</td> | |
| <td class="px-6 py-4"> | |
| <button @onclick="() => NavigateToKanban(project.Id)" class="text-sm font-semibold text-slate-900 hover:text-violet-600 transition-colors"> | |
| @project.Name | |
| </button> | |
| @if (!string.IsNullOrEmpty(project.Description)) | |
| { | |
| <p class="text-xs text-slate-400 mt-0.5 truncate max-w-xs">@project.Description</p> | |
| } | |
| </td> | |
| <td class="px-6 py-4 text-sm text-slate-600">@DisplayFormats.Date(project.StartDate)</td> | |
| <td class="px-6 py-4 text-sm text-slate-600">@DisplayFormats.Date(project.EndDate)</td> | |
| <td class="px-6 py-4 text-sm text-slate-400">@DisplayFormats.Date(project.CreatedAt)</td> | |
| <td class="px-6 py-4 text-right"> | |
| <div class="flex items-center justify-end space-x-2"> | |
| @if (canEditProject || canDeleteProject) | |
| { | |
| @if (canEditProject) | |
| { | |
| <button @onclick="() => OpenEditModal(project)" 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 (canDeleteProject) | |
| { | |
| <button @onclick="() => RequestDeleteConfirmation(project.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="6" Icon="folder-kanban" Title="No projects yet" Subtitle="@(canCreateProject ? "Create your first project to get started." : "You have not been assigned to any projects.")" /> | |
| } | |
| </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 Project" : "New Project")</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">Name <span class="text-red-500">*</span></label> | |
| <div class="relative"> | |
| <input @bind="formName" type="text" placeholder="Project name" class="w-full rounded-lg border @(isNameError ? "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 pr-10" /> | |
| @if (isNameError) | |
| { | |
| <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 (isNameError) | |
| { | |
| <p class="mt-1 text-xs text-red-500">@nameErrorMessage</p> | |
| } | |
| </div> | |
| <div> | |
| <label class="block text-sm font-medium text-slate-700 mb-1.5">Description</label> | |
| <textarea @bind="formDescription" rows="3" placeholder="Project description (optional)" 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">Start Date <span class="text-red-500">*</span></label> | |
| <div class="relative"> | |
| <input @bind="formStartDate" type="date" class="w-full rounded-lg border @(isDateError ? "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 pr-10" /> | |
| @if (isDateError) | |
| { | |
| <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> | |
| </div> | |
| <div> | |
| <label class="block text-sm font-medium text-slate-700 mb-1.5">End Date <span class="text-red-500">*</span></label> | |
| <div class="relative"> | |
| <input @bind="formEndDate" type="date" class="w-full rounded-lg border @(isDateError ? "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 pr-10" /> | |
| @if (isDateError) | |
| { | |
| <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> | |
| </div> | |
| </div> | |
| <div> | |
| <label class="block text-sm font-medium text-slate-700 mb-1.5 font-sans">Budgeted Hours</label> | |
| <input @bind="formBudgetedHours" type="number" min="0" placeholder="Budgeted hours (optional)" 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 font-sans" /> | |
| </div> | |
| @if (isDateError) | |
| { | |
| <p class="mt-1 text-xs text-red-500">@dateErrorMessage</p> | |
| } | |
| @if (!string.IsNullOrEmpty(errorMessage)) | |
| { | |
| <div class="p-3 rounded-lg bg-red-50 border border-red-100"> | |
| <p class="text-sm text-red-600">@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> | |
| } | |
| @* ===== Assign Members Modal ===== *@ | |
| @if (showAssignModal) | |
| { | |
| <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 bg-slate-50"> | |
| <div> | |
| <h3 class="text-lg font-semibold text-slate-900">Assign Members</h3> | |
| <p class="text-xs text-slate-500 mt-0.5">Project: <span class="font-semibold text-violet-700">@assigningProjectName</span></p> | |
| </div> | |
| <button @onclick="CloseAssignModal" 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"> | |
| <p class="text-xs text-slate-500">Select users to assign to this project. They will only see this project's tasks when they log in.</p> | |
| @if (isLoadingUsers) | |
| { | |
| <div class="flex justify-center py-6"><LoadingSpinner Message="Loading users..." /></div> | |
| } | |
| else | |
| { | |
| <div class="max-h-64 overflow-y-auto space-y-2 border border-slate-100 rounded-xl p-3"> | |
| @foreach (var user in allUsers) | |
| { | |
| var isChecked = selectedUserIds.Contains(user.Id); | |
| <label class="flex items-center gap-3 p-2 rounded-lg hover:bg-slate-50 cursor-pointer transition-colors"> | |
| <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" /> | |
| <span class="flex h-8 w-8 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> | |
| <p class="text-sm font-semibold text-slate-800">@user.FirstName @user.LastName</p> | |
| <p class="text-xs text-slate-400">@@@user.Username</p> | |
| </div> | |
| </label> | |
| } | |
| </div> | |
| <p class="text-xs text-slate-400">@selectedUserIds.Count user(s) selected</p> | |
| } | |
| @if (!string.IsNullOrEmpty(assignError)) | |
| { | |
| <div class="p-3 rounded-lg bg-red-50 border border-red-100"> | |
| <p class="text-sm text-red-600">@assignError</p> | |
| </div> | |
| } | |
| @if (!string.IsNullOrEmpty(assignSuccess)) | |
| { | |
| <div class="p-3 rounded-lg bg-emerald-50 border border-emerald-100"> | |
| <p class="text-sm text-emerald-700">@assignSuccess</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="CloseAssignModal" class="rounded-lg px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-100 transition-colors">Close</button> | |
| <button @onclick="RequestAssignConfirmation" disabled="@isSavingAssign" 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"> | |
| <i data-lucide="user-check" class="h-4 w-4 mr-1.5 inline"></i> | |
| @(isSavingAssign ? "Saving..." : "Save Assignment") | |
| </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" /> | |
| <ConfirmDialog IsVisible="showAssignConfirmDialog" | |
| Title="Save Assignment?" | |
| Message="Are you sure you want to save these project member assignments?" | |
| ConfirmText="Yes, Save" | |
| Icon="user-check" | |
| OnConfirm="ConfirmSaveAssignment" | |
| OnCancel="CloseAssignConfirmDialog" /> | |
| @code { | |
| private bool isLoading = true; | |
| private bool isAdmin = false; | |
| private bool canCreateProject = false; | |
| private bool canEditProject = false; | |
| private bool canDeleteProject = false; | |
| private bool canAssignProject = false; | |
| private long currentUserId = 0; | |
| private bool showModal = false; | |
| private bool isEditing = false; | |
| private bool isSaving = false; | |
| private string? errorMessage; | |
| // Confirm dialog state | |
| private bool showConfirmDialog = false; | |
| private bool showAssignConfirmDialog = 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<ProjectDto> projects = new(); | |
| // Assignment modal state | |
| private bool showAssignModal = false; | |
| private long assigningProjectId = 0; | |
| private string assigningProjectName = ""; | |
| private List<UserDto> allUsers = new(); | |
| private HashSet<long> selectedUserIds = new(); | |
| private bool isLoadingUsers = false; | |
| private bool isSavingAssign = false; | |
| private string? assignError; | |
| private string? assignSuccess; | |
| // Search state | |
| private string searchInput = ""; | |
| // 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<ProjectDto> PagedProjects => projects; | |
| // Form fields | |
| private long editingId; | |
| private string formName = ""; | |
| private string? formDescription; | |
| private DateTime formStartDate = DateTime.Today; | |
| private DateTime formEndDate = DateTime.Today.AddMonths(1); | |
| private int? formBudgetedHours; | |
| // Validation states | |
| private bool isNameError; | |
| private string nameErrorMessage = ""; | |
| private bool isDateError; | |
| private string dateErrorMessage = ""; | |
| protected override async Task OnInitializedAsync() | |
| { | |
| var authState = await AuthStateProvider.GetAuthenticationStateAsync(); | |
| var user = authState.User; | |
| isAdmin = user.IsInRole("Admin"); | |
| var idStr = user.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; | |
| if (idStr != null) long.TryParse(idStr, out currentUserId); | |
| await LoadProjectAccessAsync(user); | |
| await LoadProjects(); | |
| } | |
| protected override async Task OnAfterRenderAsync(bool firstRender) | |
| { | |
| await JS.InvokeVoidAsync("initIcons"); | |
| } | |
| private async Task LoadProjects() | |
| { | |
| isLoading = true; | |
| var client = ApiClient.CreateClient(); | |
| try | |
| { | |
| var url = BuildProjectsUrl(); | |
| var result = await client.GetFromJsonAsync<PagedResult<ProjectDto>>(url, Serialization.CaseInsensitive); | |
| projects = result?.Items ?? new(); | |
| totalCount = result?.TotalCount ?? 0; | |
| totalPages = result?.TotalPages ?? 0; | |
| currentPage = result?.Page ?? currentPage; | |
| pageSize = result?.PageSize ?? pageSize; | |
| } | |
| catch (Exception ex) | |
| { | |
| Console.WriteLine($"Load projects error: {ex.Message}"); | |
| } | |
| finally | |
| { | |
| isLoading = false; | |
| } | |
| } | |
| private string BuildProjectsUrl() | |
| { | |
| var query = new List<string> | |
| { | |
| $"page={currentPage}", | |
| $"limit={pageSize}" | |
| }; | |
| if (!string.IsNullOrWhiteSpace(searchInput)) | |
| { | |
| query.Add($"search={Uri.EscapeDataString(searchInput.Trim())}"); | |
| } | |
| return $"Project?{string.Join("&", query)}"; | |
| } | |
| private void NavigateToKanban(long projectId) | |
| { | |
| Navigation.NavigateTo($"/projects/{projectId}/tasks"); | |
| } | |
| private async Task HandlePageChanged(int page) { currentPage = page; await LoadProjects(); } | |
| private async Task HandlePageSizeChanged(int size) { pageSize = size; currentPage = 1; await LoadProjects(); } | |
| private async Task ApplySearch() | |
| { | |
| currentPage = 1; | |
| await LoadProjects(); | |
| } | |
| private async Task ResetSearch() | |
| { | |
| searchInput = ""; | |
| currentPage = 1; | |
| await LoadProjects(); | |
| } | |
| private async Task LoadProjectAccessAsync(System.Security.Claims.ClaimsPrincipal user) | |
| { | |
| canCreateProject = isAdmin || await MenuAuthorization.HasAccessCodeAsync(user, "Projects_Create"); | |
| canEditProject = isAdmin || await MenuAuthorization.HasAccessCodeAsync(user, "Projects_Update"); | |
| canDeleteProject = isAdmin || await MenuAuthorization.HasAccessCodeAsync(user, "Projects_Delete"); | |
| canAssignProject = isAdmin || await MenuAuthorization.HasAccessCodeAsync(user, "Projects_Update"); | |
| } | |
| private void ClearValidation() | |
| { | |
| isNameError = false; | |
| nameErrorMessage = ""; | |
| isDateError = false; | |
| dateErrorMessage = ""; | |
| errorMessage = null; | |
| } | |
| private void OpenCreateModal() | |
| { | |
| isEditing = false; | |
| editingId = 0; | |
| formName = ""; | |
| formDescription = null; | |
| formStartDate = DateTime.Today; | |
| formEndDate = DateTime.Today.AddMonths(1); | |
| formBudgetedHours = null; | |
| ClearValidation(); | |
| showModal = true; | |
| } | |
| private void OpenEditModal(ProjectDto project) | |
| { | |
| isEditing = true; | |
| editingId = project.Id; | |
| formName = project.Name; | |
| formDescription = project.Description; | |
| formStartDate = project.StartDate; | |
| formEndDate = project.EndDate; | |
| formBudgetedHours = project.BudgetedHours; | |
| ClearValidation(); | |
| showModal = true; | |
| } | |
| private void CloseModal() | |
| { | |
| showModal = false; | |
| ClearValidation(); | |
| } | |
| // ─── Assignment Modal ───────────────────────────────────────────────────── | |
| private async Task OpenAssignModal(ProjectDto project) | |
| { | |
| assigningProjectId = project.Id; | |
| assigningProjectName = project.Name; | |
| assignError = null; | |
| assignSuccess = null; | |
| isLoadingUsers = true; | |
| showAssignModal = true; | |
| StateHasChanged(); | |
| var client = ApiClient.CreateClient(); | |
| try | |
| { | |
| allUsers = await client.GetFromJsonAsync<List<UserDto>>("User", Serialization.CaseInsensitive) ?? new(); | |
| var membersResult = await client.GetFromJsonAsync<Result<IEnumerable<UserDto>>>($"Project/{project.Id}/members", Serialization.CaseInsensitive); | |
| selectedUserIds = new HashSet<long>(membersResult?.Value?.Select(u => u.Id) ?? Enumerable.Empty<long>()); | |
| } | |
| catch (Exception ex) | |
| { | |
| Console.WriteLine($"Load assign modal error: {ex.Message}"); | |
| } | |
| finally | |
| { | |
| isLoadingUsers = false; | |
| StateHasChanged(); | |
| } | |
| } | |
| private void CloseAssignModal() | |
| { | |
| showAssignModal = false; | |
| assignError = null; | |
| assignSuccess = null; | |
| } | |
| private void ToggleUserSelection(long userId, bool isChecked) | |
| { | |
| if (isChecked) selectedUserIds.Add(userId); | |
| else selectedUserIds.Remove(userId); | |
| } | |
| private void RequestAssignConfirmation() | |
| { | |
| showAssignConfirmDialog = true; | |
| } | |
| private void CloseAssignConfirmDialog() | |
| { | |
| showAssignConfirmDialog = false; | |
| } | |
| private async Task ConfirmSaveAssignment() | |
| { | |
| showAssignConfirmDialog = false; | |
| await SaveAssignment(); | |
| } | |
| private async Task SaveAssignment() | |
| { | |
| isSavingAssign = true; | |
| assignError = null; | |
| assignSuccess = null; | |
| var client = ApiClient.CreateClient(); | |
| try | |
| { | |
| var dto = new AssignMembersDto { UserIds = selectedUserIds.ToList() }; | |
| var response = await client.PostAsJsonAsync($"Project/{assigningProjectId}/members", dto); | |
| if (response.IsSuccessStatusCode) | |
| { | |
| assignSuccess = "Members assigned successfully!"; | |
| successMessage = "Assignment saved successfully!"; | |
| } | |
| else | |
| { | |
| assignError = "Failed to save assignment. Please try again."; | |
| } | |
| } | |
| catch (Exception ex) | |
| { | |
| assignError = ex.Message; | |
| } | |
| finally | |
| { | |
| isSavingAssign = false; | |
| } | |
| } | |
| private void RequestSaveConfirmation() | |
| { | |
| ClearValidation(); | |
| bool hasError = false; | |
| if (string.IsNullOrWhiteSpace(formName)) | |
| { | |
| isNameError = true; | |
| nameErrorMessage = "Project name is required."; | |
| hasError = true; | |
| } | |
| if (formEndDate < formStartDate) | |
| { | |
| isDateError = true; | |
| dateErrorMessage = "End date cannot be before start date."; | |
| hasError = true; | |
| } | |
| if (hasError) return; | |
| if (isEditing) | |
| { | |
| confirmTitle = "Update Project?"; | |
| confirmMessage = "Are you sure you want to update this project?"; | |
| confirmButtonText = "Update"; | |
| confirmIcon = "pencil"; | |
| confirmIconBg = "bg-amber-50"; | |
| confirmIconText = "text-amber-500"; | |
| confirmButtonClass = "bg-violet-600 hover:bg-violet-700"; | |
| } | |
| else | |
| { | |
| confirmTitle = "Create Project?"; | |
| confirmMessage = "Are you sure you want to create this project?"; | |
| confirmButtonText = "Create"; | |
| confirmIcon = "plus-circle"; | |
| confirmIconBg = "bg-emerald-50"; | |
| confirmIconText = "text-emerald-500"; | |
| confirmButtonClass = "bg-violet-600 hover:bg-violet-700"; | |
| } | |
| confirmAction = SaveProject; | |
| showConfirmDialog = true; | |
| } | |
| private void RequestDeleteConfirmation(long id) | |
| { | |
| confirmTitle = "Delete Project?"; | |
| confirmMessage = "Are you sure you want to delete this project? 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 = () => DeleteProject(id); | |
| showConfirmDialog = true; | |
| } | |
| private async Task HandleConfirmAction() | |
| { | |
| showConfirmDialog = false; | |
| if (confirmAction != null) | |
| await confirmAction(); | |
| } | |
| private void CloseConfirmDialog() | |
| { | |
| showConfirmDialog = false; | |
| } | |
| private async Task SaveProject() | |
| { | |
| if (string.IsNullOrWhiteSpace(formName) || formEndDate < formStartDate) | |
| { | |
| return; | |
| } | |
| isSaving = true; | |
| errorMessage = null; | |
| var client = ApiClient.CreateClient(); | |
| try | |
| { | |
| if (isEditing) | |
| { | |
| var dto = new UpdateProjectDto | |
| { | |
| Name = formName, | |
| Description = formDescription, | |
| StartDate = formStartDate, | |
| EndDate = formEndDate, | |
| BudgetedHours = formBudgetedHours | |
| }; | |
| var response = await client.PutAsJsonAsync($"Project/{editingId}", dto); | |
| var res = await response.Content.ReadFromJsonAsync<Result>(Serialization.CaseInsensitive); | |
| if (res == null || !res.IsSuccess) | |
| { | |
| errorMessage = res?.ErrorMessage ?? ResultMessages.FailedToUpdateProject; | |
| return; | |
| } | |
| } | |
| else | |
| { | |
| var dto = new CreateProjectDto | |
| { | |
| Name = formName, | |
| Description = formDescription, | |
| StartDate = formStartDate, | |
| EndDate = formEndDate, | |
| CreatedById = currentUserId, | |
| BudgetedHours = formBudgetedHours | |
| }; | |
| var response = await client.PostAsJsonAsync("Project", dto); | |
| var res = await response.Content.ReadFromJsonAsync<Result<ProjectDto>>(Serialization.CaseInsensitive); | |
| if (res == null || !res.IsSuccess) | |
| { | |
| errorMessage = res?.ErrorMessage ?? ResultMessages.FailedToCreateProject; | |
| return; | |
| } | |
| // After creating, reload | |
| await LoadProjects(); | |
| CloseModal(); | |
| successMessage = "Project created successfully!"; | |
| return; | |
| } | |
| CloseModal(); | |
| await LoadProjects(); | |
| successMessage = isEditing ? "Project updated successfully!" : "Project created successfully!"; | |
| } | |
| catch (Exception ex) | |
| { | |
| errorMessage = ex.Message; | |
| } | |
| finally | |
| { | |
| isSaving = false; | |
| } | |
| } | |
| private async Task DeleteProject(long id) | |
| { | |
| var client = ApiClient.CreateClient(); | |
| try | |
| { | |
| var response = await client.DeleteAsync($"Project/{id}"); | |
| var res = await response.Content.ReadFromJsonAsync<Result>(Serialization.CaseInsensitive); | |
| if (res == null || !res.IsSuccess) | |
| { | |
| await JS.InvokeVoidAsync("alert", res?.ErrorMessage ?? "Failed to delete project."); | |
| return; | |
| } | |
| await LoadProjects(); | |
| if (currentPage > TotalPages && TotalPages > 0) | |
| currentPage = TotalPages; | |
| successMessage = "Project deleted successfully!"; | |
| } | |
| catch (Exception ex) | |
| { | |
| Console.WriteLine($"Delete error: {ex.Message}"); | |
| } | |
| } | |
| } | |