feat: integrate d3-org-chart for network visualization and add SignalR token notification service

This commit is contained in:
masoodafar-web
2025-12-18 03:19:06 +03:30
parent 27c2c0259b
commit 6b457d0ce6
21 changed files with 1558 additions and 201 deletions
@@ -29,7 +29,7 @@ public partial class AddAddressDialog : ComponentBase
var response = await CityContract.GetAllCitiesByFilterAsync(new CityProto.GetAllCitiesByFilterRequest
{
PaginationState = new CityProto.PaginationState { PageNumber = 1, PageSize = 20 },
Filter = new CityProto.GetAllCitiesByFilterFilter { Native = value }
Filter = new CityProto.GetAllCitiesByFilterFilter { Name = value }
}, cancellationToken: ct);
return response?.Models?.ToList() ?? new List<CityProto.GetAllCitiesByFilterResponseModel>();
@@ -1,62 +1,82 @@
@if (_currentUser != null)
{
<div class="org-chart-container">
<div class="org-tree">
<div class="org-node root-node" id="root-node">
<div class="node-card">
<div class="node-avatar">
@if (!string.IsNullOrWhiteSpace(_currentUser.Avatar))
{
<MudAvatar Size="Size.Large">
<MudImage ObjectFit="ObjectFit.Cover"
ObjectPosition="ObjectPosition.Center"
Src="@_currentUser.Avatar" />
</MudAvatar>
}
else
{
<MudAvatar Size="Size.Large" Color="Color.Primary" Variant="Variant.Outlined">
@(string.IsNullOrWhiteSpace(_currentUser.FirstName) ? "N" : _currentUser.FirstName.Substring(0, 1))
</MudAvatar>
}
@implements IAsyncDisposable
@if (_currentUser?.Children?.Any() == true)
{
<button class="expand-btn" @onclick="ToggleExpand" data-user-id="@_currentUser.Id">
<MudIcon Icon="@(_isExpanded ? Icons.Material.Filled.Remove : Icons.Material.Filled.Add)" Size="Size.Small" />
</button>
}
</div>
<div class="node-info">
@if (!string.IsNullOrWhiteSpace(_currentUser.FirstName) || !string.IsNullOrWhiteSpace(_currentUser.LastName))
{
<div class="node-name">@_currentUser.FirstName @_currentUser.LastName</div>
}
else
{
<div class="node-name">@_currentUser.Mobile</div>
}
@* <div class="node-amounts">
<div class="personal-amount">
<span class="label">خرید شخصی:</span>
<span class="amount">@(_currentUser?.PersonalPurchase?.ToThousands().ToCurrencyUnitIRT() ?? "0 تومان")</span>
</div>
<div class="team-amount">
<span class="label">خرید تیمی:</span>
<span class="amount">@(_currentUser?.TeamPurchase?.ToThousands().ToCurrencyUnitIRT() ?? "0 تومان")</span>
</div>
</div> *@
</div>
</div>
</div>
@if (_isExpanded && _currentUser?.Children?.Any() == true)
{
<CascadingValue Value="this">
<OrganizationChartLevel Nodes="_currentUser.Children" Level="1" />
</CascadingValue>
}
</div>
<div class="org-chart-wrapper">
@* Toolbar *@
<div class="org-chart-toolbar">
<MudSelect T="int" @bind-Value="_selectedDepth" Label="عمق" Variant="Variant.Outlined"
Dense="true" Margin="Margin.Dense" Style="width: 90px; min-width: 90px;">
<MudSelectItem Value="2">2 سطح</MudSelectItem>
<MudSelectItem Value="3">3 سطح</MudSelectItem>
<MudSelectItem Value="4">4 سطح</MudSelectItem>
<MudSelectItem Value="5">5 سطح</MudSelectItem>
<MudSelectItem Value="6">6 سطح</MudSelectItem>
<MudSelectItem Value="15">همه</MudSelectItem>
</MudSelect>
<MudButtonGroup Variant="Variant.Outlined" Size="Size.Small" OverrideStyles="false">
<MudIconButton Icon="@Icons.Material.Filled.UnfoldMore" OnClick="ExpandAll" Title="باز کردن همه" Size="Size.Small" />
<MudIconButton Icon="@Icons.Material.Filled.UnfoldLess" OnClick="CollapseAll" Title="بستن همه" Size="Size.Small" />
<MudIconButton Icon="@Icons.Material.Filled.CenterFocusStrong" OnClick="CenterChart" Title="مرکز" Size="Size.Small" />
<MudIconButton Icon="@Icons.Material.Filled.ZoomOutMap" OnClick="FitToScreen" Title="نمایش کامل" Size="Size.Small" />
<MudIconButton Icon="@Icons.Material.Filled.Refresh" OnClick="RefreshData" Color="Color.Primary" Title="بروزرسانی" Size="Size.Small" />
</MudButtonGroup>
@if (_currentViewUserId.HasValue)
{
<MudButtonGroup Variant="Variant.Outlined" Size="Size.Small" OverrideStyles="false">
<MudIconButton Icon="@Icons.Material.Filled.ArrowForward" OnClick="GoBack" Title="بازگشت" Size="Size.Small" Color="Color.Secondary" />
<MudIconButton Icon="@Icons.Material.Filled.Home" OnClick="GoToMyTree" Title="درخت من" Size="Size.Small" Color="Color.Secondary" />
</MudButtonGroup>
}
</div>
}
@* Chart Container *@
@if (_isLoading)
{
<div class="chart-loading">
<MudProgressCircular Color="Color.Primary" Indeterminate="true" />
<MudText Typo="Typo.body2" Class="mt-2">در حال بارگذاری...</MudText>
</div>
}
else if (_hasError)
{
<div class="error-message">
<MudIcon Icon="@Icons.Material.Filled.Error" Color="Color.Error" Size="Size.Large" />
<MudText Typo="Typo.body1" Class="mt-2">خطا در بارگذاری داده‌ها</MudText>
<MudButton Variant="Variant.Text" Color="Color.Primary" OnClick="RefreshData">تلاش مجدد</MudButton>
</div>
}
else
{
<div id="org-chart-container" class="org-chart-container"></div>
}
@* Stats Bar *@
@if (_statistics != null && !_isLoading)
{
<div class="org-chart-stats">
<div class="stat-item">
<span class="stat-label">کل اعضا:</span>
<span class="stat-value">@_statistics.TotalMembers</span>
</div>
<div class="stat-item">
<span class="stat-label">پای چپ:</span>
<span class="stat-value left">@_statistics.LeftLegCount</span>
</div>
<div class="stat-item">
<span class="stat-label">پای راست:</span>
<span class="stat-value right">@_statistics.RightLegCount</span>
</div>
<div class="stat-item">
<span class="stat-label">عمق:</span>
<span class="stat-value">@_statistics.TreeDepth</span>
</div>
<div class="stat-item">
<span class="stat-label">پای ضعیف:</span>
<span class="stat-value">@(_statistics.WeakerLeg == "Left" ? "چپ" : "راست")</span>
</div>
</div>
}
</div>
@@ -1,120 +1,262 @@
using FrontOffice.BFF.User.Protobuf.Protos.User;
using Mapster;
using FrontOffice.Main.Utilities;
using Microsoft.AspNetCore.Components;
using static FrontOffice.Main.Pages.Profile.Components.OrganizationChartLevel;
using Microsoft.JSInterop;
namespace FrontOffice.Main.Pages.Profile.Components;
public partial class OrganizationChart
{
private UserNode? _currentUser;
private bool _isExpanded;
[Inject] private UserContract.UserContractClient UserContract { get; set; } = default!;
private GetUserResponse _userProfile = new();
public partial class OrganizationChart : IAsyncDisposable
{
private NetworkTreeDto? _networkTree;
private NetworkStatisticsDto? _statistics;
private bool _isLoading = true;
private bool _hasError = false;
private int _selectedDepthValue = 3;
private DotNetObjectReference<OrganizationChart>? _dotNetHelper;
private bool _chartNeedsInit = false;
private bool _jsReady = false;
// برای نگهداری شناسه کاربر فعلی که درختش نمایش داده شده
private long? _currentViewUserId = null;
// Stack برای بازگشت به عقب
private Stack<long> _navigationHistory = new();
private int _selectedDepth
{
get => _selectedDepthValue;
set
{
if (_selectedDepthValue != value)
{
_selectedDepthValue = value;
_ = OnDepthChanged();
}
}
}
[Inject] private NetworkMembershipService NetworkService { get; set; } = default!;
// JSRuntime is injected globally via _Imports.razor;
protected override async Task OnInitializedAsync()
{
await LoadCurrentUser();
await LoadData();
}
private async Task LoadUserProfile()
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
_jsReady = true;
}
// Initialize chart after JS is ready and data is loaded
if (_jsReady && _chartNeedsInit && _networkTree?.RootNode != null)
{
_chartNeedsInit = false;
await InitializeChart();
}
}
private async Task LoadData()
{
_isLoading = true;
_hasError = false;
StateHasChanged();
try
{
_userProfile = await UserContract.GetUserAsync(request: new());
// Load tree and statistics in parallel
var treeTask = NetworkService.GetMyNetworkTreeAsync(_selectedDepth);
var statsTask = NetworkService.GetMyNetworkStatisticsAsync();
await Task.WhenAll(treeTask, statsTask);
_networkTree = await treeTask;
_statistics = await statsTask;
// Mark that chart needs to be initialized
_chartNeedsInit = true;
}
catch (Exception ex)
{
// Handle the case when user is not authenticated or API fails
_userProfile = new GetUserResponse();
Console.WriteLine($"Error loading network data: {ex.Message}");
_hasError = true;
}
StateHasChanged();
}
private async Task LoadCurrentUser()
{
await LoadUserProfile();
// Mock data - replace with actual API call
_currentUser = new UserNode
finally
{
Id = _userProfile.Id,
FirstName = _userProfile.FirstName,
LastName = _userProfile.LastName,
Mobile = _userProfile.Mobile,
Avatar = _userProfile.AvatarPath,
PersonalPurchase = 0,
TeamPurchase = 0,
Children = await GetUserChildren(userId: _userProfile.Id)
};
}
private async Task<List<UserNode>?> GetUserChildren(long userId)
{
Console.WriteLine("OK0");
if (userId != 0)
{
Console.WriteLine("OK1");
var children = await UserContract.GetAllUserByFilterAsync(request: new()
{
Filter = new()
{
ParentId = userId,
}
});
if (children?.Models?.Any() == true)
{
Console.WriteLine("OK2");
var result = new List<UserNode>();
foreach (var item in children.Models)
{
var node = new UserNode
{
Id = item.Id,
FirstName = item.FirstName,
LastName = item.LastName,
Mobile = item.Mobile,
Avatar = item.AvatarPath,
ReferralCode = item.ReferralCode,
PersonalPurchase = 0,
TeamPurchase = 0,
Children = await GetUserChildren(userId: item.Id)
};
result.Add(node);
}
return result;
}
}
return null;
}
private void ToggleExpand()
{
_isExpanded = !_isExpanded;
StateHasChanged();
}
public void ToggleNodeExpand(long userId)
{
var node = FindNode(_currentUser, userId);
if (node != null)
{
node.IsExpanded = !node.IsExpanded;
_isLoading = false;
StateHasChanged();
}
}
private UserNode? FindNode(UserNode? node, long userId)
private async Task InitializeChart()
{
if (node == null) return null;
if (node.Id == userId) return node;
if (_networkTree?.RootNode == null) return;
if (node.Children != null)
try
{
foreach (var child in node.Children)
_dotNetHelper = DotNetObjectReference.Create(this);
var flatData = _networkTree.ToFlatArray();
await JSRuntime.InvokeVoidAsync("OrgChart.init", "org-chart-container", flatData, _dotNetHelper);
}
catch (Exception ex)
{
Console.WriteLine($"Error initializing chart: {ex.Message}");
}
}
private async Task RefreshData()
{
await LoadData();
if (_networkTree?.RootNode != null)
{
await InitializeChart();
}
}
private async Task OnDepthChanged()
{
await RefreshData();
}
private async Task ExpandAll()
{
try
{
await JSRuntime.InvokeVoidAsync("OrgChart.expandAll");
}
catch { }
}
private async Task CollapseAll()
{
try
{
await JSRuntime.InvokeVoidAsync("OrgChart.collapseAll");
}
catch { }
}
private async Task CenterChart()
{
try
{
await JSRuntime.InvokeVoidAsync("OrgChart.center");
}
catch { }
}
private async Task FitToScreen()
{
try
{
await JSRuntime.InvokeVoidAsync("OrgChart.fitToScreen");
}
catch { }
}
private async Task ExportPng()
{
try
{
await JSRuntime.InvokeVoidAsync("OrgChart.exportPng");
}
catch { }
}
/// <summary>
/// Called from JavaScript when a node is clicked
/// </summary>
[JSInvokable]
public async Task OnNodeClicked(long userId)
{
// اگر روی همان نود کلیک شده، کاری نکن
if (_currentViewUserId == userId) return;
// ذخیره نود فعلی در history برای بازگشت
if (_currentViewUserId.HasValue)
{
_navigationHistory.Push(_currentViewUserId.Value);
}
await LoadSubordinateTree(userId);
}
/// <summary>
/// بارگذاری درخت یک زیرمجموعه
/// </summary>
private async Task LoadSubordinateTree(long targetUserId)
{
_isLoading = true;
StateHasChanged();
try
{
_networkTree = await NetworkService.GetSubordinateTreeAsync(targetUserId, _selectedDepth);
_currentViewUserId = targetUserId;
_chartNeedsInit = true;
}
catch (UnauthorizedAccessException)
{
// نمایش پیام خطا - این کاربر زیرمجموعه نیست
Console.WriteLine("Unauthorized access to subordinate tree");
}
catch (Exception ex)
{
Console.WriteLine($"Error loading subordinate tree: {ex.Message}");
}
finally
{
_isLoading = false;
StateHasChanged();
}
}
/// <summary>
/// بازگشت به درخت قبلی
/// </summary>
private async Task GoBack()
{
if (_navigationHistory.Count > 0)
{
var previousUserId = _navigationHistory.Pop();
await LoadSubordinateTree(previousUserId);
}
else
{
// بازگشت به درخت خود کاربر
_currentViewUserId = null;
await LoadData();
if (_networkTree?.RootNode != null)
{
var found = FindNode(child, userId);
if (found != null) return found;
await InitializeChart();
}
}
}
return null;
/// <summary>
/// بازگشت به درخت اصلی (خود کاربر)
/// </summary>
private async Task GoToMyTree()
{
_navigationHistory.Clear();
_currentViewUserId = null;
await LoadData();
if (_networkTree?.RootNode != null)
{
await InitializeChart();
}
}
public async ValueTask DisposeAsync()
{
try
{
await JSRuntime.InvokeVoidAsync("OrgChart.dispose");
}
catch { }
_dotNetHelper?.Dispose();
}
}
@@ -4,6 +4,12 @@ using MudBlazor;
using FrontOffice.Main.Utilities;
namespace FrontOffice.Main.Pages.Profile.Components;
/// <summary>
/// DEPRECATED: This component is no longer used.
/// d3-org-chart is now used for tree visualization.
/// Kept for backward compatibility.
/// </summary>
public partial class OrganizationChartLevel
{
[Parameter] public List<UserNode>? Nodes { get; set; }
@@ -25,18 +31,12 @@ public partial class OrganizationChartLevel
private void ToggleNodeExpand(long userId)
{
if (ParentChart != null)
{
ParentChart.ToggleNodeExpand(userId);
}
// No longer needed - d3-org-chart handles this internally
}
private void SafeToggleNodeExpand(long userId)
{
if (ParentChart != null)
{
ParentChart.ToggleNodeExpand(userId);
}
// No longer needed - d3-org-chart handles this internally
}
private async Task CopyReferralCode(string referralCode)