Spaces:
Running
Running
| @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" 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">@task.Title</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">@project.Name</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" 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"> | |
| <i data-lucide="rotate-ccw" class="h-4 w-4 mr-2 text-slate-500"></i> | |
| @AppLocalization.Text("action.reset", "Reset") | |
| </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"> | |
| @issue.Title | |
| </button> | |
| </td> | |
| <td class="px-5 py-4 text-slate-600 whitespace-nowrap"> | |
| <span class="font-medium text-slate-700">@(issue.ActualHours?.ToString("0.##") ?? "-") @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">@AppLocalization.Text("common.due", "Due"): @issue.DueDate.ToString("MMM d, yyyy")</span> | |
| </td> | |
| <td class="px-5 py-4 text-sm text-slate-600 break-words"> | |
| @(!string.IsNullOrWhiteSpace(issue.AssignedToName) ? issue.AssignedToName : 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">#@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> | |
| <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" /> | |
| </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> | |
| <div> | |
| <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("common.issueTitle", "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" /> | |
| </div> | |
| <div> | |
| <label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.issueDescription", "Description")</label> | |
| <textarea @bind="formDescription" rows="3" placeholder="@AppLocalization.Text("issue.describeWork", "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> | |
| </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.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">@user.FirstName @user.LastName</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; | |
| private string? successMessage; | |
| private string? errorMessage; | |
| private string 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? formDescription; | |
| 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 bool formIsBlocked; | |
| private string? formBlockedBy; | |
| private int formEscalationLevel; | |
| private string SelectedTaskLabel => tasks.FirstOrDefault(t => t.Id == formTaskId)?.Title ?? $"Task #{formTaskId}"; | |
| private string SelectedProjectLabel => tasks.FirstOrDefault(t => t.Id == formTaskId) is { } task | |
| ? projects.FirstOrDefault(p => p.Id == task.ProjectId)?.Name ?? $"Project #{task.ProjectId}" | |
| : "Unknown project"; | |
| private IEnumerable<UserDto> filteredProjectMembers => projectMembers.Where(u => u.IsActive); | |
| private static string GetTaskCode(long taskId) => $"TSK-{taskId:D4}"; | |
| 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"); | |
| 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); | |
| ApplyPageResult(issuesTask.Result); | |
| 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(); | |
| } | |
| catch (Exception ex) | |
| { | |
| errorMessage = 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; | |
| } | |
| 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; | |
| } | |
| editingId = issue.Id; | |
| formTaskId = issue.TaskId; | |
| formTitle = issue.Title; | |
| formDescription = issue.Description; | |
| formAssignedTo = issue.AssignedTo; | |
| formActualHours = issue.ActualHours; | |
| formStartDate = issue.StartDate; | |
| formDueDate = issue.DueDate; | |
| formStatusId = issue.StatusId; | |
| formPriorityId = issue.PriorityId; | |
| formDelayReason = issue.DelayReason; | |
| formIsBlocked = issue.IsBlocked; | |
| formBlockedBy = issue.BlockedBy; | |
| formEscalationLevel = issue.EscalationLevel; | |
| errorMessage = null; | |
| showModal = true; | |
| await LoadMembersForTaskAsync(issue.TaskId); | |
| } | |
| private void CloseModal() | |
| { | |
| showModal = false; | |
| errorMessage = null; | |
| editingId = 0; | |
| } | |
| 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(), | |
| Description = formDescription, | |
| AssignedTo = formAssignedTo, | |
| ActualHours = formActualHours, | |
| DelayReason = formDelayReason, | |
| IsBlocked = formIsBlocked, | |
| BlockedBy = formBlockedBy, | |
| EscalationLevel = formEscalationLevel, | |
| StartDate = formStartDate, | |
| DueDate = formDueDate, | |
| StatusId = formStatusId, | |
| PriorityId = formPriorityId | |
| }; | |
| var response = await client.PutAsJsonAsync($"Issue/{editingId}", dto); | |
| if (response.IsSuccessStatusCode) | |
| { | |
| successMessage = "Issue updated successfully!"; | |
| showModal = false; | |
| await LoadIssues(); | |
| } | |
| else | |
| { | |
| errorMessage = await ReadErrorMessageAsync(response); | |
| } | |
| } | |
| catch (Exception ex) | |
| { | |
| errorMessage = ex.Message; | |
| } | |
| finally | |
| { | |
| isSaving = false; | |
| } | |
| } | |
| private void RequestDeleteConfirmation(long issueId) | |
| { | |
| if (!canDeleteIssue) | |
| { | |
| return; | |
| } | |
| deleteIssueId = issueId; | |
| 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 = "Issue deleted successfully!"; | |
| await LoadIssues(); | |
| } | |
| else | |
| { | |
| errorMessage = await ReadErrorMessageAsync(response); | |
| } | |
| } | |
| catch (Exception ex) | |
| { | |
| errorMessage = 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 => "To Do", | |
| AppTaskStatus.InProgress => "In Progress", | |
| AppTaskStatus.Done => "Done", | |
| _ => "Unknown" | |
| }; | |
| private string GetPriorityBadge(TaskPriority priorityId) => priorityId switch | |
| { | |
| TaskPriority.Low => "bg-slate-100 text-slate-600", | |
| TaskPriority.Medium => "bg-amber-100 text-amber-700", | |
| TaskPriority.High => "bg-red-100 text-red-700", | |
| _ => "bg-slate-100 text-slate-600" | |
| }; | |
| private string GetPriorityLabel(TaskPriority priorityId) => priorityId switch | |
| { | |
| TaskPriority.Low => "Low", | |
| TaskPriority.Medium => "Medium", | |
| TaskPriority.High => "High", | |
| _ => "Unknown" | |
| }; | |
| private static string GetEscalationLabel(int level) => level switch | |
| { | |
| 1 => "Team Lead", | |
| 2 => "PM", | |
| 3 => "Manager", | |
| _ => "None" | |
| }; | |
| } | |