Merge branch 'stage-new' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 5m49s

This commit is contained in:
masoodafar-web
2025-12-25 02:04:20 +03:30
6 changed files with 843 additions and 110 deletions
@@ -1,5 +1,6 @@
@page "/network/tree"
@attribute [Authorize]
@implements IAsyncDisposable
@inject IJSRuntime JS
@using Foursat.BackOffice.BFF.NetworkMembership.Protos
@@ -8,6 +9,7 @@
<MudContainer MaxWidth="MaxWidth.ExtraExtraLarge" Class="mt-4">
<MudText Typo="Typo.h4" Class="mb-4">درخت شبکه</MudText>
@* Search & Filter Panel *@
<MudPaper Class="pa-4 mb-4">
<MudStack Row="true" Spacing="3" AlignItems="AlignItems.Center" Class="mb-3">
<div style="max-width: 300px; min-width: 250px;">
@@ -24,50 +26,97 @@
</MudStack>
<MudStack Row="true" Spacing="3" AlignItems="AlignItems.Center">
<div style="max-width: 200px; min-width: 150px;">
<MudSelect T="bool?" Label="وضعیت باشگاه" @bind-Value="_clubActiveFilter" Variant="Variant.Outlined" Clearable="true">
<MudSelectItem T="bool?" Value="@(null)">همه</MudSelectItem>
<MudSelectItem T="bool?" Value="@(true)">فعال</MudSelectItem>
<MudSelectItem T="bool?" Value="@(false)">غیرفعال</MudSelectItem>
</MudSelect>
</div>
<MudSelect T="int" @bind-Value="_selectedDepth" Label="عمق نمایش" Variant="Variant.Outlined"
Dense="true" Margin="Margin.Dense" Style="width: 120px;">
<MudSelectItem Value="3">3 سطح</MudSelectItem>
<MudSelectItem Value="5">5 سطح</MudSelectItem>
<MudSelectItem Value="10">10 سطح</MudSelectItem>
<MudSelectItem Value="15">15 سطح</MudSelectItem>
<MudSelectItem Value="20">همه</MudSelectItem>
</MudSelect>
<div style="max-width: 200px; min-width: 150px;">
<WeekNumberPicker Label="هفته فعالسازی"
@bind-SelectedWeekDefinitionId="_activationWeekFilter" />
</div>
<MudSpacer />
<MudStack Row="true" Spacing="2">
<MudChip T="string" Color="Color.Info" Size="Size.Small">
کل اعضا: @_totalMembers
</MudChip>
<MudChip T="string" Color="Color.Success" Size="Size.Small">
زیرمجموعه چپ: @_leftCount
</MudChip>
<MudChip T="string" Color="Color.Warning" Size="Size.Small">
زیرمجموعه راست: @_rightCount
</MudChip>
</MudStack>
<MudSelect T="bool?" Label="وضعیت باشگاه" @bind-Value="_clubActiveFilter" Variant="Variant.Outlined"
Clearable="true" Dense="true" Style="width: 140px;">
<MudSelectItem T="bool?" Value="@(null)">همه</MudSelectItem>
<MudSelectItem T="bool?" Value="@(true)">فعال</MudSelectItem>
<MudSelectItem T="bool?" Value="@(false)">غیرفعال</MudSelectItem>
</MudSelect>
<WeekNumberPicker Label="هفته فعالسازی"
@bind-SelectedWeekDefinitionId="_activationWeekFilter" />
</MudStack>
</MudPaper>
@* Chart Toolbar *@
@if (_treeData != null && _treeData.Nodes.Any())
{
<MudPaper Class="pa-2 mb-2">
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudButtonGroup Variant="Variant.Outlined" Size="Size.Small">
<MudIconButton Icon="@Icons.Material.Filled.UnfoldMore" OnClick="ExpandAll" Title="باز کردن همه" />
<MudIconButton Icon="@Icons.Material.Filled.UnfoldLess" OnClick="CollapseAll" Title="بستن همه" />
<MudIconButton Icon="@Icons.Material.Filled.CenterFocusStrong" OnClick="FitChart" Title="نمایش کامل" />
<MudIconButton Icon="@Icons.Material.Filled.Image" OnClick="ExportPng" Title="خروجی تصویر" />
</MudButtonGroup>
@if (_currentViewUserId.HasValue && _currentViewUserId != _searchUserId)
{
<MudButtonGroup Variant="Variant.Outlined" Size="Size.Small" Color="Color.Secondary">
<MudButton StartIcon="@Icons.Material.Filled.ArrowForward" OnClick="GoBack" Size="Size.Small">
بازگشت
</MudButton>
<MudButton StartIcon="@Icons.Material.Filled.Home" OnClick="GoToRoot" Size="Size.Small">
ریشه
</MudButton>
</MudButtonGroup>
}
<MudStack Row="true" Spacing="2">
<MudChip T="string" Color="Color.Info" Size="Size.Small" Icon="@Icons.Material.Filled.People">
کل: @_totalMembers
</MudChip>
<MudChip T="string" Color="Color.Success" Size="Size.Small">
چپ: @_leftCount
</MudChip>
<MudChip T="string" Color="Color.Warning" Size="Size.Small">
راست: @_rightCount
</MudChip>
</MudStack>
</MudStack>
</MudPaper>
}
@* Chart Container *@
@if (_isLoading)
{
<MudPaper Class="pa-8 d-flex justify-center">
<MudPaper Class="pa-8 d-flex flex-column align-center justify-center" Style="min-height: 500px;">
<MudProgressCircular Color="Color.Primary" Size="Size.Large" Indeterminate="true" />
<MudText Typo="Typo.body1" Class="mt-3">در حال بارگذاری درخت شبکه...</MudText>
</MudPaper>
}
else if (_hasError)
{
<MudPaper Class="pa-8 d-flex flex-column align-center justify-center" Style="min-height: 300px;">
<MudIcon Icon="@Icons.Material.Filled.Error" Color="Color.Error" Size="Size.Large" />
<MudText Typo="Typo.body1" Class="mt-2" Color="Color.Error">خطا در بارگذاری درخت</MudText>
<MudButton Variant="Variant.Text" Color="Color.Primary" OnClick="LoadTree" Class="mt-2">
تلاش مجدد
</MudButton>
</MudPaper>
}
else if (_treeData != null && _treeData.Nodes.Any())
{
<MudPaper Class="pa-4" Style="min-height: 800px;">
<div id="network-tree-container" style="width: 100%; height: 800px; overflow: auto;"></div>
<MudPaper Class="pa-0" Style="min-height: 600px; overflow: hidden;">
<div id="admin-org-chart-container" class="admin-org-chart-container"></div>
</MudPaper>
@* Data Grid *@
<MudPaper Class="pa-4 mt-4">
<MudText Typo="Typo.h6" Class="mb-3">جدول اعضای شبکه</MudText>
<MudDataGrid T="NetworkTreeNodeModel" Items="@_treeData.Nodes" Hover="true" Filterable="true" Dense="true">
<MudDataGrid T="NetworkTreeNodeModel" Items="@_treeData.Nodes" Hover="true" Filterable="true" Dense="true"
SortMode="SortMode.Multiple" RowsPerPage="10">
<Columns>
<PropertyColumn Property="x => x.UserId" Title="شناسه کاربر" />
<PropertyColumn Property="x => x.UserId" Title="شناسه" />
<PropertyColumn Property="x => x.UserName" Title="نام کاربر" />
<PropertyColumn Property="x => x.NetworkLeg" Title="موقعیت">
@@ -82,56 +131,17 @@
<PropertyColumn Property="x => x.NetworkLevel" Title="سطح" />
<PropertyColumn Property="x => x.IsClubActive" Title="وضعیت">
<PropertyColumn Property="x => x.IsClubActive" Title="باشگاه">
<CellTemplate>
<MudChip T="string"
Color="@(context.Item.IsClubActive ? Color.Success : Color.Error)"
Color="@(context.Item.IsClubActive ? Color.Success : Color.Default)"
Size="Size.Small">
@(context.Item.IsClubActive ? "فعال" : "غیرفعال")
</MudChip>
</CellTemplate>
</PropertyColumn>
<PropertyColumn Property="x => x.JoinedAt" Title="تاریخ عضویت">
<CellTemplate>
@if (context.Item.JoinedAt != null)
{
@context.Item.JoinedAt.ToDateTime().ToLocalTime().ToString("yyyy/MM/dd")
}
else
{
<MudText Typo="Typo.body2" Color="Color.Default">-</MudText>
}
</CellTemplate>
</PropertyColumn>
<PropertyColumn Property="x => x.IsClubActive" Title="باشگاه">
<CellTemplate>
@if (context.Item.IsClubActive)
{
<MudChip T="string" Color="Color.Success" Size="Size.Small">فعال</MudChip>
}
else
{
<MudChip T="string" Color="Color.Default" Size="Size.Small">غیرفعال</MudChip>
}
</CellTemplate>
</PropertyColumn>
<PropertyColumn Property="x => x.ActivationWeekDefinitionId" Title="هفته فعالسازی">
<CellTemplate>
@if (context.Item.ActivationWeekDefinitionId != null)
{
<MudText Typo="Typo.body2">@context.Item.ActivationWeekDefinitionId</MudText>
}
else
{
<MudText Typo="Typo.body2" Color="Color.Default">-</MudText>
}
</CellTemplate>
</PropertyColumn>
<PropertyColumn Property="x => x.ClubActivatedAt" Title="تاریخ فعالسازی باشگاه">
<PropertyColumn Property="x => x.ClubActivatedAt" Title="تاریخ فعالسازی">
<CellTemplate>
@if (context.Item.ClubActivatedAt != null)
{
@@ -139,30 +149,52 @@
}
else
{
<MudText Typo="Typo.body2" Color="Color.Default">-</MudText>
<span style="color: #999;">-</span>
}
</CellTemplate>
</PropertyColumn>
<PropertyColumn Property="x => x.ActivationWeekDefinitionId" Title="هفته">
<CellTemplate>
@if (context.Item.ActivationWeekDefinitionId != null)
{
<MudChip T="string" Size="Size.Small" Color="Color.Info">
W@context.Item.ActivationWeekDefinitionId
</MudChip>
}
else
{
<span style="color: #999;">-</span>
}
</CellTemplate>
</PropertyColumn>
<TemplateColumn Title="عملیات" Sortable="false">
<CellTemplate>
<MudButton Size="Size.Small"
Variant="Variant.Text"
Color="Color.Primary"
OnClick="@(() => ViewUserDetails(context.Item.UserId))">
جزئیات
</MudButton>
<MudButtonGroup Size="Size.Small" Variant="Variant.Text">
<MudIconButton Icon="@Icons.Material.Filled.AccountTree"
Color="Color.Primary"
Title="نمایش درخت این کاربر"
OnClick="@(() => ViewUserTree(context.Item.UserId))" />
<MudIconButton Icon="@Icons.Material.Filled.Info"
Color="Color.Info"
Title="جزئیات کاربر"
OnClick="@(() => ViewUserDetails(context.Item.UserId))" />
</MudButtonGroup>
</CellTemplate>
</TemplateColumn>
</Columns>
<PagerContent>
<MudDataGridPager T="NetworkTreeNodeModel" />
</PagerContent>
</MudDataGrid>
</MudPaper>
}
else
{
<MudPaper Class="pa-8">
<MudAlert Severity="Severity.Info">
برای نمایش درخت شبکه، شناسه کاربر را وارد کنید و دکمه "نمایش درخت" را بزنید.
<MudAlert Severity="Severity.Info" Icon="@Icons.Material.Filled.Info">
برای نمایش درخت شبکه، کاربر مورد نظر را جستجو کرده و دکمه "نمایش درخت" را بزنید.
</MudAlert>
</MudPaper>
}
@@ -173,14 +205,19 @@
[Inject] public NavigationManager NavigationManager { get; set; }
private long? _searchUserId;
private long? _currentViewUserId;
private GetNetworkTreeResponse _treeData;
private bool _isLoading;
private bool _hasError;
private int _totalMembers;
private int _leftCount;
private int _rightCount;
private DotNetObjectReference<NetworkTreeViewer> _dotNetRef;
private int _selectedDepth = 10;
private bool? _clubActiveFilter;
private long? _activationWeekFilter;
private DotNetObjectReference<NetworkTreeViewer> _dotNetRef;
private Stack<long> _navigationHistory = new();
private bool _chartNeedsInit = false;
protected override void OnInitialized()
{
@@ -189,9 +226,10 @@
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
if (_chartNeedsInit && _treeData != null && _treeData.Nodes.Any())
{
await JS.InvokeVoidAsync("NetworkTreeViewer.setDotNetReference", _dotNetRef);
_chartNeedsInit = false;
await RenderChart();
}
}
@@ -204,81 +242,186 @@
}
_isLoading = true;
StateHasChanged(); // Force render to show loading state
_hasError = false;
StateHasChanged();
try
{
var request = new GetNetworkTreeRequest
{
UserId = _searchUserId.Value,
MaxDepth = 20,
IsClubActive = _clubActiveFilter.HasValue ? _clubActiveFilter.Value : null,
ActivationWeekDefinitionId = _activationWeekFilter!=null ? _activationWeekFilter : null
MaxDepth = _selectedDepth,
IsClubActive = _clubActiveFilter,
ActivationWeekDefinitionId = _activationWeekFilter
};
_treeData = await NetworkContract.GetNetworkTreeAsync(request);
_currentViewUserId = _searchUserId;
_navigationHistory.Clear();
CalculateStats();
_isLoading = false;
StateHasChanged(); // Render the container first
await Task.Delay(100); // Wait for DOM to be ready
await RenderTree();
_chartNeedsInit = true;
StateHasChanged();
Snackbar.Add($"درخت بارگذاری شد - {_treeData.Nodes.Count} عضو", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"خطا در بارگذاری درخت: {ex.Message}", Severity.Error);
_hasError = true;
_isLoading = false;
Snackbar.Add($"خطا در بارگذاری درخت: {ex.Message}", Severity.Error);
StateHasChanged();
}
}
private async Task RenderTree()
private async Task LoadSubTree(long userId)
{
_isLoading = true;
StateHasChanged();
try
{
var request = new GetNetworkTreeRequest
{
UserId = userId,
MaxDepth = _selectedDepth,
IsClubActive = _clubActiveFilter,
ActivationWeekDefinitionId = _activationWeekFilter
};
_treeData = await NetworkContract.GetNetworkTreeAsync(request);
if (_currentViewUserId.HasValue && _currentViewUserId != userId)
{
_navigationHistory.Push(_currentViewUserId.Value);
}
_currentViewUserId = userId;
CalculateStats();
_isLoading = false;
_chartNeedsInit = true;
StateHasChanged();
}
catch (Exception ex)
{
_isLoading = false;
Snackbar.Add($"خطا: {ex.Message}", Severity.Error);
StateHasChanged();
}
}
private async Task RenderChart()
{
if (_treeData == null || !_treeData.Nodes.Any()) return;
var jsNodes = _treeData.Nodes.Select(n => new
try
{
userId = n.UserId,
userName = n.UserName,
parentId = n.ParentId,
networkLevel = n.NetworkLevel,
networkLeg = n.NetworkLeg,
isActive = n.IsClubActive,
isClubActive = n.IsClubActive,
isActivatedInTargetWeek = n.IsActivatedInTargetWeek,
activationWeekNumber = _activationWeekFilter ?? 0, // فیلتر UI
clubActivatedAt = n.ClubActivatedAt?.ToDateTime().ToLocalTime().ToString("yyyy/MM/dd") ?? "",
userCreated = n.UserCreated?.ToDateTime().ToLocalTime().ToString("yyyy/MM/dd") ?? ""
}).ToArray();
await Task.Delay(50); // Wait for DOM
var jsNodes = _treeData.Nodes.Select(n => new
{
id = n.UserId.ToString(),
parentId = n.ParentId > 0 ? n.ParentId.ToString() : "",
userId = n.UserId,
userName = n.UserName ?? $"کاربر {n.UserId}",
networkLevel = n.NetworkLevel,
networkLeg = n.NetworkLeg,
isClubActive = n.IsClubActive,
isActivatedInTargetWeek = n.IsActivatedInTargetWeek,
activationWeekDefinitionId = n.ActivationWeekDefinitionId,
clubActivatedAt = n.ClubActivatedAt?.ToDateTime().ToLocalTime().ToString("yyyy/MM/dd") ?? ""
}).ToArray();
await JS.InvokeVoidAsync("NetworkTreeViewer.initialize", "network-tree-container", jsNodes);
var options = new { filterWeek = _activationWeekFilter };
await JS.InvokeVoidAsync("AdminOrgChart.init", "admin-org-chart-container", jsNodes, _dotNetRef, options);
}
catch (Exception ex)
{
Console.WriteLine($"Error rendering chart: {ex.Message}");
}
}
[JSInvokable]
public async Task OnNodeClicked(long userId)
{
_searchUserId = userId;
await LoadTree();
if (userId == _currentViewUserId) return;
await LoadSubTree(userId);
}
private void CalculateStats()
{
if (_treeData == null || !_treeData.Nodes.Any()) return;
if (_treeData == null || !_treeData.Nodes.Any())
{
_totalMembers = _leftCount = _rightCount = 0;
return;
}
_totalMembers = _treeData.Nodes.Count;
_leftCount = _treeData.Nodes.Count(n => n.NetworkLeg == 0);
_rightCount = _treeData.Nodes.Count(n => n.NetworkLeg == 1);
}
private async Task ExpandAll()
{
try { await JS.InvokeVoidAsync("AdminOrgChart.expandAll"); } catch { }
}
private async Task CollapseAll()
{
try { await JS.InvokeVoidAsync("AdminOrgChart.collapseAll"); } catch { }
}
private async Task FitChart()
{
try { await JS.InvokeVoidAsync("AdminOrgChart.fit"); } catch { }
}
private async Task ExportPng()
{
try { await JS.InvokeVoidAsync("AdminOrgChart.exportPng"); } catch { }
}
private async Task GoBack()
{
if (_navigationHistory.Count > 0)
{
var previousUserId = _navigationHistory.Pop();
_currentViewUserId = previousUserId;
await LoadSubTree(previousUserId);
}
}
private async Task GoToRoot()
{
if (_searchUserId.HasValue)
{
_navigationHistory.Clear();
await LoadSubTree(_searchUserId.Value);
}
}
private async Task ViewUserTree(long userId)
{
await LoadSubTree(userId);
}
private void ViewUserDetails(long userId)
{
NavigationManager.NavigateTo($"/network/user-info/{userId}");
}
public void Dispose()
public async ValueTask DisposeAsync()
{
try
{
await JS.InvokeVoidAsync("AdminOrgChart.dispose");
}
catch { }
_dotNetRef?.Dispose();
}
}
@@ -0,0 +1,272 @@
/* Admin Org Chart Styles */
.admin-org-chart-container {
width: 100%;
height: 600px;
background: #fafafa;
border-radius: 4px;
}
/* Node Card */
.admin-node-card {
background: white;
border-radius: 8px;
padding: 8px 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border: 2px solid #e0e0e0;
min-width: 140px;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.admin-node-card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
/* Position colors */
.admin-node-card.leg-left {
border-left: 4px solid #4caf50;
}
.admin-node-card.leg-right {
border-left: 4px solid #ff9800;
}
.admin-node-card.leg-root {
border-left: 4px solid #1976d2;
}
/* Club status */
.admin-node-card.club-active {
border-top: 2px solid #4caf50;
}
.admin-node-card.club-inactive {
border-top: 2px solid #e0e0e0;
opacity: 0.85;
}
/* Node header */
.admin-node-card .node-header {
display: flex;
align-items: center;
gap: 8px;
}
.admin-node-card .node-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: linear-gradient(135deg, #1976d2 0%, #42a5f5 100%);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 14px;
}
.admin-node-card.leg-left .node-avatar {
background: linear-gradient(135deg, #388e3c 0%, #66bb6a 100%);
}
.admin-node-card.leg-right .node-avatar {
background: linear-gradient(135deg, #f57c00 0%, #ffb74d 100%);
}
.admin-node-card .node-main-info {
flex: 1;
min-width: 0;
}
.admin-node-card .node-name {
font-weight: 600;
font-size: 12px;
color: #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100px;
}
.admin-node-card .node-meta {
display: flex;
align-items: center;
gap: 6px;
margin-top: 2px;
}
.admin-node-card .level-badge {
background: #e3f2fd;
color: #1976d2;
font-size: 10px;
padding: 1px 6px;
border-radius: 10px;
font-weight: 500;
}
.admin-node-card .leg-text-left {
color: #4caf50;
font-size: 10px;
font-weight: 500;
}
.admin-node-card .leg-text-right {
color: #ff9800;
font-size: 10px;
font-weight: 500;
}
/* Node footer */
.admin-node-card .node-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid #f0f0f0;
font-size: 10px;
}
.admin-node-card .club-status {
font-weight: 500;
}
.admin-node-card .club-status.club-active {
color: #4caf50;
}
.admin-node-card .club-status.club-inactive {
color: #9e9e9e;
}
.admin-node-card .activation-date {
color: #757575;
font-size: 9px;
}
/* Week badge */
.admin-node-card .week-badge {
position: absolute;
top: -8px;
right: -8px;
padding: 2px 6px;
border-radius: 10px;
font-size: 9px;
font-weight: bold;
}
.admin-node-card .week-badge.week-match {
background: #4caf50;
color: white;
}
.admin-node-card .week-badge.week-other {
background: #ff5722;
color: white;
}
/* Week filter disabled state */
.admin-node-card.week-disabled {
opacity: 0.35;
filter: grayscale(80%);
transform: scale(0.95);
box-shadow: none;
border-color: #e0e0e0 !important;
}
.admin-node-card.week-disabled:hover {
opacity: 0.5;
transform: scale(0.97);
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.admin-node-card.week-disabled .node-avatar {
background: #bdbdbd !important;
}
.admin-node-card.week-disabled .node-name {
color: #9e9e9e;
}
.admin-node-card.week-disabled .club-status {
color: #bdbdbd !important;
}
/* Target week highlight (sparkle badge) */
.admin-node-card .target-week-highlight {
position: absolute;
top: -12px;
left: -12px;
font-size: 18px;
animation: pulse-highlight 1.5s ease-in-out infinite;
}
@keyframes pulse-highlight {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.2); opacity: 0.8; }
}
/* Enhanced styling for target week nodes */
.admin-node-card:not(.week-disabled) {
/* Normal nodes when filter is active get subtle enhancement */
}
/* When week filter is active, make matching nodes stand out more */
.admin-node-card.leg-left:not(.week-disabled),
.admin-node-card.leg-right:not(.week-disabled) {
/* These will naturally stand out against disabled ones */
}
/* Expand button */
.admin-expand-btn {
width: 20px;
height: 20px;
border-radius: 50%;
background: #1976d2;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: bold;
cursor: pointer;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.admin-expand-btn:hover {
background: #1565c0;
}
/* Messages */
.admin-org-chart-container .no-data-message,
.admin-org-chart-container .error-message {
padding: 40px;
text-align: center;
color: #666;
}
/* RTL support */
[dir="rtl"] .admin-node-card {
border-left: none;
border-right: 4px solid #e0e0e0;
}
[dir="rtl"] .admin-node-card.leg-left {
border-right-color: #4caf50;
}
[dir="rtl"] .admin-node-card.leg-right {
border-right-color: #ff9800;
}
[dir="rtl"] .admin-node-card.leg-root {
border-right-color: #1976d2;
}
[dir="rtl"] .admin-node-card .week-badge {
right: auto;
left: -8px;
}
+4
View File
@@ -14,6 +14,7 @@
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" />
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
<link href="_content/Tizzani.MudBlazor.HtmlEditor/MudHtmlEditor.css" rel="stylesheet" />
<link href="css/admin-org-chart.css" rel="stylesheet" />
<!-- If you add any scoped CSS files, uncomment the following to load them
<link href="BackOffice.styles.css" rel="stylesheet" /> -->
@@ -35,6 +36,9 @@
</div>
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
<script src="/js/d3.v7.min.js"></script>
<script src="/js/d3-flextree.min.js"></script>
<script src="/js/d3-org-chart3.js"></script>
<script src="/js/admin-org-chart.js"></script>
<script src="/js/main.js"></script>
<script src="/js/quill.js"></script>
<script src="/js/network-tree.js"></script>
@@ -0,0 +1,243 @@
/**
* d3-org-chart integration for BackOffice Admin
* Network Binary Tree visualization with admin features
*/
window.AdminOrgChart = {
chart: null,
dotNetHelper: null,
containerId: null,
/**
* Initialize the organization chart
* @param {string} containerId - The ID of the container element
* @param {object} data - The tree data in flat array format
* @param {object} dotNetHelper - Blazor .NET helper for callbacks
* @param {object} options - Chart options (filterWeek, etc.)
*/
init: function (containerId, data, dotNetHelper, options = {}) {
this.dotNetHelper = dotNetHelper;
this.containerId = containerId;
const container = document.getElementById(containerId);
if (!container) {
console.error('AdminOrgChart: Container not found:', containerId);
return;
}
// Clear previous chart
container.innerHTML = '';
if (!data || data.length === 0) {
container.innerHTML = '<div class="no-data-message" style="padding: 40px; text-align: center; color: #666;">داده‌ای برای نمایش وجود ندارد</div>';
return;
}
try {
const filterWeek = options.filterWeek || null;
this.chart = new d3.OrgChart()
.container('#' + containerId)
.data(data)
.nodeWidth((d) => 160)
.nodeHeight((d) => 80)
.childrenMargin((d) => 60)
.compactMarginBetween((d) => 20)
.compactMarginPair((d) => 20)
.neighbourMargin((a, b) => 20)
.siblingsMargin((d) => 20)
.buttonContent(({ node, state }) => {
const hasChildren = node.data._directSubordinates > 0;
const isExpanded = node.children;
return hasChildren ? `<div class="admin-expand-btn">
<span>${isExpanded ? '' : '+'}</span>
</div>` : '';
})
.linkUpdate(function (d, i, arr) {
d3.select(this)
.attr('stroke', (d) => d.data._highlighted || d.data._upToTheRootHighlighted ? '#1976d2' : '#bdbdbd')
.attr('stroke-width', (d) => d.data._highlighted || d.data._upToTheRootHighlighted ? 3 : 2);
})
.nodeContent(function (d, i, arr, state) {
const data = d.data;
const isRoot = !data.parentId || data.parentId === '';
// Position styling
const positionClass = data.networkLeg === 0 ? 'leg-left' :
data.networkLeg === 1 ? 'leg-right' : 'leg-root';
// Club status
const clubClass = data.isClubActive ? 'club-active' : 'club-inactive';
// Week filter: check if this node is activated in target week
const isTargetWeek = data.isActivatedInTargetWeek;
const weekFilterActive = filterWeek && filterWeek > 0;
const isDisabledByWeekFilter = weekFilterActive && !isTargetWeek;
const disabledClass = isDisabledByWeekFilter ? 'week-disabled' : '';
// Week activation status badge
let weekIndicator = '';
if (weekFilterActive && data.activationWeekDefinitionId) {
weekIndicator = `<div class="week-badge ${isTargetWeek ? 'week-match' : 'week-other'}">
W${data.activationWeekDefinitionId}
</div>`;
}
// Highlight badge for target week matches
let highlightBadge = '';
if (weekFilterActive && isTargetWeek) {
highlightBadge = '<div class="target-week-highlight">✨</div>';
}
// Avatar - first letter of name
const firstChar = data.userName ? data.userName.charAt(0).toUpperCase() : '?';
// Level badge
const levelBadge = `<span class="level-badge">L${data.networkLevel || 0}</span>`;
// Leg indicator
const legText = isRoot ? '' : (data.networkLeg === 0 ? 'چپ' : 'راست');
const legClass = data.networkLeg === 0 ? 'leg-text-left' : 'leg-text-right';
// Build tooltip text
const tooltipLines = [
`👤 ${data.userName || 'کاربر ' + data.userId}`,
`🆔 شناسه: ${data.userId}`,
`📊 سطح: ${data.networkLevel || 0}`,
`${data.networkLeg === 0 ? '⬅️' : '➡️'} موقعیت: ${data.networkLeg === 0 ? 'چپ' : 'راست'}`,
`${data.isClubActive ? '✅' : '❌'} باشگاه: ${data.isClubActive ? 'فعال' : 'غیرفعال'}`
];
if (data.clubActivatedAt) {
tooltipLines.push(`📅 فعالسازی: ${data.clubActivatedAt}`);
}
if (data.activationWeekDefinitionId) {
tooltipLines.push(`📆 هفته: ${data.activationWeekDefinitionId}`);
}
if (weekFilterActive) {
tooltipLines.push(isTargetWeek ? '🎯 فعال در هفته انتخابی' : '⚪ خارج از هفته انتخابی');
}
const tooltipText = tooltipLines.join('&#10;');
return `
<div class="admin-node-card ${positionClass} ${clubClass} ${disabledClass}"
data-user-id="${data.userId}"
title="${tooltipText}">
${weekIndicator}
${highlightBadge}
<div class="node-header">
<div class="node-avatar">${firstChar}</div>
<div class="node-main-info">
<div class="node-name">${data.userName || 'کاربر ' + data.userId}</div>
<div class="node-meta">
${levelBadge}
${legText ? `<span class="${legClass}">${legText}</span>` : ''}
</div>
</div>
</div>
<div class="node-footer">
<span class="club-status ${clubClass}">
${data.isClubActive ? '✓ فعال' : '✗ غیرفعال'}
</span>
${data.clubActivatedAt ? `<span class="activation-date">${data.clubActivatedAt}</span>` : ''}
</div>
</div>
`;
})
.onNodeClick((d) => {
if (this.dotNetHelper) {
const userId = parseInt(d.data.userId, 10);
this.dotNetHelper.invokeMethodAsync('OnNodeClicked', userId);
}
})
.render();
// Initial fit
setTimeout(() => {
if (this.chart) {
this.chart.fit();
}
}, 100);
} catch (error) {
console.error('AdminOrgChart: Error initializing chart:', error);
container.innerHTML = '<div class="error-message" style="padding: 40px; text-align: center; color: #f44336;">خطا در بارگذاری نمودار</div>';
}
},
/**
* Update the chart with new data
*/
update: function (data, options = {}) {
if (this.chart && this.containerId) {
// Re-init with new data
this.init(this.containerId, data, this.dotNetHelper, options);
}
},
/**
* Expand all nodes
*/
expandAll: function () {
if (this.chart) {
this.chart.expandAll().render();
}
},
/**
* Collapse all nodes
*/
collapseAll: function () {
if (this.chart) {
this.chart.collapseAll().render();
}
},
/**
* Center/Fit the chart
*/
fit: function () {
if (this.chart) {
this.chart.fit();
}
},
/**
* Zoom to specific node
*/
zoomToNode: function (nodeId) {
if (this.chart) {
this.chart.setCentered(nodeId.toString()).render();
}
},
/**
* Export chart as PNG
*/
exportPng: function () {
if (this.chart) {
this.chart.exportImg({ full: true });
}
},
/**
* Export chart as SVG
*/
exportSvg: function () {
if (this.chart) {
this.chart.exportSvg();
}
},
/**
* Dispose the chart
*/
dispose: function () {
if (this.chart) {
this.chart = null;
this.dotNetHelper = null;
this.containerId = null;
}
}
};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long