User
Deploy localized dashboard fixes
3dd5c9a
Raw History Blame Contribute Delete
52.2 kB
@page "/issues"
@page "/issues/all"
@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("issues", "Issues")</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.PageTitle("issues", "Issues")</h2>
<p class="text-slate-500 mt-1">@AppLocalization.Text("page.issues.desc", "Track daily work items, update progress, and review what was done.")</p>
</div>
@if (canCreateIssue)
{
<button @onclick="GoToAddIssue" 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>
@AppLocalization.Text("common.addIssue", "Add Issue")
</button>
}
</div>
@if (isLoading)
{
<LoadingSpinner Message="@AppLocalization.Text("common.loadingIssues", "Loading issues...")" />
}
else
{
<div class="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<div class="grid grid-cols-1 lg:grid-cols-6 gap-3">
<div class="lg:col-span-2">
<input @bind="searchInput" @bind:event="oninput" type="text" placeholder="@AppLocalization.Text("common.searchIssues", "Search issue title, description or assignee...")"
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>
<select @bind="taskIdInput" 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.allTasks", "All tasks")</option>
@foreach (var task in tasks)
{
<option value="@task.Id">@Content.GetTaskTitle(task)</option>
}
</select>
</div>
<div>
<select @bind="projectIdInput" 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.allProjects", "All projects")</option>
@foreach (var project in projects)
{
<option value="@project.Id">@Content.GetProjectName(project)</option>
}
</select>
</div>
<div>
<select @bind="statusIdInput" 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="">@AppLocalization.Text("common.all", "All")</option>
<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>
<select @bind="priorityIdInput" 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="">@AppLocalization.Text("common.all", "All")</option>
<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>
<div class="mt-3 flex flex-wrap items-center gap-3">
<label class="inline-flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" @bind="assignedOnly" class="h-4 w-4 rounded border-slate-300 accent-violet-600" />
@AppLocalization.Text("common.assignedToMeOnly", "Assigned to me only")
</label>
<button @onclick="ApplySearch" class="inline-flex items-center rounded-lg bg-violet-600 px-4 py-2 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>
@AppLocalization.Text("action.search", "Search")
</button>
<button @onclick="ResetSearch" title="@AppLocalization.Text("action.reset", "Reset")" aria-label="@AppLocalization.Text("action.reset", "Reset")" class="inline-flex items-center justify-center rounded-lg border border-slate-200 bg-white p-2 text-slate-700 hover:bg-slate-50 transition-colors shadow-sm">
<i data-lucide="rotate-ccw" class="h-4 w-4 text-slate-500"></i>
</button>
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden">
<div class="overflow-hidden">
<table class="w-full table-fixed">
<colgroup>
<col class="w-[12%]" />
<col class="w-[24%]" />
<col class="w-[11%]" />
<col class="w-[11%]" />
<col class="w-[12%]" />
<col class="w-[12%]" />
<col class="w-[12%]" />
<col class="w-[8%]" />
<col class="w-[10%]" />
</colgroup>
<thead>
<tr class="border-b border-slate-100 bg-slate-50/60">
<th class="px-5 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.taskCode", "Task Code")</th>
<th class="px-5 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.issue", "Issue")</th>
<th class="px-5 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.hours", "Hours")</th>
<th class="px-5 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.status", "Status")</th>
<th class="px-5 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.priority", "Priority")</th>
<th class="px-5 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.date", "Date")</th>
<th class="px-5 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.assignee", "Assignee")</th>
<th class="px-5 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.risk", "Risk")</th>
<th class="px-5 py-3 text-center text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.actions", "Actions")</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 text-[13px]">
@if (issues.Any())
{
@foreach (var entry in issues.Select((issue, index) => (issue, index)))
{
var issue = entry.issue;
<tr class="hover:bg-slate-50/50 transition-colors align-top">
<td class="px-5 py-4 text-left whitespace-nowrap">
<span class="font-mono text-[11px] font-semibold tracking-wide text-indigo-700">
@GetTaskCode(issue.TaskId)
</span>
</td>
<td class="px-5 py-4 text-left">
<button @onclick="() => OpenEditModal(issue)" class="min-w-0 text-left text-sm font-semibold leading-5 text-slate-900 hover:text-violet-600 transition-colors break-words">
@Content.GetIssueTitle(issue)
</button>
</td>
<td class="px-5 py-4 text-slate-600 whitespace-nowrap">
<span class="font-medium text-slate-700">@(issue.ActualHours.HasValue ? AppLocalization.Number(issue.ActualHours.Value) : "-") @AppLocalization.Text("common.hoursAbbrev", "hr")</span>
</td>
<td class="px-5 py-4">
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium @GetStatusBadge(issue.StatusId)">
@GetStatusLabel(issue.StatusId)
</span>
</td>
<td class="px-5 py-4">
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium @GetPriorityBadge(issue.PriorityId)">
@GetPriorityLabel(issue.PriorityId)
</span>
</td>
<td class="px-5 py-4 text-sm text-slate-600 whitespace-nowrap">
<span class="font-medium text-slate-700">@DisplayFormats.Date(issue.DueDate)</span>
</td>
<td class="px-5 py-4 text-sm text-slate-600 break-words">
@(!string.IsNullOrWhiteSpace(Content.GetText(issue.AssignedToName, issue.AssignedToNameMy)) ? Content.GetText(issue.AssignedToName, issue.AssignedToNameMy) : AppLocalization.Text("common.unassigned", "Unassigned"))
</td>
<td class="px-5 py-4">
@if (issue.IsBlocked)
{
<span class="inline-flex items-center rounded-full bg-rose-50 px-2 py-0.5 text-xs font-semibold text-rose-700">@AppLocalization.Text("status.blocked", "Blocked")</span>
}
else if (issue.EscalationLevel > 0)
{
<span class="inline-flex items-center rounded-full bg-amber-50 px-2 py-0.5 text-xs font-semibold text-amber-700">@GetEscalationLabel(issue.EscalationLevel)</span>
}
else
{
<span class="text-xs text-slate-400">@AppLocalization.Text("status.clear", "Clear")</span>
}
</td>
<td class="px-5 py-4 text-center">
<div class="flex items-center justify-center gap-1">
@if (canEditIssue)
{
<button @onclick="() => OpenEditModal(issue)" class="rounded-lg p-1.5 text-slate-400 hover:bg-amber-50 hover:text-amber-600 transition-all" title="@AppLocalization.Text("action.edit", "Edit")">
<i data-lucide="pencil" class="h-4 w-4"></i>
</button>
}
@if (canDeleteIssue)
{
<button @onclick="() => RequestDeleteConfirmation(issue.Id)" class="rounded-lg p-1.5 text-slate-400 hover:bg-rose-50 hover:text-rose-600 transition-all" title="@AppLocalization.Text("action.delete", "Delete")">
<i data-lucide="trash-2" class="h-4 w-4"></i>
</button>
}
</div>
</td>
</tr>
}
}
else
{
<EmptyState ColSpan="9" Icon="file-pen-line" Title="@AppLocalization.Text("common.noResults", "No results found.")" Subtitle="@AppLocalization.Text("common.createFirstEntry", "Create your first entry to get started.")" />
}
</tbody>
</table>
</div>
<Pagination CurrentPage="currentPage"
TotalPages="totalPages"
PageSize="pageSize"
TotalCount="totalCount"
OnPageChanged="HandlePageChanged"
OnPageSizeChanged="HandlePageSizeChanged" />
</div>
}
</div>
@if (showModal)
{
<div class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 p-4 backdrop-blur-sm sm:p-6">
<div class="flex max-h-[calc(100vh-2rem)] w-full max-w-4xl flex-col overflow-hidden rounded-2xl bg-white shadow-xl sm:max-h-[calc(100vh-3rem)]">
<div class="shrink-0 flex items-center justify-between px-6 py-4 border-b border-slate-100">
<div>
<h3 class="text-lg font-semibold text-slate-900">@AppLocalization.Text("common.editIssue", "Edit Issue")</h3>
<p class="text-xs text-slate-500 mt-0.5">#@AppLocalization.Number(editingId)</p>
</div>
<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="min-h-0 flex-1 overflow-y-auto p-6 space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
<p class="text-xs font-semibold uppercase tracking-wider text-slate-400">@AppLocalization.Text("common.task", "Task")</p>
<p class="mt-1 text-sm font-semibold text-slate-900">@SelectedTaskLabel</p>
</div>
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
<p class="text-xs font-semibold uppercase tracking-wider text-slate-400">@AppLocalization.Text("common.project", "Project")</p>
<p class="mt-1 text-sm font-semibold text-slate-900">@SelectedProjectLabel</p>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<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>
<BilingualEditorTabs BurmeseComplete="@(!string.IsNullOrWhiteSpace(formTitleMy) || !string.IsNullOrWhiteSpace(formDescriptionMy))"
EnglishComplete="@(!string.IsNullOrWhiteSpace(formTitle) || !string.IsNullOrWhiteSpace(formDescription))"
BurmeseAiGenerated="@burmeseGenerated" EnglishAiGenerated="@englishGenerated"
BurmeseNeedsReview="@burmeseNeedsReview" EnglishNeedsReview="@englishNeedsReview">
<BurmeseContent>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">ပိတ်ဆို့မှု</label>
<input @bind="formBlockedByMy" type="text" placeholder="ပိတ်ဆို့ထားသူ / မှီခိုမှု" class="mb-2 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" />
<BilingualFieldWarning Text="@formBlockedByMy" ExpectedBurmese="true" MoveToOtherLanguage="MoveIssueBlockedByToEnglish" />
<BilingualTranslationAction SourceText="@formBlockedByMy" SourceLanguage="my" TargetLanguage="en" TargetHasText="@(!string.IsNullOrWhiteSpace(formBlockedBy))" Generated="GenerateIssueEnglishBlockedBy" />
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">နှောင့်နှေးရခြင်းအကြောင်း</label>
<input @bind="formDelayReasonMy" type="text" placeholder="နှောင့်နှေးရခြင်းအကြောင်း" class="mb-2 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" />
<BilingualFieldWarning Text="@formDelayReasonMy" ExpectedBurmese="true" MoveToOtherLanguage="MoveIssueDelayToEnglish" />
<BilingualTranslationAction SourceText="@formDelayReasonMy" SourceLanguage="my" TargetLanguage="en" TargetHasText="@(!string.IsNullOrWhiteSpace(formDelayReason))" Generated="GenerateIssueEnglishDelay" />
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">ခေါင်းစဉ် <span class="text-red-500">*</span></label>
<input @bind="formTitleMy" type="text" placeholder="ခေါင်းစဉ်" 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" />
<BilingualFieldWarning Text="@formTitleMy" ExpectedBurmese="true" MoveToOtherLanguage="MoveIssueTitleToEnglish" />
<BilingualTranslationAction SourceText="@formTitleMy" SourceLanguage="my" TargetLanguage="en" TargetHasText="@(!string.IsNullOrWhiteSpace(formTitle))" Generated="GenerateIssueEnglishTitle" />
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">ဖော်ပြချက်</label>
<textarea @bind="formDescriptionMy" rows="3" placeholder="ဖော်ပြချက်" 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>
<BilingualFieldWarning Text="@formDescriptionMy" ExpectedBurmese="true" MoveToOtherLanguage="MoveIssueDescriptionToEnglish" />
<BilingualTranslationAction SourceText="@formDescriptionMy" SourceLanguage="my" TargetLanguage="en" TargetHasText="@(!string.IsNullOrWhiteSpace(formDescription))" Generated="GenerateIssueEnglishDescription" />
</div>
</BurmeseContent>
<EnglishContent>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<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" />
<BilingualFieldWarning Text="@formBlockedBy" ExpectedBurmese="false" MoveToOtherLanguage="MoveIssueBlockedByToBurmese" />
<BilingualTranslationAction SourceText="@formBlockedBy" SourceLanguage="en" TargetLanguage="my" TargetHasText="@(!string.IsNullOrWhiteSpace(formBlockedByMy))" Generated="GenerateIssueBurmeseBlockedBy" />
</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" />
<BilingualFieldWarning Text="@formDelayReason" ExpectedBurmese="false" MoveToOtherLanguage="MoveIssueDelayToBurmese" />
<BilingualTranslationAction SourceText="@formDelayReason" SourceLanguage="en" TargetLanguage="my" TargetHasText="@(!string.IsNullOrWhiteSpace(formDelayReasonMy))" Generated="GenerateIssueBurmeseDelay" />
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("field.title", "Title") <span class="text-red-500">*</span></label>
<input @bind="formTitle" type="text" placeholder="@AppLocalization.Text("issue.titlePlaceholder", "Issue 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" />
<BilingualFieldWarning Text="@formTitle" ExpectedBurmese="false" MoveToOtherLanguage="MoveIssueTitleToBurmese" />
<BilingualTranslationAction SourceText="@formTitle" SourceLanguage="en" TargetLanguage="my" TargetHasText="@(!string.IsNullOrWhiteSpace(formTitleMy))" Generated="GenerateIssueBurmeseTitle" />
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.description", "Description")</label>
<textarea @bind="formDescription" rows="3" placeholder="@AppLocalization.Text("issue.descriptionPlaceholder", "Describe the work...")" 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>
<BilingualFieldWarning Text="@formDescription" ExpectedBurmese="false" MoveToOtherLanguage="MoveIssueDescriptionToBurmese" />
<BilingualTranslationAction SourceText="@formDescription" SourceLanguage="en" TargetLanguage="my" TargetHasText="@(!string.IsNullOrWhiteSpace(formDescriptionMy))" Generated="GenerateIssueBurmeseDescription" />
</div>
</EnglishContent>
</BilingualEditorTabs>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.assignee", "Assignee")</label>
<select @bind="formAssignedTo" 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="">@AppLocalization.Text("common.unassigned", "Unassigned")</option>
@foreach (var user in filteredProjectMembers)
{
<option value="@user.Id">@Content.GetUserFullName(user)</option>
}
</select>
</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" />
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<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>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<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>
@if (!string.IsNullOrWhiteSpace(errorMessage))
{
<div class="rounded-lg border border-rose-100 bg-rose-50 p-3">
<p class="text-sm text-rose-600">@errorMessage</p>
</div>
}
</div>
<div class="shrink-0 flex items-center justify-end gap-3 border-t border-slate-100 bg-white px-6 py-4 shadow-[0_-12px_24px_rgba(15,23,42,0.06)]">
<button @onclick="CloseModal" class="rounded-lg px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-100 transition-colors">@AppLocalization.Text("common.cancel", "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 ? AppLocalization.Text("common.loadingSpinner", "Loading...") : AppLocalization.Text("common.update", "Update"))
</button>
</div>
</div>
</div>
}
<ConfirmDialog IsVisible="showDeleteConfirm"
Title="@AppLocalization.Text("issue.deleteIssue", "Delete Issue?")"
Message="@deleteConfirmMessage"
ConfirmText="@AppLocalization.Text("issue.yesDelete", "Yes, Delete")"
Icon="trash-2"
IconBgClass="bg-rose-50"
IconTextClass="text-rose-600"
ButtonClass="bg-rose-600 hover:bg-rose-700"
OnConfirm="ConfirmDelete"
OnCancel="CloseDeleteConfirm" />
<ConfirmDialog IsVisible="showSaveConfirm"
Title="@AppLocalization.Text("common.saveChanges", "Save Changes?")"
Message="@AppLocalization.Text("issue.saveConfirmMessage", "Are you sure you want to save these issue changes?")"
ConfirmText="@AppLocalization.Text("issue.yesSave", "Yes, Save")"
Icon="save"
IconBgClass="bg-violet-50"
IconTextClass="text-violet-600"
ButtonClass="bg-violet-600 hover:bg-violet-700"
OnConfirm="ConfirmSave"
OnCancel="CloseSaveConfirm" />
@code {
private bool isLoading = true;
private bool isSaving;
private bool canCreateIssue;
private bool canEditIssue;
private bool canDeleteIssue;
private bool isAdmin;
private bool showModal;
private bool showDeleteConfirm;
private bool showSaveConfirm;
[Parameter, SupplyParameterFromQuery(Name = "created")]
public bool Created { get; set; }
private string? successMessage;
private string? errorMessage;
private string deleteConfirmMessage = AppLocalization.Text("issue.deleteConfirmMessage", "Are you sure you want to delete this issue?");
private long deleteIssueId;
private long currentUserId;
private List<IssueDto> issues = new();
private List<TaskDto> tasks = new();
private List<ProjectDto> projects = new();
private List<UserDto> projectMembers = new();
private int currentPage = 1;
private int pageSize = 10;
private int totalCount;
private int totalPages;
private string searchInput = "";
private long taskIdInput;
private long projectIdInput;
private AppTaskStatus? statusIdInput;
private TaskPriority? priorityIdInput;
private bool assignedOnly;
private long editingId;
private long formTaskId;
private string formTitle = "";
private string? formTitleMy;
private string? formDescription;
private string? formDescriptionMy;
private long? formAssignedTo;
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 string? formDelayReasonMy;
private bool formIsBlocked;
private string? formBlockedBy;
private string? formBlockedByMy;
private int formEscalationLevel;
private bool burmeseGenerated;
private bool englishGenerated;
private bool burmeseNeedsReview;
private bool englishNeedsReview;
private string SelectedTaskLabel => tasks.FirstOrDefault(t => t.Id == formTaskId) is { } task ? Content.GetTaskTitle(task) : $"{AppLocalization.Text("common.task", "Task")} #{AppLocalization.Number(formTaskId)}";
private string SelectedProjectLabel => tasks.FirstOrDefault(t => t.Id == formTaskId) is { } task
? projects.FirstOrDefault(p => p.Id == task.ProjectId) is { } project ? Content.GetProjectName(project) : $"{AppLocalization.Text("common.project", "Project")} #{AppLocalization.Number(task.ProjectId)}"
: AppLocalization.Text("issue.unknownProject", "Unknown project");
private IEnumerable<UserDto> filteredProjectMembers => projectMembers.Where(u => u.IsActive);
private static string GetTaskCode(long taskId) => $"TSK-{AppLocalization.LocalizeDigits(taskId.ToString("D4", System.Globalization.CultureInfo.InvariantCulture))}";
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var user = authState.User;
isAdmin = user.IsInRole("Admin");
if (long.TryParse(user.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var userId))
{
currentUserId = userId;
}
canCreateIssue = isAdmin || await MenuAuthorization.HasAccessCodeAsync(user, "Issues_Create");
canEditIssue = isAdmin || await MenuAuthorization.HasAccessCodeAsync(user, "Issues_Update");
canDeleteIssue = isAdmin || await MenuAuthorization.HasAccessCodeAsync(user, "Issues_Delete");
if (Created)
{
successMessage = AppLocalization.Text("issue.created", "Issue created successfully!");
}
var client = ApiClient.CreateClient();
try
{
var issuesTask = LoadIssuesPageAsync(client);
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 System.Threading.Tasks.Task.WhenAll(issuesTask, tasksTask, projectsTask, usersTask);
tasks = tasksTask.Result ?? new();
projects = projectsTask.Result ?? new();
projectMembers = usersTask.Result?.Where(u => u.IsActive).OrderBy(u => u.FirstName).ThenBy(u => u.LastName).ToList() ?? new();
ApplyPageResult(issuesTask.Result);
}
catch (Exception ex)
{
errorMessage = AppLocalization.Text("issue.loadFailed", "Unable to load issues. Please try again.");
Console.WriteLine($"Load issues error: {ex.Message}");
}
finally
{
isLoading = false;
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await JS.InvokeVoidAsync("initIcons");
}
private async Task<PagedResult<IssueDto>?> LoadIssuesPageAsync(HttpClient? client = null)
{
client ??= ApiClient.CreateClient();
return await client.GetFromJsonAsync<PagedResult<IssueDto>>(BuildIssuesUrl(), Serialization.CaseInsensitive);
}
private void ApplyPageResult(PagedResult<IssueDto>? result)
{
issues = result?.Items ?? new();
totalCount = result?.TotalCount ?? 0;
totalPages = result?.TotalPages ?? 0;
currentPage = result?.Page ?? currentPage;
pageSize = result?.PageSize ?? pageSize;
RemoveOrphanedIssues();
}
private bool HasActiveParentRecords(IssueDto issue)
{
var task = tasks.FirstOrDefault(t => t.Id == issue.TaskId);
return task != null &&
task.ProjectId == issue.ProjectId &&
projects.Any(project => project.Id == task.ProjectId);
}
private void RemoveOrphanedIssues()
{
var activeIssues = issues.Where(HasActiveParentRecords).ToList();
var removedCount = issues.Count - activeIssues.Count;
if (removedCount == 0)
{
return;
}
issues = activeIssues;
totalCount = Math.Max(0, totalCount - removedCount);
totalPages = pageSize > 0 ? (int)Math.Ceiling(totalCount / (double)pageSize) : 0;
}
private async Task LoadIssues()
{
var client = ApiClient.CreateClient();
var result = await LoadIssuesPageAsync(client);
ApplyPageResult(result);
}
private string BuildIssuesUrl()
{
var query = new List<string>
{
$"page={currentPage}",
$"limit={pageSize}"
};
if (!string.IsNullOrWhiteSpace(searchInput))
{
query.Add($"search={Uri.EscapeDataString(searchInput.Trim())}");
}
if (taskIdInput > 0)
{
query.Add($"taskId={taskIdInput}");
}
if (projectIdInput > 0)
{
query.Add($"projectId={projectIdInput}");
}
if (statusIdInput.HasValue)
{
query.Add($"statusId={(int)statusIdInput.Value}");
}
if (priorityIdInput.HasValue)
{
query.Add($"priorityId={(int)priorityIdInput.Value}");
}
if (assignedOnly)
{
query.Add("assignedOnly=true");
}
return $"Issue?{string.Join("&", query)}";
}
private async Task ApplySearch()
{
currentPage = 1;
await LoadIssues();
}
private async Task ResetSearch()
{
searchInput = "";
taskIdInput = 0;
projectIdInput = 0;
statusIdInput = null;
priorityIdInput = null;
assignedOnly = false;
currentPage = 1;
await LoadIssues();
}
private async Task HandlePageChanged(int page)
{
currentPage = page;
await LoadIssues();
}
private async Task HandlePageSizeChanged(int size)
{
pageSize = size;
currentPage = 1;
await LoadIssues();
}
private void GoToAddIssue()
{
Navigation.NavigateTo("/issues/add");
}
private async Task OpenEditModal(IssueDto issue)
{
if (!canEditIssue)
{
return;
}
// The page can still contain a DTO loaded before its task/project was deleted.
// Never open an issue editor for an orphaned issue.
if (!HasActiveParentRecords(issue))
{
issues.RemoveAll(current => current.Id == issue.Id);
totalCount = Math.Max(0, totalCount - 1);
totalPages = pageSize > 0 ? (int)Math.Ceiling(totalCount / (double)pageSize) : 0;
return;
}
editingId = issue.Id;
formTaskId = issue.TaskId;
formTitle = issue.Title;
formTitleMy = issue.TitleMy;
formDescription = issue.Description;
formDescriptionMy = issue.DescriptionMy;
formAssignedTo = issue.AssignedTo;
formActualHours = issue.ActualHours;
formStartDate = issue.StartDate;
formDueDate = issue.DueDate;
formStatusId = issue.StatusId;
formPriorityId = issue.PriorityId;
formDelayReason = issue.DelayReason;
formDelayReasonMy = issue.DelayReasonMy;
formIsBlocked = issue.IsBlocked;
formBlockedBy = issue.BlockedBy;
formBlockedByMy = issue.BlockedByMy;
formEscalationLevel = issue.EscalationLevel;
burmeseGenerated = englishGenerated = burmeseNeedsReview = englishNeedsReview = false;
errorMessage = null;
showModal = true;
await LoadMembersForTaskAsync(issue.TaskId);
}
private void CloseModal()
{
showModal = false;
errorMessage = null;
editingId = 0;
}
private void MoveIssueTitleToEnglish() => formTitle = formTitleMy ?? string.Empty;
private void MoveIssueTitleToBurmese() => formTitleMy = formTitle;
private void MoveIssueDescriptionToEnglish() => formDescription = formDescriptionMy;
private void MoveIssueDescriptionToBurmese() => formDescriptionMy = formDescription;
private void MoveIssueDelayToEnglish() => formDelayReason = formDelayReasonMy;
private void MoveIssueDelayToBurmese() => formDelayReasonMy = formDelayReason;
private void MoveIssueBlockedByToEnglish() => formBlockedBy = formBlockedByMy;
private void MoveIssueBlockedByToBurmese() => formBlockedByMy = formBlockedBy;
private void MarkGenerated(bool english)
{
if (english) { englishGenerated = true; englishNeedsReview = true; }
else { burmeseGenerated = true; burmeseNeedsReview = true; }
}
private Task GenerateIssueEnglishTitle(TranslationResult result) { formTitle = result.TranslatedText ?? string.Empty; MarkGenerated(true); return Task.CompletedTask; }
private Task GenerateIssueBurmeseTitle(TranslationResult result) { formTitleMy = result.TranslatedText; MarkGenerated(false); return Task.CompletedTask; }
private Task GenerateIssueEnglishDescription(TranslationResult result) { formDescription = result.TranslatedText; MarkGenerated(true); return Task.CompletedTask; }
private Task GenerateIssueBurmeseDescription(TranslationResult result) { formDescriptionMy = result.TranslatedText; MarkGenerated(false); return Task.CompletedTask; }
private Task GenerateIssueEnglishDelay(TranslationResult result) { formDelayReason = result.TranslatedText; MarkGenerated(true); return Task.CompletedTask; }
private Task GenerateIssueBurmeseDelay(TranslationResult result) { formDelayReasonMy = result.TranslatedText; MarkGenerated(false); return Task.CompletedTask; }
private Task GenerateIssueEnglishBlockedBy(TranslationResult result) { formBlockedBy = result.TranslatedText; MarkGenerated(true); return Task.CompletedTask; }
private Task GenerateIssueBurmeseBlockedBy(TranslationResult result) { formBlockedByMy = result.TranslatedText; MarkGenerated(false); return Task.CompletedTask; }
private async Task LoadMembersForTaskAsync(long taskId)
{
projectMembers.Clear();
var task = tasks.FirstOrDefault(t => t.Id == taskId);
if (task == null)
{
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();
}
}
catch (Exception ex)
{
Console.WriteLine($"Failed to load issue assignees: {ex.Message}");
}
}
private void RequestSaveConfirmation()
{
showSaveConfirm = true;
}
private void CloseSaveConfirm()
{
showSaveConfirm = false;
}
private async Task ConfirmSave()
{
showSaveConfirm = false;
await SaveIssueAsync();
}
private async Task SaveIssueAsync()
{
if (editingId <= 0)
{
return;
}
isSaving = true;
errorMessage = null;
try
{
var client = ApiClient.CreateClient();
var dto = new UpdateIssueDto
{
Title = formTitle.Trim(),
TitleMy = formTitleMy,
Description = formDescription,
DescriptionMy = formDescriptionMy,
AssignedTo = formAssignedTo,
ActualHours = formActualHours,
DelayReason = formDelayReason,
DelayReasonMy = formDelayReasonMy,
IsBlocked = formIsBlocked,
BlockedBy = formBlockedBy,
BlockedByMy = formBlockedByMy,
EscalationLevel = formEscalationLevel,
StartDate = formStartDate,
DueDate = formDueDate,
StatusId = formStatusId,
PriorityId = formPriorityId
};
var response = await client.PutAsJsonAsync($"Issue/{editingId}", dto);
if (response.IsSuccessStatusCode)
{
successMessage = AppLocalization.Text("issue.updated", "Issue updated successfully!");
showModal = false;
await LoadIssues();
}
else
{
errorMessage = AppLocalization.Text("issue.updateFailed", "Unable to update the issue. Please try again.");
}
}
catch (Exception ex)
{
errorMessage = AppLocalization.Text("issue.updateFailed", "Unable to update the issue. Please try again.");
Console.WriteLine($"Update issue error: {ex.Message}");
}
finally
{
isSaving = false;
}
}
private void RequestDeleteConfirmation(long issueId)
{
if (!canDeleteIssue)
{
return;
}
deleteIssueId = issueId;
deleteConfirmMessage = AppLocalization.Text("issue.deleteConfirmMessage", "Are you sure you want to delete this issue?");
showDeleteConfirm = true;
}
private void CloseDeleteConfirm()
{
showDeleteConfirm = false;
}
private async Task ConfirmDelete()
{
showDeleteConfirm = false;
try
{
var client = ApiClient.CreateClient();
var response = await client.DeleteAsync($"Issue/{deleteIssueId}");
if (response.IsSuccessStatusCode)
{
successMessage = AppLocalization.Text("issue.deleted", "Issue deleted successfully!");
await LoadIssues();
}
else
{
errorMessage = AppLocalization.Text("issue.deleteFailed", "Unable to delete the issue. Please try again.");
}
}
catch (Exception ex)
{
errorMessage = AppLocalization.Text("issue.deleteFailed", "Unable to delete the issue. Please try again.");
Console.WriteLine($"Delete issue error: {ex.Message}");
}
}
private static async Task<string> ReadErrorMessageAsync(HttpResponseMessage response)
{
var body = await response.Content.ReadAsStringAsync();
if (string.IsNullOrWhiteSpace(body))
{
return $"Request failed with status {(int)response.StatusCode}.";
}
try
{
using var doc = System.Text.Json.JsonDocument.Parse(body);
if (doc.RootElement.TryGetProperty("message", out var message))
{
return message.GetString() ?? body;
}
}
catch
{
}
return body;
}
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 => AppLocalization.Text("status.todo", "To Do"),
AppTaskStatus.InProgress => AppLocalization.Text("status.inprogress", "In Progress"),
AppTaskStatus.Done => AppLocalization.Text("status.done", "Done"),
_ => AppLocalization.Text("common.unknown", "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 => AppLocalization.Text("priority.low", "Low"),
TaskPriority.Medium => AppLocalization.Text("priority.medium", "Medium"),
TaskPriority.High => AppLocalization.Text("priority.high", "High"),
_ => AppLocalization.Text("common.unknown", "Unknown")
};
private static string GetEscalationLabel(int level) => AppLocalization.EscalationLabel(level);
}