User
add: language switch
8c88a89
Raw
History Blame Contribute Delete
29 kB
@page "/issues/add"
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using TaskTrackingSystem.Shared
@using TaskTrackingSystem.Shared.Enums
@using TaskTrackingSystem.Shared.Models.Issue
@using TaskTrackingSystem.Shared.Models.Project
@using TaskTrackingSystem.Shared.Models.Task
@using TaskTrackingSystem.Shared.Models.User
@rendermode @(new InteractiveServerRenderMode(prerender: false))
@attribute [Authorize]
@inject ApiClientService ApiClient
@inject IJSRuntime JS
@inject AuthenticationStateProvider AuthStateProvider
@inject MenuAuthorizationService MenuAuthorization
@inject NavigationManager Navigation
<PageTitle>@AppLocalization.PageTitle("addIssue", "Add Issue")</PageTitle>
<SuccessAlert Message="@successMessage" />
<div class="space-y-6">
<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.Text("common.addIssue", "Add Issue")</h2>
<p class="text-slate-500 mt-1">@AppLocalization.Text("issue.addIssueDescription", "Create a one-day work issue for a task and record the hours you spent.")</p>
</div>
</div>
@if (isLoading)
{
<LoadingSpinner Message="@AppLocalization.Text("common.loadingTasksAndUsers", "Loading tasks and users...")" />
}
else
{
<div class="w-full rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="md:col-span-2">
<label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.task", "Task") <span class="text-red-500">*</span></label>
<select value="@formTaskId" @onchange="OnTaskChanged" class="w-full rounded-lg border @(isTaskError ? "border-red-500 focus:ring-red-500" : "border-slate-200 focus:ring-violet-600") px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:border-transparent transition-all">
<option value="0">@AppLocalization.Text("common.selectTask", "Select task...")</option>
@foreach (var task in tasks)
{
<option value="@task.Id">@task.Title</option>
}
</select>
@if (isTaskError)
{
<p class="mt-1 text-xs text-red-500">@taskErrorMessage</p>
}
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.issueTitle", "Issue Title") <span class="text-red-500">*</span></label>
<input @bind="formTitle" type="text" placeholder="@AppLocalization.Text("issue.whatDidYouWorkOn", "What did you work on?")"
class="w-full rounded-lg border @(isTitleError ? "border-red-500 focus:ring-red-500" : "border-slate-200 focus:ring-violet-600") px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:border-transparent transition-all" />
@if (isTitleError)
{
<p class="mt-1 text-xs text-red-500">@titleErrorMessage</p>
}
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.description", "Description")</label>
<textarea @bind="formDescription" rows="4" placeholder="@AppLocalization.Text("issue.addShortNote", "Add a short note about the work you did...")"
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"></textarea>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.estimatedHours", "Estimated Hours")</label>
<input @bind="formEstimatedHours" type="number" step="0.25" min="0"
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"
placeholder="@AppLocalization.Text("issue.exampleEstimatedHours", "e.g. 4")" />
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.actualHours", "Actual Hours")</label>
<input @bind="formActualHours" type="number" step="0.25" min="0" max="7"
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"
placeholder="@AppLocalization.Text("issue.exampleActualHours", "e.g. 7")" />
<p class="mt-1 text-xs text-slate-500">@AppLocalization.Text("issue.actualHoursHint", "Use the total hours you spent today. For your standard day, this is usually 7 hours.")</p>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.startDate", "Start Date")</label>
<input @bind="formStartDate" 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-violet-600 focus:border-transparent transition-all" />
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.dueDate", "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-violet-600 focus:border-transparent transition-all" />
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.status", "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-violet-600 focus:border-transparent transition-all">
<option value="@AppTaskStatus.Todo">@AppLocalization.StatusLabel(AppTaskStatus.Todo)</option>
<option value="@AppTaskStatus.InProgress">@AppLocalization.StatusLabel(AppTaskStatus.InProgress)</option>
<option value="@AppTaskStatus.Done">@AppLocalization.StatusLabel(AppTaskStatus.Done)</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.priority", "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-violet-600 focus:border-transparent transition-all">
<option value="@TaskPriority.Low">@AppLocalization.PriorityLabel(TaskPriority.Low)</option>
<option value="@TaskPriority.Medium">@AppLocalization.PriorityLabel(TaskPriority.Medium)</option>
<option value="@TaskPriority.High">@AppLocalization.PriorityLabel(TaskPriority.High)</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.blockedStatus", "Blocked Status")</label>
<label class="flex h-10 items-center gap-2 rounded-lg border border-slate-200 px-3 text-sm text-slate-700">
<input type="checkbox" @bind="formIsBlocked" class="h-4 w-4 rounded border-slate-300 text-violet-600 focus:ring-violet-500" />
<span>@AppLocalization.Text("status.blocked", "Blocked")</span>
</label>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.escalationLevel", "Escalation Level")</label>
<select @bind="formEscalationLevel" 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">@AppLocalization.Text("common.none", "None")</option>
<option value="1">@AppLocalization.Text("issue.teamLead", "Team Lead")</option>
<option value="2">@AppLocalization.Text("issue.pm", "PM")</option>
<option value="3">@AppLocalization.Text("issue.manager", "Manager")</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.blockedBy", "Blocked By")</label>
<input @bind="formBlockedBy" type="text" placeholder="@AppLocalization.Text("issue.blockedByPlaceholder", "Dependency, person, or team")"
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>
<label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.delayReason", "Delay Reason")</label>
<input @bind="formDelayReason" type="text" placeholder="@AppLocalization.Text("issue.delayReasonPlaceholder", "Why is this issue delayed?")"
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>
<label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.project", "Project")</label>
<input value="@SelectedProjectLabel" readonly
class="w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700 focus:outline-none" />
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.assignee", "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-violet-600">
<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="@AppLocalization.Text("common.close", "Close")">
<i data-lucide="x" class="h-4 w-4"></i>
</button>
</div>
<div class="relative">
<input @bind="assigneeSearchInput"
@onfocus="EnsureAssigneeDropdownOpen"
type="text"
placeholder="@AppLocalization.Text("common.searchProjectMembers", "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">
@AppLocalization.Text("common.loadingProjectMembers", "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">@AppLocalization.Text("common.unassigned", "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">
@AppLocalization.Text("common.noMembersMatchSearch", "No members match the current search.")
</div>
}
</div>
}
</div>
}
</div>
</div>
</div>
<div class="mt-6 flex items-center justify-end gap-3">
<button @onclick="ResetForm" class="inline-flex items-center rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 transition-colors shadow-sm">
@AppLocalization.Text("action.reset", "Reset")
</button>
<button @onclick="RequestSaveConfirmation" disabled="@isSaving" class="inline-flex items-center rounded-lg bg-violet-600 px-5 py-2 text-sm font-semibold text-white hover:bg-violet-700 disabled:opacity-50 transition-colors shadow-sm">
@(isSaving ? AppLocalization.Text("common.loadingSpinner", "Loading...") : AppLocalization.Text("common.addIssue", "Add Issue"))
</button>
</div>
@if (!string.IsNullOrWhiteSpace(errorMessage))
{
<p class="mt-4 text-sm text-red-600">@errorMessage</p>
}
</div>
}
</div>
<ConfirmDialog IsVisible="showConfirmDialog"
Title="@AppLocalization.Text("common.addIssueTitle", "Add Issue?")"
Message="@AppLocalization.Text("issue.saveAddConfirmMessage", "Are you sure you want to save this issue?")"
ConfirmText="@AppLocalization.Text("issue.yesAdd", "Yes, Add")"
Icon="circle-plus"
IconBgClass="bg-emerald-50"
IconTextClass="text-emerald-600"
ButtonClass="bg-violet-600 hover:bg-violet-700"
OnConfirm="ConfirmSave"
OnCancel="CloseConfirmDialog" />
@code {
private bool isLoading = true;
private bool isSaving;
private bool showConfirmDialog;
private bool isTaskError;
private bool isTitleError;
private string taskErrorMessage = "";
private string titleErrorMessage = "";
private string? errorMessage;
private string? successMessage;
private List<TaskDto> tasks = new();
private List<ProjectDto> projects = new();
private List<UserDto> projectMembers = new();
private long currentUserId;
private long formTaskId;
private string formTitle = "";
private string? formDescription;
private long? formAssignedTo;
private decimal? formEstimatedHours;
private decimal? formActualHours = 7m;
private DateTime formStartDate = DateTime.Today;
private DateTime formDueDate = DateTime.Today;
private AppTaskStatus formStatusId = AppTaskStatus.Todo;
private TaskPriority formPriorityId = TaskPriority.Medium;
private string? formDelayReason;
private bool formIsBlocked;
private string? formBlockedBy;
private int formEscalationLevel;
private string assigneeSearchInput = "";
private bool isAssigneeDropdownOpen;
private bool isLoadingProjectMembers;
private TaskDto? SelectedTask => tasks.FirstOrDefault(t => t.Id == formTaskId);
private string SelectedTaskLabel => SelectedTask == null ? "No task selected" : SelectedTask.Title;
private string SelectedProjectLabel => SelectedTask == null
? "No project"
: (projects.FirstOrDefault(p => p.Id == SelectedTask.ProjectId)?.Name ?? $"Project #{SelectedTask.ProjectId}");
private string SelectedAssigneeLabel => formAssignedTo.HasValue
? (projectMembers.FirstOrDefault(u => u.Id == formAssignedTo.Value) is { } user ? $"{user.FirstName} {user.LastName}" : "Selected member")
: "Unassigned";
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) ||
u.Username.Contains(search, StringComparison.OrdinalIgnoreCase));
}
return result.OrderBy(u => u.FirstName).ThenBy(u => u.LastName);
}
}
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var user = authState.User;
var idStr = user.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
if (idStr != null)
{
long.TryParse(idStr, out currentUserId);
}
await LoadDataAsync(user);
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await JS.InvokeVoidAsync("initIcons");
}
private async Task LoadDataAsync(System.Security.Claims.ClaimsPrincipal user)
{
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);
await Task.WhenAll(tasksTask, projectsTask, usersTask);
tasks = tasksTask.Result ?? new();
projects = projectsTask.Result ?? new();
var allUsers = usersTask.Result ?? new();
projectMembers = allUsers.Where(u => u.IsActive).ToList();
formTaskId = tasks.FirstOrDefault()?.Id ?? 0;
await LoadProjectMembersForTaskAsync(formTaskId);
}
catch (Exception ex)
{
errorMessage = ex.Message;
}
finally
{
isLoading = false;
}
}
private async Task OnTaskChanged(ChangeEventArgs e)
{
if (long.TryParse(e.Value?.ToString(), out var taskId))
{
formTaskId = taskId;
await LoadProjectMembersForTaskAsync(formTaskId);
if (formTaskId > 0)
{
formAssignedTo = currentUserId;
}
}
}
private async Task LoadProjectMembersForTaskAsync(long taskId)
{
isLoadingProjectMembers = true;
projectMembers.Clear();
var task = tasks.FirstOrDefault(t => t.Id == taskId);
if (task == null)
{
formAssignedTo = null;
isLoadingProjectMembers = false;
return;
}
var client = ApiClient.CreateClient();
try
{
var membersResult = await client.GetFromJsonAsync<Result<IEnumerable<UserDto>>>($"Project/{task.ProjectId}/members", Serialization.CaseInsensitive);
if (membersResult?.IsSuccess == true && membersResult.Value != null)
{
projectMembers = membersResult.Value.Where(u => u.IsActive).OrderBy(u => u.FirstName).ThenBy(u => u.LastName).ToList();
if (projectMembers.Any(u => u.Id == currentUserId))
{
formAssignedTo = currentUserId;
}
else if (formAssignedTo.HasValue && !projectMembers.Any(u => u.Id == formAssignedTo.Value))
{
formAssignedTo = projectMembers.FirstOrDefault()?.Id;
}
}
}
catch (Exception ex)
{
Console.WriteLine($"Failed to load project members: {ex.Message}");
}
finally
{
isLoadingProjectMembers = false;
}
}
private void ToggleAssigneeDropdown()
{
isAssigneeDropdownOpen = !isAssigneeDropdownOpen;
if (isAssigneeDropdownOpen)
{
assigneeSearchInput = "";
}
}
private void CloseAssigneeDropdown()
{
isAssigneeDropdownOpen = false;
}
private void EnsureAssigneeDropdownOpen()
{
isAssigneeDropdownOpen = true;
}
private void SetFormAssignee(long? userId)
{
formAssignedTo = userId;
isAssigneeDropdownOpen = false;
assigneeSearchInput = "";
}
private void RequestSaveConfirmation()
{
isTaskError = false;
isTitleError = false;
taskErrorMessage = "";
titleErrorMessage = "";
if (formTaskId <= 0)
{
isTaskError = true;
taskErrorMessage = "Please select a task.";
return;
}
if (string.IsNullOrWhiteSpace(formTitle))
{
isTitleError = true;
titleErrorMessage = "Issue title is required.";
return;
}
showConfirmDialog = true;
}
private void CloseConfirmDialog()
{
showConfirmDialog = false;
}
private async Task ConfirmSave()
{
showConfirmDialog = false;
await SaveIssueAsync();
}
private async Task SaveIssueAsync()
{
if (formTaskId <= 0)
{
errorMessage = "Please select a task.";
return;
}
if (string.IsNullOrWhiteSpace(formTitle))
{
errorMessage = "Issue title is required.";
return;
}
isSaving = true;
errorMessage = null;
try
{
var client = ApiClient.CreateClient();
var dto = new CreateIssueDto
{
TaskId = formTaskId,
Title = formTitle.Trim(),
Description = formDescription,
AssignedTo = formAssignedTo,
EstimatedHours = formEstimatedHours,
ActualHours = formActualHours,
DelayReason = formDelayReason,
IsBlocked = formIsBlocked,
BlockedBy = formBlockedBy,
EscalationLevel = formEscalationLevel,
StartDate = formStartDate,
DueDate = formDueDate,
StatusId = formStatusId,
PriorityId = formPriorityId
};
var response = await client.PostAsJsonAsync("Issue", dto);
var result = await response.Content.ReadFromJsonAsync<Result<IssueDto>>(Serialization.CaseInsensitive);
if (result?.IsSuccess == true)
{
successMessage = "Issue created successfully!";
ResetForm();
}
else
{
errorMessage = result?.ErrorMessage ?? "Failed to create issue.";
}
}
catch (Exception ex)
{
errorMessage = ex.Message;
}
finally
{
isSaving = false;
}
}
private void ResetForm()
{
formTaskId = tasks.FirstOrDefault()?.Id ?? 0;
formTitle = "";
formDescription = null;
formAssignedTo = currentUserId;
formEstimatedHours = null;
formActualHours = 7m;
formStartDate = DateTime.Today;
formDueDate = DateTime.Today;
formStatusId = AppTaskStatus.Todo;
formPriorityId = TaskPriority.Medium;
formDelayReason = null;
formIsBlocked = false;
formBlockedBy = null;
formEscalationLevel = 0;
taskErrorMessage = "";
titleErrorMessage = "";
isTaskError = false;
isTitleError = false;
assigneeSearchInput = "";
isAssigneeDropdownOpen = false;
_ = LoadProjectMembersForTaskAsync(formTaskId);
}
}