User
Deploy localized dashboard fixes
3dd5c9a
Raw History Blame Contribute Delete
77.3 kB
@page "/roles"
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using TaskTrackingSystem.Shared.Models.Menu
@using TaskTrackingSystem.Shared.Models.User
@rendermode @(new InteractiveServerRenderMode(prerender: false))
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@inject ApiClientService ApiClient
@inject IJSRuntime JS
@inject AuthenticationStateProvider AuthStateProvider
@inject MenuAuthorizationService MenuAuthorization
<PageTitle>@AppLocalization.Text("role.rolesAccess", "Roles & Access")</PageTitle>
<SuccessAlert Message="@successMessage" />
<div class="space-y-6">
<!-- Page Header -->
<div class="flex items-center justify-between">
<div>
<h2 class="text-3xl font-bold tracking-tight text-slate-900">@AppLocalization.Text("role.rolesAccess", "Roles & Access")</h2>
<p class="text-slate-500 mt-1">@AppLocalization.Text("role.rolesAccessDesc", "Manage roles, assign access and permissions, and control visibility.")</p>
</div>
@if (canCreateRole)
{
<button @onclick="OpenCreateModal" class="inline-flex items-center rounded-lg bg-violet-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-violet-700 transition-colors shadow-sm">
<i data-lucide="plus" class="h-4 w-4 mr-2"></i>
@AppLocalization.Text("role.newRole", "New Role")
</button>
}
</div>
@if (isLoading)
{
<LoadingSpinner Message="@AppLocalization.Text("role.loadingRoles", "Loading roles...")" />
}
else
{
<!-- Search Panel -->
<div class="rounded-xl border border-slate-200 bg-white p-4 shadow-sm mb-4">
<div class="flex items-center gap-3">
<div class="relative flex-1 max-w-md">
<input @bind="searchInput" @bind:event="oninput" type="text" placeholder="@AppLocalization.Text("role.searchByName", "Search roles by name...")" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" />
</div>
<button @onclick="ApplySearch" class="inline-flex items-center rounded-lg bg-violet-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-violet-700 transition-colors shadow-sm">
<i data-lucide="search" class="h-4 w-4 mr-2"></i>
@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.5 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>
<!-- Roles Table -->
<div class="rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden">
<table class="w-full">
<thead>
<tr class="border-b border-slate-100 bg-slate-50/60">
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.index", "No.")</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.role", "Role")</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.description", "Description")</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("field.created", "Created")</th>
<th class="px-6 py-3 text-right 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">
@if (PagedRoles.Any())
{
@foreach (var entry in PagedRoles.Select((role, index) => (role, index)))
{
var role = entry.role;
<tr class="hover:bg-slate-50/50 transition-colors">
<td class="px-6 py-4 text-sm font-semibold text-slate-500">@((currentPage - 1) * pageSize + entry.index + 1)</td>
<td class="px-6 py-4">
<div class="flex items-center space-x-3">
<span class="flex h-9 w-9 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600">
<i data-lucide="shield-check" class="h-4 w-4"></i>
</span>
<span class="text-sm font-semibold text-slate-900">@Content.GetRoleName(role)</span>
</div>
</td>
<td class="px-6 py-4 text-sm text-slate-500 max-w-xs truncate">@(Content.GetText(role.Description, role.DescriptionMy) is { Length: > 0 } description ? description : AppLocalization.Text("common.na", "N/A"))</td>
<td class="px-6 py-4 text-sm text-slate-400">@DisplayFormats.Date(role.CreatedAt)</td>
<td class="px-6 py-4 text-right">
<div class="flex items-center justify-end space-x-2">
@if (canManageAccess)
{
<button @onclick="() => OpenAccessModal(role)"
class="inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium text-indigo-700 bg-indigo-50 hover:bg-indigo-100 transition-all"
title="@AppLocalization.Text("role.manageAccess", "Manage Access")">
<i data-lucide="shield-check" class="h-3.5 w-3.5"></i>
@AppLocalization.Text("role.access", "Access")
</button>
}
@if (canEditRole)
{
<button @onclick="() => OpenEditModal(role)" 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 (canDeleteRole)
{
<button @onclick="() => RequestDeleteConfirmation(role.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="5" Icon="shield-check" Title="@AppLocalization.Text("role.noRolesYet", "No roles yet")" Subtitle="@AppLocalization.Text("role.createFirstRole", "Create your first role to get started.")" />
}
</tbody>
</table>
<!-- Pagination -->
<Pagination CurrentPage="currentPage"
TotalPages="TotalPages"
PageSize="pageSize"
TotalCount="TotalCount"
OnPageChanged="HandlePageChanged"
OnPageSizeChanged="HandlePageSizeChanged" />
</div>
}
</div>
@* ===== Create / Edit Role Modal ===== *@
@if (showRoleModal)
{
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div class="bg-white rounded-2xl shadow-lg w-full mx-4 flex flex-col overflow-hidden"
style="max-width: @(isEditing ? "30rem" : "58rem"); max-height: 88vh;">
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-100 bg-slate-50 shrink-0">
<div class="flex items-center gap-3">
<span class="flex h-8 w-8 items-center justify-center rounded-lg bg-violet-600 text-white">
<i data-lucide="@(isEditing ? "pencil" : "shield-plus")" class="h-4 w-4"></i>
</span>
<h3 class="text-base font-semibold text-slate-900">
@(isEditing ? AppLocalization.Text("role.editRole", "Edit Role") : AppLocalization.Text("role.createNewRole", "Create New Role"))
</h3>
</div>
<button @onclick="CloseRoleModal" class="rounded-lg p-1.5 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>
<!-- Body: two panels on Create, single on Edit -->
<div class="flex overflow-hidden flex-1 min-h-0">
<!-- LEFT: Role Details -->
<div class="p-6 space-y-4 overflow-y-auto @(isEditing ? "w-full" : "w-5/12") border-r border-slate-100">
<BilingualEditorTabs BurmeseComplete="@(!string.IsNullOrWhiteSpace(formNameMy))"
EnglishComplete="@(!string.IsNullOrWhiteSpace(formName))"
BurmeseAiGenerated="@burmeseGenerated"
EnglishAiGenerated="@englishGenerated"
BurmeseNeedsReview="@burmeseNeedsReview"
EnglishNeedsReview="@englishNeedsReview">
<BurmeseContent>
<label class="block text-sm font-medium text-slate-700 mb-1.5">အမည် <span class="text-red-500">*</span></label>
<input @bind="formNameMy" type="text" placeholder="အမည်" class="w-full rounded-lg border @(isRoleNameError && string.IsNullOrWhiteSpace(formName) ? "border-red-500" : "border-slate-200") px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600" />
<BilingualFieldWarning Text="@formNameMy" ExpectedBurmese="true" MoveToOtherLanguage="MoveRoleNameToEnglish" />
<BilingualTranslationAction SourceText="@formNameMy" SourceLanguage="my" TargetLanguage="en" TargetHasText="@(!string.IsNullOrWhiteSpace(formName))" Generated="GenerateRoleEnglishName" />
<label class="mt-4 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 focus:outline-none focus:ring-2 focus:ring-violet-600 resize-none"></textarea>
<BilingualTranslationAction SourceText="@formDescriptionMy" SourceLanguage="my" TargetLanguage="en" TargetHasText="@(!string.IsNullOrWhiteSpace(formDescription))" Generated="GenerateRoleEnglishDescription" />
</BurmeseContent>
<EnglishContent>
<label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.name", "Name") <span class="text-red-500">*</span></label>
<input @bind="formName" type="text" placeholder="@AppLocalization.Text("role.namePlaceholder", "e.g. Manager, Viewer")" class="w-full rounded-lg border @(isRoleNameError && string.IsNullOrWhiteSpace(formNameMy) ? "border-red-500" : "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" />
<BilingualFieldWarning Text="@formName" ExpectedBurmese="false" MoveToOtherLanguage="MoveRoleNameToBurmese" />
<BilingualTranslationAction SourceText="@formName" SourceLanguage="en" TargetLanguage="my" TargetHasText="@(!string.IsNullOrWhiteSpace(formNameMy))" Generated="GenerateRoleBurmeseName" />
<label class="mt-4 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("role.descriptionPlaceholder", "What can this role do?")" 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 resize-none"></textarea>
<BilingualTranslationAction SourceText="@formDescription" SourceLanguage="en" TargetLanguage="my" TargetHasText="@(!string.IsNullOrWhiteSpace(formDescriptionMy))" Generated="GenerateRoleBurmeseDescription" />
</EnglishContent>
</BilingualEditorTabs>
@if (isRoleNameError) { <p class="mt-1 text-xs text-red-500">@roleNameErrorMsg</p> }
@if (!string.IsNullOrEmpty(roleError))
{
<div class="p-3 rounded-lg bg-red-50 border border-red-100 flex items-start gap-2">
<i data-lucide="alert-circle" class="h-4 w-4 text-red-500 mt-0.5 shrink-0"></i>
<p class="text-sm text-red-600">@roleError</p>
</div>
}
@if (!isEditing)
{
<p class="text-xs text-slate-400 flex items-center gap-1">
<i data-lucide="info" class="h-3.5 w-3.5 shrink-0"></i>
@AppLocalization.Text("role.createHint", "Choose what this role can see and do on the right. It will be saved with the role.")
</p>
}
</div>
<!-- RIGHT: Access (Create only) -->
@if (!isEditing)
{
<div class="w-7/12 flex flex-col min-h-0 border-l border-slate-100">
<div class="px-5 py-3 border-b border-slate-100 bg-slate-50/60 shrink-0 flex items-center justify-between gap-3">
<div class="flex items-center gap-2 min-w-0">
<i data-lucide="shield-check" class="h-4 w-4 text-violet-500"></i>
<span class="text-sm font-semibold text-slate-700">@AppLocalization.Text("role.assignAccess", "Assign Access")</span>
<span class="hidden md:inline text-xs text-slate-400">@AppLocalization.Text("role.accessHint", "Pages control where the role can go. Actions control what it can do.")</span>
</div>
<div class="flex items-center gap-2">
<div class="relative w-36 sm:w-44">
<i data-lucide="search" class="pointer-events-none absolute left-2.5 top-2.5 h-3.5 w-3.5 text-slate-400"></i>
<input @bind-value="formAccessSearch" @bind-value:event="oninput" type="text" placeholder="@AppLocalization.Text("common.search", "Search")"
class="w-full rounded-lg border border-slate-200 bg-white pl-8 pr-3 py-2 text-xs text-slate-700 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent" />
</div>
@if (formAccessItems.Any())
{
<label class="flex items-center gap-1.5 cursor-pointer group whitespace-nowrap">
<input type="checkbox"
checked="@IsAllFormAccessSelected"
@onchange="ToggleSelectAllFormAccess"
class="h-3.5 w-3.5 rounded accent-violet-600" />
<span class="text-xs text-slate-500 group-hover:text-violet-600 transition-colors">@AppLocalization.Text("common.all", "All")</span>
</label>
}
</div>
</div>
<div class="overflow-y-auto flex-1 px-4 py-4">
@if (isLoadingFormAccessItems)
{
<div class="flex items-center justify-center py-10">
<svg class="animate-spin h-5 w-5 text-violet-500" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</div>
}
else if (!formAccessItems.Any())
{
<p class="text-sm text-slate-400 text-center py-8">@AppLocalization.Text("role.noAccessItemsFound", "No access items found in database.")</p>
}
else
{
<div class="mb-4 rounded-xl border border-slate-100 bg-slate-50/60 px-4 py-3 text-xs text-slate-500 flex items-center justify-between">
<span>@AppLocalization.Text("role.pagePermissionHint", "Page access lets the role open this section. Permissions let it use the buttons and actions inside.")</span>
<span class="font-medium text-slate-400">@formSelectedAccessCodes.Count @AppLocalization.Text("common.selected", "selected")</span>
</div>
@if (GetRootAccessItems(true).Any())
{
<div class="grid grid-cols-1 xl:grid-cols-2 gap-3">
@foreach (var parent in GetRootAccessItems(true))
{
var children = GetChildAccessItems(parent, true).ToList();
var parentChecked = formSelectedAccessCodes.Contains(parent.MenuCode);
<div class="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden">
<label class="flex items-start gap-3 px-4 py-3 bg-slate-50 cursor-pointer hover:bg-slate-50 transition-colors">
<input type="checkbox" checked="@parentChecked"
@onchange="(e) => ToggleAccessSelection(parent, (bool)(e.Value ?? false), isForm: true)"
class="mt-0.5 h-4 w-4 rounded border-slate-300 accent-violet-600" />
<div class="flex min-w-0 flex-1 items-start gap-2">
@if (!string.IsNullOrEmpty(parent.Icon))
{
<i data-lucide="@parent.Icon" class="mt-0.5 h-4 w-4 text-violet-500 shrink-0"></i>
}
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="text-sm font-semibold text-slate-800 truncate">@Content.GetMenuName(parent)</span>
<span class="rounded-full bg-violet-50 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-violet-600">@AppLocalization.Text("common.module", "အပိုင်း")</span>
</div>
<div class="text-[11px] text-slate-400 truncate">@parent.MenuCode</div>
</div>
</div>
</label>
<div class="border-t border-slate-100 px-3 py-3">
@if (parent.Permissions.Any())
{
<div class="mb-3">
<div class="mb-2 flex items-center justify-between px-1">
<span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">@AppLocalization.Text("common.actions", "Actions")</span>
<span class="text-[11px] text-slate-400">@AppLocalization.Text("role.crudPermissions", "CRUD permissions for this page")</span>
</div>
<div class="flex flex-wrap gap-2">
@foreach (var action in parent.Permissions)
{
var isActionChecked = formSelectedAccessCodes.Contains(action.PermissionCode);
<label class="inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] cursor-pointer select-none transition-all
@(isActionChecked
? "bg-violet-50 border-violet-200 text-violet-700"
: "bg-white border-slate-200 text-slate-500 hover:border-violet-200 hover:text-violet-500")">
<input type="checkbox" checked="@isActionChecked"
@onchange="(e) => TogglePermissionSelection(action.PermissionCode, (bool)(e.Value ?? false), isForm: true)"
class="h-3.5 w-3.5 rounded accent-violet-600" />
<span>@Content.GetPermissionName(action.ActionName, action.ActionNameMy)</span>
</label>
}
</div>
</div>
}
@if (children.Any())
{
<div class="space-y-2">
@foreach (var child in children)
{
var childChecked = formSelectedAccessCodes.Contains(child.MenuCode);
<div class="rounded-xl border border-slate-100 bg-slate-50/50 p-3">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked="@childChecked"
@onchange="(e) => ToggleAccessSelection(child, (bool)(e.Value ?? false), isForm: true)"
class="h-4 w-4 rounded border-slate-300 accent-violet-600" />
<span class="text-sm font-medium text-slate-700">@Content.GetMenuName(child)</span>
</label>
@if (child.Permissions.Any())
{
<div class="mt-2 flex flex-wrap gap-2 pl-6">
@foreach (var action in child.Permissions)
{
var isActionChecked = formSelectedAccessCodes.Contains(action.PermissionCode);
<label class="inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] cursor-pointer select-none transition-all
@(isActionChecked
? "bg-violet-50 border-violet-200 text-violet-700"
: "bg-white border-slate-200 text-slate-500 hover:border-violet-200 hover:text-violet-500")">
<input type="checkbox" checked="@isActionChecked"
@onchange="(e) => TogglePermissionSelection(action.PermissionCode, (bool)(e.Value ?? false), isForm: true)"
class="h-3 w-3 rounded accent-violet-600" />
<span>@Content.GetPermissionName(action.ActionName, action.ActionNameMy)</span>
</label>
}
</div>
}
</div>
}
</div>
}
else if (!parent.Permissions.Any())
{
<p class="text-xs text-slate-400">@AppLocalization.Text("role.nothingInsidePage", "Nothing else is available inside this page.")</p>
}
</div>
</div>
}
</div>
}
else
{
<p class="py-10 text-center text-sm text-slate-400">@AppLocalization.Text("role.noPagesMatchSearch", "No pages or permissions match your search.")</p>
}
}
</div>
</div>
}
</div>
<!-- Footer -->
<div class="flex items-center justify-between px-6 py-4 border-t border-slate-100 bg-slate-50/50 shrink-0">
<span class="text-xs text-slate-400">
@if (!isEditing && formSelectedAccessCodes.Count > 0)
{
<span class="text-violet-600 font-medium">@AppLocalization.Text("role.selectedItems", "{count} selected item(s)").Replace("{count}", formSelectedAccessCodes.Count.ToString())</span>
<span> @AppLocalization.Text("role.willBeSaved", "will be saved")</span>
}
</span>
<div class="flex items-center gap-3">
<button @onclick="CloseRoleModal" 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="inline-flex items-center gap-2 rounded-lg bg-violet-600 px-5 py-2 text-sm font-medium text-white hover:bg-violet-700 disabled:opacity-50 transition-colors shadow-sm">
<svg class="animate-spin h-4 w-4 @(isSaving ? "" : "hidden")" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
<span class="@(isSaving ? "" : "hidden")">@AppLocalization.Text("common.saving", "Saving...")</span>
<i data-lucide="@(isEditing ? "check" : "shield-plus")" class="h-4 w-4 @(isSaving ? "hidden" : "")"></i>
<span class="@(isSaving ? "hidden" : "")">@(isEditing ? AppLocalization.Text("action.update", "Update") : AppLocalization.Text("action.create", "Create"))</span>
</button>
</div>
</div>
</div>
</div>
}
@* ===== Assign Access Modal ===== *@
@* ===== Role Access Modal ===== *@
@if (showAccessModal)
{
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div class="bg-white rounded-2xl shadow-lg w-full max-w-6xl mx-4 overflow-hidden flex flex-col" style="max-height: 88vh;">
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-100 bg-slate-50">
<div class="flex items-center gap-3">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-violet-100 text-violet-600">
<i data-lucide="shield-check" class="h-4 w-4"></i>
</div>
<div>
<h3 class="text-base font-semibold text-slate-900">@AppLocalization.Text("role.access", "Access") - <span class="text-violet-700">@selectedRoleName</span></h3>
<p class="text-xs text-slate-500">@AppLocalization.Text("role.accessConfigured", "Configure access and permissions")</p>
</div>
</div>
<button @onclick="CloseAccessModal" class="rounded-lg p-1.5 text-slate-400 hover:bg-white hover:text-slate-600 transition-all">
<i data-lucide="x" class="h-5 w-5"></i>
</button>
</div>
<!-- Select All Bar -->
<div class="px-6 py-3 border-b border-slate-100 bg-slate-50/60 flex items-center justify-between gap-3">
<div class="flex items-center gap-2 min-w-0">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked="@IsAllAccessSelected" @onchange="ToggleSelectAllAccess"
class="h-4 w-4 rounded border-slate-300 accent-violet-600" />
<span class="text-sm font-medium text-slate-700">@AppLocalization.Text("common.selectAll", "Select All")</span>
</label>
<span class="hidden md:inline text-xs text-slate-400">@AppLocalization.Text("role.permissionsHint", "Menus open pages. Permissions unlock actions.")</span>
</div>
<div class="flex items-center gap-2">
<div class="relative w-36 sm:w-48">
<i data-lucide="search" class="pointer-events-none absolute left-2.5 top-2.5 h-3.5 w-3.5 text-slate-400"></i>
<input @bind-value="accessSearch" @bind-value:event="oninput" type="text" placeholder="@AppLocalization.Text("common.search", "Search")"
class="w-full rounded-lg border border-slate-200 bg-white pl-8 pr-3 py-2 text-xs text-slate-700 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent" />
</div>
<span class="text-xs text-slate-400 whitespace-nowrap">@selectedAccessCodes.Count @AppLocalization.Text("common.selected", "selected")</span>
</div>
</div>
<!-- Menu Items Body -->
<div class="overflow-y-auto flex-1 px-4 py-4">
@if (isLoadingAccessItems)
{
<div class="flex items-center justify-center py-12">
<svg class="animate-spin h-6 w-6 text-violet-500" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</div>
}
else if (!accessItems.Any())
{
<div class="text-center py-12">
<i data-lucide="shield-check" class="h-10 w-10 mx-auto text-slate-300 mb-3"></i>
<p class="text-sm text-slate-500">@AppLocalization.Text("role.noAccessItemsFound", "No access items found in database.")</p>
</div>
}
else
{
<div class="mb-4 rounded-xl border border-slate-100 bg-slate-50/60 px-4 py-3 text-xs text-slate-500 flex items-center justify-between">
<span>@AppLocalization.Text("role.permissionsHint", "Menus open pages. Permissions unlock actions.")</span>
<span class="font-medium text-slate-400">@selectedAccessCodes.Count @AppLocalization.Text("common.selected", "selected")</span>
</div>
@if (GetRootAccessItems(false).Any())
{
<div class="grid grid-cols-1 xl:grid-cols-2 gap-3">
@foreach (var parent in GetRootAccessItems(false))
{
var children = GetChildAccessItems(parent, false).ToList();
var parentChecked = selectedAccessCodes.Contains(parent.MenuCode);
<div class="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden">
<label class="flex items-start gap-3 px-4 py-3 bg-slate-50 cursor-pointer hover:bg-slate-50 transition-colors">
<input type="checkbox" checked="@parentChecked"
@onchange="(e) => ToggleAccessSelection(parent, (bool)(e.Value ?? false), isForm: false)"
class="mt-0.5 h-4 w-4 rounded border-slate-300 accent-violet-600" />
<div class="flex min-w-0 flex-1 items-start gap-2">
@if (!string.IsNullOrEmpty(parent.Icon))
{
<i data-lucide="@parent.Icon" class="mt-0.5 h-4 w-4 text-violet-500 shrink-0"></i>
}
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="text-sm font-semibold text-slate-800 truncate">@Content.GetMenuName(parent)</span>
<span class="rounded-full bg-violet-50 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-violet-600">@AppLocalization.Text("common.module", "အပိုင်း")</span>
</div>
<div class="text-[11px] text-slate-400 truncate">@parent.MenuCode</div>
</div>
</div>
</label>
<div class="border-t border-slate-100 px-3 py-3">
@if (parent.Permissions.Any())
{
<div class="mb-3">
<div class="mb-2 flex items-center justify-between px-1">
<span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">@AppLocalization.Text("common.actions", "Actions")</span>
<span class="text-[11px] text-slate-400">@AppLocalization.Text("role.crudPermissions", "CRUD permissions for this page")</span>
</div>
<div class="flex flex-wrap gap-2">
@foreach (var action in parent.Permissions)
{
var isActionChecked = selectedAccessCodes.Contains(action.PermissionCode);
<label class="inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] cursor-pointer select-none transition-all
@(isActionChecked
? "bg-violet-50 border-violet-200 text-violet-700"
: "bg-white border-slate-200 text-slate-500 hover:border-violet-200 hover:text-violet-500")">
<input type="checkbox" checked="@isActionChecked"
@onchange="(e) => TogglePermissionSelection(action.PermissionCode, (bool)(e.Value ?? false), isForm: false)"
class="h-3.5 w-3.5 rounded accent-violet-600" />
<span>@Content.GetPermissionName(action.ActionName, action.ActionNameMy)</span>
</label>
}
</div>
</div>
}
@if (children.Any())
{
<div class="space-y-2">
@foreach (var child in children)
{
var childChecked = selectedAccessCodes.Contains(child.MenuCode);
<div class="rounded-xl border border-slate-100 bg-slate-50/50 p-3">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked="@childChecked"
@onchange="(e) => ToggleAccessSelection(child, (bool)(e.Value ?? false), isForm: false)"
class="h-4 w-4 rounded border-slate-300 accent-violet-600" />
<span class="text-sm font-medium text-slate-700">@Content.GetMenuName(child)</span>
</label>
@if (child.Permissions.Any())
{
<div class="mt-2 flex flex-wrap gap-2 pl-6">
@foreach (var action in child.Permissions)
{
var isActionChecked = selectedAccessCodes.Contains(action.PermissionCode);
<label class="inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] cursor-pointer select-none transition-all
@(isActionChecked
? "bg-violet-50 border-violet-200 text-violet-700"
: "bg-white border-slate-200 text-slate-500 hover:border-violet-200 hover:text-violet-500")">
<input type="checkbox" checked="@isActionChecked"
@onchange="(e) => TogglePermissionSelection(action.PermissionCode, (bool)(e.Value ?? false), isForm: false)"
class="h-3 w-3 rounded accent-violet-600" />
<span>@Content.GetPermissionName(action.ActionName, action.ActionNameMy)</span>
</label>
}
</div>
}
</div>
}
</div>
}
else if (!parent.Permissions.Any())
{
<p class="text-xs text-slate-400">@AppLocalization.Text("role.nothingInsidePage", "Nothing else is available inside this page.")</p>
}
</div>
</div>
}
</div>
}
else
{
<p class="py-10 text-center text-sm text-slate-400">@AppLocalization.Text("role.noPagesMatchSearch", "No pages or permissions match your search.")</p>
}
}
</div>
<!-- Footer -->
@if (!string.IsNullOrEmpty(accessSaveError))
{
<div class="px-6 pb-2">
<div class="p-3 rounded-lg bg-red-50 border border-red-100">
<p class="text-sm text-red-600">@accessSaveError</p>
</div>
</div>
}
@if (!string.IsNullOrEmpty(accessSaveSuccess))
{
<div class="px-6 pb-2">
<div class="p-3 rounded-lg bg-emerald-50 border border-emerald-100">
<p class="text-sm text-emerald-700">@accessSaveSuccess</p>
</div>
</div>
}
<div class="flex items-center justify-between px-6 py-4 border-t border-slate-100 bg-slate-50/50">
<span class="text-xs text-slate-400">
<i data-lucide="info" class="h-3.5 w-3.5 inline mr-1 text-slate-400"></i>
@AppLocalization.Text("role.changeApplyAfterSave", "Changes apply immediately after saving.")
</span>
<div class="flex items-center gap-3">
<button @onclick="CloseAccessModal" 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="SaveAccess" disabled="@isSavingAccess"
class="inline-flex items-center gap-2 rounded-lg bg-violet-600 px-5 py-2 text-sm font-medium text-white hover:bg-violet-700 disabled:opacity-50 transition-colors shadow-sm">
<svg class="animate-spin h-4 w-4 @(isSavingAccess ? "" : "hidden")" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
<span class="@(isSavingAccess ? "" : "hidden")">@AppLocalization.Text("common.saving", "Saving...")</span>
<i data-lucide="save" class="h-4 w-4 @(isSavingAccess ? "hidden" : "")"></i>
<span class="@(isSavingAccess ? "hidden" : "")">@AppLocalization.Text("role.saveAccess", "Save Access")</span>
</button>
</div>
</div>
</div>
</div>
}
@* ===== Confirm Dialog ===== *@
<ConfirmDialog IsVisible="showConfirmDialog"
Title="@confirmTitle"
Message="@confirmMessage"
ConfirmText="@confirmButtonText"
Icon="@confirmIcon"
IconBgClass="@confirmIconBg"
IconTextClass="@confirmIconText"
ButtonClass="@confirmButtonClass"
HideCancel="@confirmHideCancel"
OnConfirm="HandleConfirmAction"
OnCancel="CloseConfirmDialog" />
@code {
private bool isLoading = true;
private bool showRoleModal = false;
private bool showAccessModal = false;
private bool isEditing = false;
private bool isSaving = false;
private bool canCreateRole = false;
private bool canEditRole = false;
private bool canDeleteRole = false;
private bool canManageAccess = false;
private string? roleError;
private bool isRoleNameError = false;
private string roleNameErrorMsg = "";
// Confirm dialog state
private bool showConfirmDialog = false;
private bool confirmHideCancel = false;
private string? successMessage;
private string confirmTitle = "";
private string confirmMessage = "";
private string confirmButtonText = "";
private string confirmIcon = "";
private string confirmIconBg = "";
private string confirmIconText = "";
private string confirmButtonClass = "";
private Func<Task>? confirmAction;
private List<RoleDto> roleList = new();
private List<UserDto> userList = new();
// Search state
private string searchInput = "";
// Pagination state
private int currentPage = 1;
private int pageSize = 10;
private int totalCount = 0;
private int totalPages = 0;
private int TotalPages => totalPages;
private int TotalCount => totalCount;
private IEnumerable<RoleDto> PagedRoles => roleList;
// Role form
private long editingId;
private string formName = "";
private string? formNameMy;
private string? formDescription;
private string? formDescriptionMy;
private bool burmeseGenerated;
private bool englishGenerated;
private bool burmeseNeedsReview;
private bool englishNeedsReview;
private void MoveRoleNameToEnglish() { formName = formNameMy ?? string.Empty; }
private void MoveRoleNameToBurmese() { formNameMy = formName; }
private void MarkGenerated(bool english)
{
if (english) { englishGenerated = true; englishNeedsReview = true; }
else { burmeseGenerated = true; burmeseNeedsReview = true; }
}
private Task GenerateRoleEnglishName(TranslationResult result) { formName = result.TranslatedText ?? string.Empty; MarkGenerated(true); return Task.CompletedTask; }
private Task GenerateRoleBurmeseName(TranslationResult result) { formNameMy = result.TranslatedText; MarkGenerated(false); return Task.CompletedTask; }
private Task GenerateRoleEnglishDescription(TranslationResult result) { formDescription = result.TranslatedText; MarkGenerated(true); return Task.CompletedTask; }
private Task GenerateRoleBurmeseDescription(TranslationResult result) { formDescriptionMy = result.TranslatedText; MarkGenerated(false); return Task.CompletedTask; }
// Inline access items in Create form
private List<AccessMenuDto> formAccessItems = new();
private HashSet<string> formSelectedAccessCodes = new();
private string formAccessSearch = "";
private bool isLoadingFormAccessItems = false;
private bool IsAllFormAccessSelected
=> formAccessItems.Count > 0 && formSelectedAccessCodes.Count == (formAccessItems.Count + formAccessItems.Sum(m => m.Permissions.Count));
// Access modal (for editing existing role)
private long selectedRoleId;
private string selectedRoleName = "";
private List<AccessMenuDto> accessItems = new();
private HashSet<string> selectedAccessCodes = new();
private string accessSearch = "";
private bool isLoadingAccessItems = false;
private bool isSavingAccess = false;
private string? accessSaveError;
private string? accessSaveSuccess;
private bool IsAllAccessSelected
=> accessItems.Count > 0 && selectedAccessCodes.Count == (accessItems.Count + accessItems.Sum(m => m.Permissions.Count));
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var user = authState.User;
canCreateRole = await MenuAuthorization.HasAccessCodeAsync(user, "Roles_Create");
canEditRole = await MenuAuthorization.HasAccessCodeAsync(user, "Roles_Update");
canDeleteRole = await MenuAuthorization.HasAccessCodeAsync(user, "Roles_Delete");
canManageAccess = canEditRole;
await LoadRoles();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await JS.InvokeVoidAsync("initIcons");
}
private async Task LoadRoles()
{
isLoading = true;
var client = ApiClient.CreateClient();
try
{
var rolesTask = client.GetFromJsonAsync<PagedResult<RoleDto>>(BuildRolesUrl(), Serialization.CaseInsensitive);
var usersTask = client.GetFromJsonAsync<List<UserDto>>("User", Serialization.CaseInsensitive);
await Task.WhenAll(rolesTask, usersTask);
roleList = rolesTask.Result?.Items ?? new();
totalCount = rolesTask.Result?.TotalCount ?? 0;
totalPages = rolesTask.Result?.TotalPages ?? 0;
currentPage = rolesTask.Result?.Page ?? currentPage;
pageSize = rolesTask.Result?.PageSize ?? pageSize;
userList = usersTask.Result ?? new();
}
catch (Exception ex)
{
Console.WriteLine($"Load roles error: {ex.Message}");
}
finally
{
isLoading = false;
}
}
// Pagination handlers
private async Task HandlePageChanged(int page)
{
currentPage = page;
await LoadRoles();
}
private async Task HandlePageSizeChanged(int size)
{
pageSize = size;
currentPage = 1;
await LoadRoles();
}
private async Task ApplySearch()
{
currentPage = 1;
await LoadRoles();
}
private async Task ResetSearch()
{
searchInput = "";
currentPage = 1;
await LoadRoles();
}
private string BuildRolesUrl()
{
var query = new List<string>
{
$"page={currentPage}",
$"limit={pageSize}"
};
if (!string.IsNullOrWhiteSpace(searchInput))
{
query.Add($"search={Uri.EscapeDataString(searchInput.Trim())}");
}
return $"Role?{string.Join("&", query)}";
}
// ===== Role CRUD =====
private async Task OpenCreateModal()
{
isEditing = false;
editingId = 0;
formName = "";
formNameMy = null;
burmeseGenerated = englishGenerated = burmeseNeedsReview = englishNeedsReview = false;
formDescription = null;
formDescriptionMy = null;
roleError = null;
isRoleNameError = false;
roleNameErrorMsg = "";
formSelectedAccessCodes = new(StringComparer.OrdinalIgnoreCase);
formAccessItems = new();
formAccessSearch = "";
isLoadingFormAccessItems = true;
showRoleModal = true;
StateHasChanged();
try
{
var client = ApiClient.CreateClient();
formAccessItems = await client.GetFromJsonAsync<List<AccessMenuDto>>("Menu/access-items", Serialization.CaseInsensitive) ?? new();
}
catch (Exception ex)
{
Console.WriteLine($"Load form menus error: {ex.Message}");
}
finally
{
isLoadingFormAccessItems = false;
StateHasChanged();
}
}
private void OpenEditModal(RoleDto role)
{
isEditing = true;
editingId = role.Id;
formName = role.Name;
formNameMy = role.NameMy;
formDescription = role.Description;
formDescriptionMy = role.DescriptionMy;
burmeseGenerated = englishGenerated = burmeseNeedsReview = englishNeedsReview = false;
roleError = null;
isRoleNameError = false;
roleNameErrorMsg = "";
showRoleModal = true;
}
private void CloseRoleModal()
{
showRoleModal = false;
roleError = null;
isRoleNameError = false;
roleNameErrorMsg = "";
formAccessItems = new();
formSelectedAccessCodes = new(StringComparer.OrdinalIgnoreCase);
formAccessSearch = "";
}
private void ToggleAccessSelection(AccessMenuDto menu, bool isChecked, bool isForm)
{
var targetSet = isForm ? formSelectedAccessCodes : selectedAccessCodes;
var allMenuSource = isForm ? formAccessItems : accessItems;
if (isChecked)
{
AddMenuSelection(menu, allMenuSource, targetSet);
}
else
{
RemoveMenuSelection(menu, allMenuSource, targetSet);
}
StateHasChanged();
}
private void TogglePermissionSelection(string actionCode, bool isChecked, bool isForm)
{
var targetSet = isForm ? formSelectedAccessCodes : selectedAccessCodes;
var menuSource = isForm ? formAccessItems : accessItems;
if (isChecked)
{
targetSet.Add(actionCode);
var permission = FindPermission(actionCode, menuSource);
if (permission != null)
{
EnsureMenuPathSelected(permission.ParentMenuCode, menuSource, targetSet);
EnsureMenuBranchVisible(permission.ParentMenuCode, menuSource, targetSet);
}
}
else
{
targetSet.Remove(actionCode);
}
StateHasChanged();
}
private void ToggleSelectAllFormAccess(ChangeEventArgs e)
{
var selectAll = (bool)(e.Value ?? false);
if (selectAll)
{
formSelectedAccessCodes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var menu in formAccessItems)
{
formSelectedAccessCodes.Add(menu.MenuCode);
foreach (var action in menu.Permissions)
{
formSelectedAccessCodes.Add(action.PermissionCode);
}
}
}
else
{
formSelectedAccessCodes.Clear();
}
}
private static bool IsRootMenu(AccessMenuDto menu)
=> string.IsNullOrWhiteSpace(menu.ParentCode) || menu.ParentCode == "0";
private static bool MatchesSearch(string? value, string search)
=> !string.IsNullOrWhiteSpace(value)
&& value.Contains(search, StringComparison.OrdinalIgnoreCase);
private IEnumerable<AccessMenuDto> GetRootAccessItems(bool isForm)
{
var source = isForm ? formAccessItems : accessItems;
var search = (isForm ? formAccessSearch : accessSearch).Trim();
return source
.Where(IsRootMenu)
.Where(menu => string.IsNullOrWhiteSpace(search) || AccessBranchMatchesSearch(menu, source, search))
.OrderBy(m => m.OrderNo);
}
private IEnumerable<AccessMenuDto> GetChildAccessItems(AccessMenuDto parent, bool isForm)
{
var source = isForm ? formAccessItems : accessItems;
var search = (isForm ? formAccessSearch : accessSearch).Trim();
var children = source
.Where(c => c.ParentCode == parent.MenuCode)
.OrderBy(c => c.OrderNo);
if (string.IsNullOrWhiteSpace(search))
{
return children;
}
return children.Where(child => AccessBranchMatchesSearch(child, source, search));
}
private static bool AccessBranchMatchesSearch(AccessMenuDto menu, IReadOnlyList<AccessMenuDto> source, string search)
{
if (MatchesSearch(menu.MenuName, search) || MatchesSearch(menu.MenuNameMy, search) || MatchesSearch(menu.MenuCode, search) || MatchesSearch(menu.MenuUrl, search))
{
return true;
}
if (menu.Permissions.Any(permission =>
MatchesSearch(permission.ActionName, search) ||
MatchesSearch(permission.ActionNameMy, search) ||
MatchesSearch(permission.PermissionCode, search) ||
MatchesSearch(permission.ApiName, search)))
{
return true;
}
return source
.Where(child => child.ParentCode == menu.MenuCode)
.Any(child => AccessBranchMatchesSearch(child, source, search));
}
private static AccessPermissionDto? FindPermission(string actionCode, IReadOnlyList<AccessMenuDto> source)
{
foreach (var menu in source)
{
var permission = menu.Permissions.FirstOrDefault(p => string.Equals(p.PermissionCode, actionCode, StringComparison.OrdinalIgnoreCase));
if (permission != null)
{
return permission;
}
}
return null;
}
private static AccessMenuDto? FindMenu(string menuCode, IReadOnlyList<AccessMenuDto> source)
{
return source.FirstOrDefault(m => string.Equals(m.MenuCode, menuCode, StringComparison.OrdinalIgnoreCase));
}
private static void EnsureMenuPathSelected(string menuCode, IReadOnlyList<AccessMenuDto> source, ISet<string> targetSet)
{
var current = FindMenu(menuCode, source);
while (current != null)
{
targetSet.Add(current.MenuCode);
if (string.IsNullOrWhiteSpace(current.ParentCode) || current.ParentCode == "0")
{
break;
}
current = FindMenu(current.ParentCode, source);
}
}
private static void EnsureMenuBranchVisible(string menuCode, IReadOnlyList<AccessMenuDto> source, ISet<string> targetSet)
{
var menu = FindMenu(menuCode, source);
if (menu == null)
{
return;
}
targetSet.Add(menu.MenuCode);
foreach (var child in GetAutoVisibleChildren(menu, source))
{
EnsureMenuBranchVisible(child.MenuCode, source, targetSet);
}
}
private static void AddMenuSelection(AccessMenuDto menu, IReadOnlyList<AccessMenuDto> source, ISet<string> targetSet)
{
targetSet.Add(menu.MenuCode);
foreach (var permission in menu.Permissions)
{
targetSet.Add(permission.PermissionCode);
}
EnsureMenuPathSelected(menu.ParentCode, source, targetSet);
foreach (var child in GetAutoVisibleChildren(menu, source))
{
EnsureMenuBranchVisible(child.MenuCode, source, targetSet);
}
}
private static void RemoveMenuSelection(AccessMenuDto menu, IReadOnlyList<AccessMenuDto> source, ISet<string> targetSet)
{
targetSet.Remove(menu.MenuCode);
foreach (var permission in menu.Permissions)
{
targetSet.Remove(permission.PermissionCode);
}
foreach (var child in GetAutoVisibleChildren(menu, source))
{
RemoveMenuSelection(child, source, targetSet);
}
CleanupAncestorSelection(menu.ParentCode, source, targetSet);
}
private static void CleanupAncestorSelection(string parentCode, IReadOnlyList<AccessMenuDto> source, ISet<string> targetSet)
{
if (string.IsNullOrWhiteSpace(parentCode) || parentCode == "0")
{
return;
}
var parent = FindMenu(parentCode, source);
if (parent == null)
{
return;
}
if (HasAnySelectedInBranch(parent, source, targetSet))
{
return;
}
targetSet.Remove(parent.MenuCode);
CleanupAncestorSelection(parent.ParentCode, source, targetSet);
}
private static bool HasAnySelectedInBranch(AccessMenuDto menu, IReadOnlyList<AccessMenuDto> source, ISet<string> targetSet)
{
if (targetSet.Contains(menu.MenuCode) || menu.Permissions.Any(p => targetSet.Contains(p.PermissionCode)))
{
return true;
}
return GetAutoVisibleChildren(menu, source)
.Any(child => HasAnySelectedInBranch(child, source, targetSet));
}
private static void NormalizeAccessSelection(IReadOnlyList<AccessMenuDto> source, ISet<string> targetSet)
{
var selectedPermissions = source
.SelectMany(menu => menu.Permissions)
.Where(permission => targetSet.Contains(permission.PermissionCode))
.ToList();
foreach (var permission in selectedPermissions)
{
EnsureMenuPathSelected(permission.ParentMenuCode, source, targetSet);
EnsureMenuBranchVisible(permission.ParentMenuCode, source, targetSet);
}
}
private static IEnumerable<AccessMenuDto> GetAutoVisibleChildren(AccessMenuDto menu, IReadOnlyList<AccessMenuDto> source)
{
return source
.Where(m => m.ParentCode == menu.MenuCode && IsAutoVisiblePageMenu(m))
.OrderBy(m => m.OrderNo)
.ThenBy(m => m.MenuName, StringComparer.OrdinalIgnoreCase);
}
private static bool IsAutoVisiblePageMenu(AccessMenuDto menu)
{
var text = $"{menu.MenuCode} {menu.MenuUrl}".Trim();
return text.Contains("list", StringComparison.OrdinalIgnoreCase)
|| text.Contains("view", StringComparison.OrdinalIgnoreCase)
|| text.Contains("dashboard", StringComparison.OrdinalIgnoreCase)
|| text.Contains("home", StringComparison.OrdinalIgnoreCase);
}
private void RequestSaveConfirmation()
{
isRoleNameError = false;
roleNameErrorMsg = "";
roleError = null;
bool hasError = false;
if (string.IsNullOrWhiteSpace(formName) && string.IsNullOrWhiteSpace(formNameMy))
{
isRoleNameError = true;
roleNameErrorMsg = ResultMessages.RoleNameRequired;
hasError = true;
}
if (hasError) return;
if (isEditing)
{
confirmTitle = AppLocalization.Text("role.updateTitle", "Update Role?");
confirmMessage = AppLocalization.Text("role.updateConfirm", "Are you sure you want to update this role?");
confirmButtonText = AppLocalization.Text("action.update", "Update");
confirmIcon = "pencil";
confirmIconBg = "bg-amber-50";
confirmIconText = "text-amber-500";
confirmButtonClass = "bg-violet-600 hover:bg-violet-700";
}
else
{
confirmTitle = AppLocalization.Text("role.createTitle", "Create Role?");
confirmMessage = AppLocalization.Text("role.createConfirm", "Are you sure you want to create this role?");
confirmButtonText = AppLocalization.Text("action.create", "Create");
confirmIcon = "plus-circle";
confirmIconBg = "bg-emerald-50";
confirmIconText = "text-emerald-500";
confirmButtonClass = "bg-violet-600 hover:bg-violet-700";
}
confirmAction = SaveRole;
showConfirmDialog = true;
}
private void RequestDeleteConfirmation(long id)
{
confirmTitle = AppLocalization.Text("role.deleteTitle", "Delete Role?");
confirmMessage = AppLocalization.Text("role.deleteConfirm", "Are you sure you want to delete this role? This action cannot be undone.");
confirmButtonText = AppLocalization.Text("action.delete", "Delete");
confirmIcon = "trash-2";
confirmIconBg = "bg-rose-50";
confirmIconText = "text-rose-500";
confirmButtonClass = "bg-violet-600 hover:bg-violet-700";
confirmAction = () => DeleteRole(id);
confirmHideCancel = false;
showConfirmDialog = true;
}
private async Task HandleConfirmAction()
{
showConfirmDialog = false;
if (confirmAction != null)
await confirmAction();
}
private void CloseConfirmDialog()
{
showConfirmDialog = false;
confirmHideCancel = false;
}
private async Task SaveRole()
{
if (string.IsNullOrWhiteSpace(formName) && string.IsNullOrWhiteSpace(formNameMy))
{
return;
}
isSaving = true;
roleError = null;
var client = ApiClient.CreateClient();
try
{
if (isEditing)
{
var dto = new UpdateRoleDto { Name = formName, NameMy = formNameMy, Description = formDescription, DescriptionMy = formDescriptionMy };
var response = await client.PutAsJsonAsync($"Role/{editingId}", dto);
var res = await response.Content.ReadFromJsonAsync<Result>(Serialization.CaseInsensitive);
if (res == null || !res.IsSuccess) { roleError = res?.ErrorMessage ?? ResultMessages.FailedToUpdateRole; return; }
}
else
{
NormalizeAccessSelection(formAccessItems, formSelectedAccessCodes);
var dto = new CreateRoleDto
{
Name = formName,
NameMy = formNameMy,
Description = formDescription,
DescriptionMy = formDescriptionMy,
AccessCodes = formSelectedAccessCodes.ToList()
};
var response = await client.PostAsJsonAsync("Role", dto);
var res = await response.Content.ReadFromJsonAsync<Result<RoleDto>>(Serialization.CaseInsensitive);
if (res == null || !res.IsSuccess) { roleError = res?.ErrorMessage ?? ResultMessages.FailedToCreateRole; return; }
}
CloseRoleModal();
await LoadRoles();
successMessage = isEditing ? AppLocalization.Text("role.updated", "Role updated successfully!") : AppLocalization.Text("role.created", "Role created successfully!");
}
catch (Exception ex) { roleError = ex.Message; }
finally { isSaving = false; }
}
private async Task DeleteRole(long id)
{
var usersCount = userList.Count(u => u.RoleId == id);
if (usersCount > 0)
{
confirmTitle = AppLocalization.Text("role.cannotDeleteTitle", "Cannot Delete Role");
confirmMessage = AppLocalization.Text("role.cannotDeleteUsers", "You can't delete this role because there are {count} users in this role.").Replace("{count}", usersCount.ToString());
confirmButtonText = AppLocalization.Text("common.ok", "OK");
confirmIcon = "alert-circle";
confirmIconBg = "bg-rose-50";
confirmIconText = "text-rose-500";
confirmButtonClass = "bg-rose-600 hover:bg-rose-700";
confirmAction = null;
confirmHideCancel = true;
showConfirmDialog = true;
StateHasChanged();
return;
}
var client = ApiClient.CreateClient();
try
{
var response = await client.DeleteAsync($"Role/{id}");
var res = await response.Content.ReadFromJsonAsync<Result>(Serialization.CaseInsensitive);
if (res == null || !res.IsSuccess)
{
confirmTitle = AppLocalization.Text("role.cannotDeleteTitle", "Cannot Delete Role");
confirmMessage = res?.ErrorMessage ?? AppLocalization.Text("role.failedDelete", "Failed to delete role.");
confirmButtonText = AppLocalization.Text("common.ok", "OK");
confirmIcon = "alert-circle";
confirmIconBg = "bg-rose-50";
confirmIconText = "text-rose-500";
confirmButtonClass = "bg-rose-600 hover:bg-rose-700";
confirmAction = null;
confirmHideCancel = true;
showConfirmDialog = true;
StateHasChanged();
return;
}
await LoadRoles();
if (currentPage > TotalPages && TotalPages > 0)
currentPage = TotalPages;
successMessage = AppLocalization.Text("role.deleted", "Role deleted successfully!");
}
catch (Exception ex)
{
Console.WriteLine($"Delete error: {ex.Message}");
confirmTitle = AppLocalization.Text("common.error", "Error");
confirmMessage = $"Delete error: {ex.Message}";
confirmButtonText = AppLocalization.Text("common.ok", "OK");
confirmIcon = "alert-circle";
confirmIconBg = "bg-rose-50";
confirmIconText = "text-rose-500";
confirmButtonClass = "bg-rose-600 hover:bg-rose-700";
confirmAction = null;
confirmHideCancel = true;
showConfirmDialog = true;
StateHasChanged();
}
}
// ===== Access Management =====
private async Task OpenAccessModal(RoleDto role)
{
selectedRoleId = role.Id;
selectedRoleName = Content.GetRoleName(role);
accessSaveError = null;
accessSaveSuccess = null;
accessSearch = "";
isLoadingAccessItems = true;
showAccessModal = true;
StateHasChanged();
var client = ApiClient.CreateClient();
try
{
// Load all available access items with their nested permissions
accessItems = await client.GetFromJsonAsync<List<AccessMenuDto>>("Menu/access-items", Serialization.CaseInsensitive) ?? new();
// Load currently assigned access items for this role
var assignedResult = await client.GetFromJsonAsync<Result<List<string>>>($"Role/{role.Id}/access", Serialization.CaseInsensitive);
selectedAccessCodes = new HashSet<string>(assignedResult?.Value ?? new(), StringComparer.OrdinalIgnoreCase);
NormalizeAccessSelection(accessItems, selectedAccessCodes);
}
catch (Exception ex)
{
Console.WriteLine($"Load access items error: {ex.Message}");
accessSaveError = AppLocalization.Text("role.failedLoadAccess", "Failed to load access items. Please try again.");
}
finally
{
isLoadingAccessItems = false;
StateHasChanged();
}
}
private void CloseAccessModal()
{
showAccessModal = false;
accessItems = new();
selectedAccessCodes = new(StringComparer.OrdinalIgnoreCase);
accessSearch = "";
accessSaveError = null;
accessSaveSuccess = null;
}
private void ToggleSelectAllAccess(ChangeEventArgs e)
{
var selectAll = (bool)(e.Value ?? false);
if (selectAll)
{
selectedAccessCodes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var menu in accessItems)
{
selectedAccessCodes.Add(menu.MenuCode);
foreach (var action in menu.Permissions)
{
selectedAccessCodes.Add(action.PermissionCode);
}
}
}
else
{
selectedAccessCodes.Clear();
}
}
private async Task SaveAccess()
{
isSavingAccess = true;
accessSaveError = null;
accessSaveSuccess = null;
var client = ApiClient.CreateClient();
try
{
NormalizeAccessSelection(accessItems, selectedAccessCodes);
var dto = new AssignAccessDto { AccessCodes = selectedAccessCodes.ToList() };
var response = await client.PostAsJsonAsync($"Role/{selectedRoleId}/access", dto);
if (response.IsSuccessStatusCode)
{
accessSaveSuccess = AppLocalization.Text("role.accessSaved", "Access saved successfully.");
}
else
{
var body = await response.Content.ReadAsStringAsync();
if (string.IsNullOrWhiteSpace(body))
{
accessSaveError = $"{AppLocalization.Text("role.failedSaveAccess", "Failed to save access.")} {(int)response.StatusCode}.";
}
else
{
try
{
using var doc = System.Text.Json.JsonDocument.Parse(body);
accessSaveError = doc.RootElement.TryGetProperty("message", out var message)
? message.GetString()
: body;
}
catch
{
accessSaveError = body;
}
}
}
}
catch (Exception ex)
{
accessSaveError = ex.Message;
}
finally
{
isSavingAccess = false;
StateHasChanged();
}
}
}