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
@@ -62,6 +62,9 @@ public static class ConfigureServices
services.AddTransient<IDeviceDetector, DeviceDetector>();
// PDF generation (Chromium only)
services.AddSingleton<FrontOffice.Main.Utilities.Pdf.IChromiumPdfService, FrontOffice.Main.Utilities.Pdf.ChromiumPdfService>();
// SignalR Token Notification Service
services.AddScoped<TokenNotificationService>();
return services;
}
+11 -2
View File
@@ -13,12 +13,12 @@
<!-- <PackageReference Include="Foursat.FrontOffice.BFF.ClubMembership.Protobuf" Version="0.0.3" /> -->
<PackageReference Include="Foursat.FrontOffice.BFF.Commission.Protobuf" Version="0.0.2" />
<PackageReference Include="Foursat.FrontOffice.BFF.DiscountShop.Protobuf" Version="0.0.2" />
<PackageReference Include="Foursat.FrontOffice.BFF.NetworkMembership.Protobuf" Version="0.0.2" />
<!-- <PackageReference Include="Foursat.FrontOffice.BFF.NetworkMembership.Protobuf" Version="0.0.2" />-->
<!-- <PackageReference Include="Foursat.FrontOffice.BFF.Package.Protobuf" Version="0.0.113" /> -->
<PackageReference Include="Foursat.FrontOffice.BFF.Products.Protobuf" Version="0.0.17" />
<PackageReference Include="Foursat.FrontOffice.BFF.Transaction.Protobuf" Version="0.0.112" />
<PackageReference Include="Foursat.FrontOffice.BFF.Category.Protobuf" Version="0.0.13" />
<PackageReference Include="Foursat.FrontOffice.BFF.User.Protobuf" Version="0.0.117" />
<!-- <PackageReference Include="Foursat.FrontOffice.BFF.User.Protobuf" Version="0.0.117" /> -->
<PackageReference Include="Foursat.FrontOffice.BFF.UserAddress.Protobuf" Version="0.0.115" />
<PackageReference Include="Foursat.FrontOffice.BFF.UserOrder.Protobuf" Version="0.0.115" />
<PackageReference Include="Foursat.FrontOffice.BFF.ShopingCart.Protobuf" Version="0.0.16" />
@@ -32,6 +32,7 @@
<!-- Keep only PuppeteerSharp for Chromium-based PDF generation -->
<PackageReference Include="PuppeteerSharp" Version="11.0.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.0" />
</ItemGroup>
<!-- Local Proto Project Reference for development -->
@@ -39,6 +40,8 @@
<ProjectReference Include="..\..\..\FrontOffice.BFF\src\Protobufs\FrontOffice.BFF.Package.Protobuf\FrontOffice.BFF.Package.Protobuf.csproj" />
<ProjectReference Include="..\..\..\FrontOffice.BFF\src\Protobufs\FrontOffice.BFF.ClubMembership.Protobuf\FrontOffice.BFF.ClubMembership.Protobuf.csproj" />
<ProjectReference Include="..\..\..\FrontOffice.BFF\src\Protobufs\FrontOffice.BFF.City.Protobuf\FrontOffice.BFF.City.Protobuf.csproj" />
<ProjectReference Include="..\..\..\FrontOffice.BFF\src\Protobufs\FrontOffice.BFF.User.Protobuf\FrontOffice.BFF.User.Protobuf.csproj" />
<ProjectReference Include="..\..\..\FrontOffice.BFF\src\Protobufs\FrontOffice.BFF.NetworkMembership.Protobuf\FrontOffice.BFF.NetworkMembership.Protobuf.csproj" />
</ItemGroup>
<!-- New Proto Projects (local references until NuGet publish) -->
@@ -60,6 +63,12 @@
<Content Include="..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
<Content Remove="Pages\Package\Packages.razor" />
</ItemGroup>
<ItemGroup>
<Compile Remove="Pages\Package\Packages.razor.cs" />
</ItemGroup>
</Project>
+32 -32
View File
@@ -21,7 +21,7 @@ public partial class Index:IDisposable
{
await InvokeAsync(StateHasChanged);
};
await LoadPackagesAsync();
// await LoadPackagesAsync();
//string mobileNumber = "09387342688";
@@ -50,37 +50,37 @@ public partial class Index:IDisposable
await base.OnAfterRenderAsync(firstRender);
}
private async Task LoadPackagesAsync()
{
_isLoadingPackages = true;
try
{
var response = await PackageClient.GetAllPackageByFilterAsync(request: new());
if (response?.Models?.Any() == true)
{
_packs = response.Models.Select(p => new Pack(
Id: p.Id,
Title: p.Title,
Body: p.Description,
Image: UrlUtility.DownloadUrl + p.ImagePath,
Price: p.Price
)).ToList();
}
else
_packs = new List<Pack>();
}
catch (Exception ex)
{
Snackbar.Add($"خطا در بارگذاری پکیج‌ها: {ex.Message}", Severity.Error);
// Fallback to empty list
_packs = new List<Pack>();
}
finally
{
_isLoadingPackages = false;
await InvokeAsync(StateHasChanged);
}
}
// private async Task LoadPackagesAsync()
// {
// _isLoadingPackages = true;
// try
// {
// var response = await PackageClient.GetAllPackageByFilterAsync(request: new());
// if (response?.Models?.Any() == true)
// {
// _packs = response.Models.Select(p => new Pack(
// Id: p.Id,
// Title: p.Title,
// Body: p.Description,
// Image: UrlUtility.DownloadUrl + p.ImagePath,
// Price: p.Price
// )).ToList();
// }
// else
// _packs = new List<Pack>();
// }
// catch (Exception ex)
// {
// Snackbar.Add($"خطا در بارگذاری پکیج‌ها: {ex.Message}", Severity.Error);
// // Fallback to empty list
// _packs = new List<Pack>();
// }
// finally
// {
// _isLoadingPackages = false;
// await InvokeAsync(StateHasChanged);
// }
// }
private void JoinWaitlist()
{
@@ -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)
@@ -56,6 +56,9 @@ public partial class Index
await base.OnAfterRenderAsync(firstRender);
if (firstRender)
{
// Refresh token to get latest user claims
await AuthService.RefreshTokenAsync();
await LoadUserAuthInfo();
await LoadUserProfile();
await LoadAddresses();
+9
View File
@@ -17,6 +17,8 @@
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" />
<!-- Ensure latest MudBlazor CSS is used (cache-busting) -->
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" asp-append-version="true" />
<!-- d3-org-chart custom styles -->
<link href="css/org-chart.css" rel="stylesheet" asp-append-version="true" />
</head>
<body>
<component type="typeof(App)" render-mode="Server" />
@@ -34,6 +36,13 @@
<!-- Load MudBlazor JS before Blazor to avoid early JS interop calls failing; add cache-busting -->
<script src="_content/MudBlazor/MudBlazor.min.js" asp-append-version="true"></script>
<!-- d3.js and d3-org-chart for network tree visualization -->
<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/org-chart.js" asp-append-version="true"></script>
<script src="_framework/blazor.server.js"></script>
<script>
window.fetchAndDownloadPdf = async function(html, fileName){
@@ -58,6 +58,11 @@
<div class="d-flex align-center gap-2">
@if (_isAuthenticated)
{
<MudBadge Content="@_cartCount" Color="Color.Error" Overlap="true" Visible="@(_cartCount > 0)">
<MudIconButton Icon="@Icons.Material.Filled.ShoppingCart" Color="Color.Inherit"
Href="@(RouteConstants.Store.Cart)" />
</MudBadge>
<MudMenu Icon="@Icons.Material.Filled.Person" Color="Color.Inherit" Size="Size.Medium">
<MudMenuItem OnClick="NavigateToProfile" Disabled="@(!AuthService.IsCompleteRegister())">
پروفایل
@@ -6,7 +6,7 @@ using MudBlazor;
using Microsoft.AspNetCore.Components.Authorization;
namespace FrontOffice.Main.Shared;
public partial class MainLayout
public partial class MainLayout : IDisposable
{
private const string TokenStorageKey = "auth:token";
@@ -15,10 +15,12 @@ public partial class MainLayout
private bool _drawerOpen;
private bool _isAuthenticated;
private string? _email;
private int _cartCount;
[Inject] private ILocalStorageService LocalStorage { get; set; } = default!;
[Inject] private AuthService AuthService { get; set; } = default!;
[Inject] private AuthDialogService AuthDialogService { get; set; } = default!;
[Inject] private CartService CartService { get; set; } = default!;
private void ToggleTheme() => _isDark = !_isDark;
private void ToggleDrawer() => _drawerOpen = !_drawerOpen;
@@ -33,10 +35,23 @@ public partial class MainLayout
{
await JSRuntime.InvokeVoidAsync("changeNavBgOnBodyScroll", "top", null, 1);
await CheckAuthStatus();
if (_isAuthenticated)
{
CartService.OnChange += OnCartChanged;
_cartCount = CartService.Count;
}
StateHasChanged();
}
}
private void OnCartChanged()
{
_cartCount = CartService.Count;
InvokeAsync(StateHasChanged);
}
private async Task CheckAuthStatus()
{
_isAuthenticated = await AuthService.IsAuthenticatedAsync();
@@ -64,6 +79,12 @@ public partial class MainLayout
{
await AuthService.LogoutAsync();
_isAuthenticated = false;
_cartCount = 0;
StateHasChanged();
}
public void Dispose()
{
CartService.OnChange -= OnCartChanged;
}
}
+57 -1
View File
@@ -1,4 +1,5 @@
using Blazored.LocalStorage;
using FrontOffice.BFF.User.Protobuf.Protos.User;
using Microsoft.AspNetCore.Components;
using MudBlazor;
using System.IdentityModel.Tokens.Jwt;
@@ -11,15 +12,25 @@ public class AuthService
private readonly NavigationManager _navigation;
private readonly ISnackbar _snackbar;
private readonly UserAuthInfo _userAuthInfo;
private readonly UserContract.UserContractClient _userContract;
private readonly ILogger<AuthService> _logger;
private const string TokenStorageKey = "auth:token";
public AuthService(ILocalStorageService localStorage, NavigationManager navigation, ISnackbar snackbar, UserAuthInfo userAuthInfo)
public AuthService(
ILocalStorageService localStorage,
NavigationManager navigation,
ISnackbar snackbar,
UserAuthInfo userAuthInfo,
UserContract.UserContractClient userContract,
ILogger<AuthService> logger)
{
_localStorage = localStorage;
_navigation = navigation;
_snackbar = snackbar;
_userAuthInfo = userAuthInfo;
_userContract = userContract;
_logger = logger;
}
public async Task<bool> IsAuthenticatedAsync()
@@ -99,6 +110,51 @@ public class AuthService
_navigation.NavigateTo(RouteConstants.Main.MainPage);
}
/// <summary>
/// Refresh the user's token by calling the BFF RefreshToken API.
/// If successful, the new token is stored and user info is updated.
/// </summary>
/// <returns>True if token was refreshed successfully</returns>
public async Task<bool> RefreshTokenAsync()
{
try
{
var currentToken = await GetTokenAsync();
if (string.IsNullOrEmpty(currentToken))
{
_logger.LogWarning("No token found for refresh");
return false;
}
var response = await _userContract.RefreshTokenAsync(new RefreshTokenRequest
{
CurrentToken = currentToken
});
if (response.Success && !string.IsNullOrEmpty(response.Token))
{
// Store the new token
await _localStorage.SetItemAsync(TokenStorageKey, response.Token);
// Update user auth info with new token claims
await InitUserAuthInfo();
_logger.LogInformation("Token refreshed successfully");
return true;
}
else
{
_logger.LogInformation("Token refresh not needed: {Message}", response.Message);
return false;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to refresh token");
return false;
}
}
public async Task RequireAuthenticationAsync()
{
var isAuthenticated = await IsAuthenticatedAsync();
@@ -9,10 +9,32 @@ public class NetworkNodeDto
public string FullName { get; set; } = string.Empty;
public string Mobile { get; set; } = string.Empty;
public string? Avatar { get; set; }
public string Position { get; set; } = string.Empty; // "Left" or "Right"
public string Position { get; set; } = string.Empty; // "Root", "Left" or "Right"
public NetworkNodeDto? LeftChild { get; set; }
public NetworkNodeDto? RightChild { get; set; }
public int Level { get; set; }
public bool IsActive { get; set; } = true;
public DateTime? JoinedAt { get; set; }
public bool IsClubActive { get; set; }
public string? ActivationWeekNumber { get; set; }
}
/// <summary>
/// DTO for flat node structure (for d3-org-chart)
/// </summary>
public class FlatNetworkNodeDto
{
public string Id { get; set; } = string.Empty;
public string ParentId { get; set; } = string.Empty;
public string FullName { get; set; } = string.Empty;
public string Mobile { get; set; } = string.Empty;
public string? Avatar { get; set; }
public string Position { get; set; } = string.Empty;
public int Level { get; set; }
public bool IsActive { get; set; } = true;
public bool IsClubActive { get; set; }
public string? ActivationWeekNumber { get; set; }
public DateTime? JoinedAt { get; set; }
}
/// <summary>
@@ -23,6 +45,48 @@ public class NetworkTreeDto
public NetworkNodeDto? RootNode { get; set; }
public int TotalMembers { get; set; }
public int CurrentDepth { get; set; }
/// <summary>
/// Convert hierarchical tree to flat array for d3-org-chart
/// </summary>
public List<FlatNetworkNodeDto> ToFlatArray()
{
var result = new List<FlatNetworkNodeDto>();
if (RootNode == null) return result;
TraverseAndFlatten(RootNode, "", result);
return result;
}
private void TraverseAndFlatten(NetworkNodeDto node, string parentId, List<FlatNetworkNodeDto> result)
{
var flatNode = new FlatNetworkNodeDto
{
Id = node.UserId.ToString(),
ParentId = parentId,
FullName = node.FullName,
Mobile = node.Mobile,
Avatar = node.Avatar,
Position = node.Position,
Level = node.Level,
IsActive = node.IsActive,
IsClubActive = node.IsClubActive,
ActivationWeekNumber = node.ActivationWeekNumber,
JoinedAt = node.JoinedAt
};
result.Add(flatNode);
if (node.LeftChild != null)
{
TraverseAndFlatten(node.LeftChild, flatNode.Id, result);
}
if (node.RightChild != null)
{
TraverseAndFlatten(node.RightChild, flatNode.Id, result);
}
}
}
/// <summary>
@@ -41,6 +41,39 @@ public class NetworkMembershipService
}
}
/// <summary>
/// Get subordinate's network tree (with security check on backend)
/// Maps to: NetworkMembershipCQ.GetSubordinateTree
/// </summary>
public async Task<NetworkTreeDto> GetSubordinateTreeAsync(long targetUserId, int maxDepth = 3)
{
try
{
var request = new GetSubordinateTreeRequest
{
TargetUserId = targetUserId,
MaxDepth = maxDepth
};
var response = await _client.GetSubordinateTreeAsync(request);
return new NetworkTreeDto
{
RootNode = MapNodeFromProto(response.RootNode),
TotalMembers = response.TotalMembers,
CurrentDepth = response.CurrentDepth
};
}
catch (Grpc.Core.RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.PermissionDenied)
{
throw new UnauthorizedAccessException("شما اجازه مشاهده درخت این کاربر را ندارید");
}
catch
{
// Fallback to current user's tree
return await GetMyNetworkTreeAsync(maxDepth);
}
}
/// <summary>
/// Get current user's network statistics
/// Maps to: NetworkMembershipCQ.GetMyNetworkStatistics
@@ -102,6 +135,10 @@ public class NetworkMembershipService
Avatar = node.Avatar,
Position = node.Position,
Level = node.Level,
IsActive = node.HasChildren, // Temporary: use HasChildren as active indicator until proto is updated
IsClubActive = false, // Will be populated when proto is updated
ActivationWeekNumber = null, // Will be populated when proto is updated
JoinedAt = null, // Will be populated when proto is updated
LeftChild = MapNodeFromProto(node.LeftChild),
RightChild = MapNodeFromProto(node.RightChild)
};
@@ -111,8 +148,8 @@ public class NetworkMembershipService
{
return new NetworkTreeDto
{
CurrentDepth = 2,
TotalMembers = 5,
CurrentDepth = 3,
TotalMembers = 7,
RootNode = new NetworkNodeDto
{
UserId = 1,
@@ -120,13 +157,38 @@ public class NetworkMembershipService
Mobile = "09121234567",
Position = "Root",
Level = 0,
IsActive = true,
IsClubActive = true,
LeftChild = new NetworkNodeDto
{
UserId = 2,
FullName = "علی محمدی",
Mobile = "09121234568",
Position = "Left",
Level = 1
Level = 1,
IsActive = true,
IsClubActive = true,
JoinedAt = DateTime.Now.AddDays(-30),
LeftChild = new NetworkNodeDto
{
UserId = 4,
FullName = "رضا کریمی",
Mobile = "09121234570",
Position = "Left",
Level = 2,
IsActive = true,
JoinedAt = DateTime.Now.AddDays(-15)
},
RightChild = new NetworkNodeDto
{
UserId = 5,
FullName = "زهرا احمدی",
Mobile = "09121234571",
Position = "Right",
Level = 2,
IsActive = false,
JoinedAt = DateTime.Now.AddDays(-10)
}
},
RightChild = new NetworkNodeDto
{
@@ -134,7 +196,20 @@ public class NetworkMembershipService
FullName = "فاطمه حسینی",
Mobile = "09121234569",
Position = "Right",
Level = 1
Level = 1,
IsActive = true,
JoinedAt = DateTime.Now.AddDays(-25),
LeftChild = new NetworkNodeDto
{
UserId = 6,
FullName = "محمد نوری",
Mobile = "09121234572",
Position = "Left",
Level = 2,
IsActive = true,
IsClubActive = true,
JoinedAt = DateTime.Now.AddDays(-5)
}
}
}
};
@@ -0,0 +1,216 @@
using Microsoft.AspNetCore.SignalR.Client;
using Blazored.LocalStorage;
namespace FrontOffice.Main.Utilities;
/// <summary>
/// Service for managing SignalR connection and handling token-related notifications from BFF.
/// This service connects to the BFF SignalR Hub and notifies the application when token refresh is needed.
/// </summary>
public class TokenNotificationService : IAsyncDisposable
{
private readonly ILocalStorageService _localStorage;
private readonly IConfiguration _configuration;
private readonly ILogger<TokenNotificationService> _logger;
private HubConnection? _hubConnection;
/// <summary>
/// Event fired when a token revoked notification is received from server
/// </summary>
public event Func<TokenRevokedEventArgs, Task>? OnTokenRevoked;
/// <summary>
/// Event fired when a force refresh token notification is received from server
/// </summary>
public event Func<ForceRefreshEventArgs, Task>? OnForceRefreshToken;
/// <summary>
/// Event fired when a broadcast message is received from server
/// </summary>
public event Func<BroadcastMessageEventArgs, Task>? OnBroadcastMessage;
/// <summary>
/// Event fired when connection state changes
/// </summary>
public event Action<HubConnectionState>? OnConnectionStateChanged;
public TokenNotificationService(
ILocalStorageService localStorage,
IConfiguration configuration,
ILogger<TokenNotificationService> logger)
{
_localStorage = localStorage;
_configuration = configuration;
_logger = logger;
}
/// <summary>
/// Gets the current connection state
/// </summary>
public HubConnectionState ConnectionState => _hubConnection?.State ?? HubConnectionState.Disconnected;
/// <summary>
/// Connect to the SignalR Hub with the user's authentication token
/// </summary>
public async Task ConnectAsync()
{
if (_hubConnection?.State == HubConnectionState.Connected)
{
_logger.LogWarning("Already connected to SignalR Hub");
return;
}
try
{
var token = await _localStorage.GetItemAsync<string>("authToken");
if (string.IsNullOrEmpty(token))
{
_logger.LogWarning("No auth token found, cannot connect to SignalR Hub");
return;
}
var gwUrl = _configuration["GwUrl"]?.TrimEnd('/') ?? "https://localhost:5002";
var hubPath = _configuration["SignalR:HubPath"] ?? "/hubs/token-relay";
var hubUrl = $"{gwUrl}{hubPath}";
_logger.LogInformation("Connecting to SignalR Hub at {HubUrl}", hubUrl);
_hubConnection = new HubConnectionBuilder()
.WithUrl(hubUrl, options =>
{
options.AccessTokenProvider = () => Task.FromResult<string?>(token);
})
.WithAutomaticReconnect(new[]
{
TimeSpan.FromSeconds(0),
TimeSpan.FromSeconds(2),
TimeSpan.FromSeconds(5),
TimeSpan.FromSeconds(10),
TimeSpan.FromSeconds(30)
})
.Build();
RegisterEventHandlers();
await _hubConnection.StartAsync();
_logger.LogInformation("Successfully connected to SignalR Hub");
OnConnectionStateChanged?.Invoke(HubConnectionState.Connected);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to connect to SignalR Hub");
OnConnectionStateChanged?.Invoke(HubConnectionState.Disconnected);
}
}
/// <summary>
/// Disconnect from the SignalR Hub
/// </summary>
public async Task DisconnectAsync()
{
if (_hubConnection != null)
{
await _hubConnection.StopAsync();
_logger.LogInformation("Disconnected from SignalR Hub");
OnConnectionStateChanged?.Invoke(HubConnectionState.Disconnected);
}
}
private void RegisterEventHandlers()
{
if (_hubConnection == null) return;
// Handle TokenRevoked event
_hubConnection.On<TokenRevokedEventArgs>("TokenRevoked", async args =>
{
_logger.LogInformation("Received TokenRevoked notification. UserId: {UserId}, Reason: {Reason}",
args.UserId, args.Reason);
if (OnTokenRevoked != null)
{
await OnTokenRevoked.Invoke(args);
}
});
// Handle ForceRefreshToken event
_hubConnection.On<ForceRefreshEventArgs>("ForceRefreshToken", async args =>
{
_logger.LogInformation("Received ForceRefreshToken notification. UserId: {UserId}", args.UserId);
if (OnForceRefreshToken != null)
{
await OnForceRefreshToken.Invoke(args);
}
});
// Handle BroadcastMessage event
_hubConnection.On<BroadcastMessageEventArgs>("BroadcastMessage", async args =>
{
_logger.LogInformation("Received BroadcastMessage: {Message}", args.Message);
if (OnBroadcastMessage != null)
{
await OnBroadcastMessage.Invoke(args);
}
});
// Handle connection state changes
_hubConnection.Reconnecting += error =>
{
_logger.LogWarning(error, "SignalR connection lost. Attempting to reconnect...");
OnConnectionStateChanged?.Invoke(HubConnectionState.Reconnecting);
return Task.CompletedTask;
};
_hubConnection.Reconnected += connectionId =>
{
_logger.LogInformation("SignalR reconnected. ConnectionId: {ConnectionId}", connectionId);
OnConnectionStateChanged?.Invoke(HubConnectionState.Connected);
return Task.CompletedTask;
};
_hubConnection.Closed += async error =>
{
_logger.LogWarning(error, "SignalR connection closed");
OnConnectionStateChanged?.Invoke(HubConnectionState.Disconnected);
await Task.CompletedTask;
};
}
public async ValueTask DisposeAsync()
{
if (_hubConnection != null)
{
await _hubConnection.DisposeAsync();
}
}
}
/// <summary>
/// Event arguments for token revoked notification
/// </summary>
public class TokenRevokedEventArgs
{
public long UserId { get; set; }
public string Reason { get; set; } = string.Empty;
public DateTime Timestamp { get; set; }
}
/// <summary>
/// Event arguments for force refresh token notification
/// </summary>
public class ForceRefreshEventArgs
{
public long UserId { get; set; }
public DateTime Timestamp { get; set; }
}
/// <summary>
/// Event arguments for broadcast message notification
/// </summary>
public class BroadcastMessageEventArgs
{
public string Message { get; set; } = string.Empty;
public DateTime Timestamp { get; set; }
}
+3
View File
@@ -6,6 +6,9 @@
"Key": "kmcQ3XTmH4mrdh8VHziuscyf8LLYjG//Kyni81nH/0E=",
"IV": "1wyF3Tt142MOkCpIyCxh/g=="
},
"SignalR": {
"HubPath": "/hubs/token-relay"
},
"Logging": {
"LogLevel": {
"Default": "Information",
@@ -0,0 +1,416 @@
/**
* d3-org-chart custom styles for FourSat
* Binary Network Tree visualization
*/
/* Container */
.org-chart-wrapper {
width: 100%;
min-height: 500px;
background: #f5f5f5;
border-radius: 8px;
overflow: hidden;
position: relative;
}
.org-chart-container {
width: 100%;
height: 550px;
}
/* Loading & Error States */
.chart-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 400px;
color: #6c757d;
}
.no-data-message,
.error-message {
text-align: center;
padding: 60px 20px;
color: #6c757d;
font-size: 16px;
}
.error-message {
color: #dc3545;
}
/* Node Card - Compact */
.org-node-card {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: all 0.2s ease;
cursor: pointer;
border: 1px solid #e0e0e0;
font-family: 'Vazirmatn', 'IRANSans', sans-serif;
direction: rtl;
width: 100%;
height: 100%;
}
.org-node-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border-color: #0380C0;
}
.org-node-card.active {
border-left: 3px solid #28a745;
}
.org-node-card.inactive {
opacity: 0.6;
border-left: 3px solid #dc3545;
}
/* Compact Header */
.node-header-compact {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
height: 100%;
background: linear-gradient(135deg, #f8f9fa 0%, #fff 100%);
}
.node-header-compact.root {
background: linear-gradient(135deg, #e8f5e9 0%, #fff 100%);
}
.node-header-compact.root .node-avatar-sm {
background: linear-gradient(135deg, #28a745 0%, #1e7e34 100%);
}
/* Avatar Small */
.node-avatar-sm {
width: 26px;
height: 26px;
min-width: 26px;
border-radius: 50%;
background: linear-gradient(135deg, #0380C0 0%, #026a9e 100%);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 11px;
font-weight: 600;
}
.node-info {
flex: 1;
min-width: 0;
text-align: right;
}
.node-name-sm {
font-size: 10px;
font-weight: 600;
color: #212529;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2;
}
.node-level-sm {
font-size: 9px;
color: #6c757d;
margin-top: 1px;
}
/* Legacy Avatar (keep for compatibility) */
.node-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
overflow: hidden;
border: 2px solid white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 20px;
font-weight: bold;
}
/* Position Badge */
.position-badge {
font-size: 11px;
padding: 4px 10px;
border-radius: 12px;
font-weight: 600;
text-transform: uppercase;
}
.position-badge.position-left {
background: #e3f2fd;
color: #1976d2;
}
.position-badge.position-right {
background: #fce4ec;
color: #c2185b;
}
.position-badge.position-root {
background: #e8f5e9;
color: #388e3c;
}
/* Node Body */
.node-body {
padding: 12px;
text-align: center;
}
.node-name {
font-size: 14px;
font-weight: 600;
color: #212529;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.node-mobile {
font-size: 12px;
color: #6c757d;
direction: ltr;
margin-bottom: 8px;
}
.node-meta {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.level-badge {
font-size: 10px;
padding: 2px 8px;
border-radius: 10px;
background: #f8f9fa;
color: #495057;
font-weight: 500;
}
.club-badge {
font-size: 14px;
}
/* Expand/Collapse Button */
.org-expand-btn {
width: 20px;
height: 20px;
background: #0380C0;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 12px;
font-weight: bold;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 2px 6px rgba(3, 128, 192, 0.4);
border: 2px solid white;
}
.org-expand-btn:hover {
background: #026a9e;
transform: scale(1.1);
}
/* Toolbar */
.org-chart-toolbar {
display: flex;
align-items: center;
justify-content: flex-start;
padding: 10px 12px;
background: white;
border-bottom: 1px solid #e9ecef;
flex-wrap: wrap;
gap: 10px;
}
.toolbar-group {
display: flex;
align-items: center;
gap: 8px;
}
.toolbar-btn {
padding: 8px 16px;
border: 1px solid #dee2e6;
background: white;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 6px;
}
.toolbar-btn:hover {
background: #f8f9fa;
border-color: #adb5bd;
}
.toolbar-btn.primary {
background: #0380C0;
color: white;
border-color: #0380C0;
}
.toolbar-btn.primary:hover {
background: #026a9e;
}
/* Stats Bar */
.org-chart-stats {
display: flex;
align-items: center;
justify-content: center;
gap: 24px;
padding: 12px 16px;
background: #f8f9fa;
border-top: 1px solid #e9ecef;
}
.stat-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.stat-label {
color: #6c757d;
}
.stat-value {
font-weight: 600;
color: #212529;
}
.stat-value.left {
color: #1976d2;
}
.stat-value.right {
color: #c2185b;
}
/* Depth Controls */
.depth-control {
display: flex;
align-items: center;
gap: 8px;
}
.depth-control label {
font-size: 13px;
color: #495057;
}
.depth-control select {
padding: 6px 12px;
border: 1px solid #dee2e6;
border-radius: 6px;
font-size: 13px;
background: white;
}
/* Responsive */
@media (max-width: 768px) {
.org-chart-wrapper {
border-radius: 8px;
}
.org-chart-container {
height: 450px;
}
.org-chart-toolbar {
padding: 10px;
gap: 8px;
}
.toolbar-group {
flex-wrap: wrap;
gap: 6px;
}
.toolbar-btn {
padding: 6px 12px;
font-size: 12px;
}
.org-chart-stats {
flex-wrap: wrap;
gap: 12px;
padding: 10px;
}
.stat-item {
font-size: 12px;
}
}
@media (max-width: 480px) {
.org-chart-container {
height: 400px;
}
.org-chart-toolbar {
padding: 8px;
}
.toolbar-btn {
padding: 5px 8px;
font-size: 11px;
}
.toolbar-btn span {
display: none;
}
.stat-item {
font-size: 11px;
}
}
/* Print styles */
@media print {
.org-chart-toolbar,
.org-chart-stats {
display: none;
}
.org-chart-container {
height: auto;
overflow: visible;
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,242 @@
/**
* d3-org-chart integration for Blazor
* Network Binary Tree visualization for FourSat
*/
window.OrgChart = {
chart: null,
dotNetHelper: 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
*/
init: function (containerId, data, dotNetHelper) {
this.dotNetHelper = dotNetHelper;
const container = document.getElementById(containerId);
if (!container) {
console.error('OrgChart: Container not found:', containerId);
return;
}
// Clear previous chart
container.innerHTML = '';
if (!data || data.length === 0) {
container.innerHTML = '<div class="no-data-message">داده‌ای برای نمایش وجود ندارد</div>';
return;
}
try {
this.chart = new d3.OrgChart()
.container('#' + containerId)
.data(data)
.nodeWidth((d) => 130)
.nodeHeight((d) => 56)
.childrenMargin((d) => 50)
.compactMarginBetween((d) => 15)
.compactMarginPair((d) => 15)
.neighbourMargin((a, b) => 15)
.siblingsMargin((d) => 15)
.buttonContent(({ node, state }) => {
const hasChildren = node.data._directSubordinates > 0;
const isExpanded = node.children;
return hasChildren ? `<div class="org-expand-btn">
<span>${isExpanded ? '' : '+'}</span>
</div>` : '';
})
.linkUpdate(function (d, i, arr) {
d3.select(this)
.attr('stroke', (d) => d.data._highlighted || d.data._upToTheRootHighlighted ? '#0380C0' : '#ccc')
.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 === '';
const positionClass = data.position === 'Left' ? 'position-left' :
data.position === 'Right' ? 'position-right' : 'position-root';
const activeClass = data.isActive ? 'active' : 'inactive';
// Avatar - first letter of name
const firstChar = data.fullName ? data.fullName.charAt(0) : '?';
return `
<div class="org-node-card ${positionClass} ${activeClass}" data-user-id="${data.id}">
<div class="node-header-compact ${isRoot ? 'root' : ''}">
<div class="node-avatar-sm">${firstChar}</div>
<div class="node-info">
<div class="node-name-sm">${data.fullName || 'بدون نام'}</div>
<div class="node-level-sm">L${data.level || 0}${!isRoot ? ' • ' + (data.position === 'Left' ? 'چپ' : 'راست') : ''}</div>
</div>
</div>
</div>
`;
})
.onNodeClick((d) => {
if (this.dotNetHelper) {
// Convert string id to number for C# long
const userId = parseInt(d.data.id, 10);
this.dotNetHelper.invokeMethodAsync('OnNodeClicked', userId);
}
})
.render();
// Initial centering and zoom
this.chart.fit();
} catch (error) {
console.error('OrgChart: Error initializing chart:', error);
container.innerHTML = '<div class="error-message">خطا در بارگذاری نمودار</div>';
}
},
/**
* Update the chart with new data
* @param {object} data - The new tree data
*/
update: function (data) {
if (this.chart) {
this.chart.data(data).render();
this.chart.fit();
}
},
/**
* 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 the chart
*/
center: function () {
if (this.chart) {
this.chart.fit();
}
},
/**
* Fit entire tree to screen (zoom out completely)
*/
fitToScreen: function () {
if (this.chart) {
this.chart.fit();
}
},
/**
* Zoom to specific node
* @param {string} nodeId - The ID of the node to zoom to
*/
zoomToNode: function (nodeId) {
if (this.chart) {
this.chart.setCentered(nodeId).render();
}
},
/**
* Highlight path to specific node
* @param {string} nodeId - The ID of the node
*/
highlightNode: function (nodeId) {
if (this.chart) {
this.chart.setHighlighted(nodeId).render();
}
},
/**
* Clear highlighting
*/
clearHighlight: function () {
if (this.chart) {
this.chart.clearHighlighting().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) {
// d3-org-chart doesn't have built-in dispose, so we just clear the reference
this.chart = null;
this.dotNetHelper = null;
}
},
/**
* Convert hierarchical tree data to flat array format for d3-org-chart
* @param {object} rootNode - The root node with nested children
* @returns {array} - Flat array of nodes
*/
convertToFlatArray: function (rootNode) {
if (!rootNode) return [];
const result = [];
function traverse(node, parentId) {
const flatNode = {
id: node.userId?.toString() || node.id?.toString(),
parentId: parentId,
fullName: node.fullName || '',
mobile: node.mobile || '',
avatar: node.avatar,
position: node.position || 'Root',
level: node.level || 0,
isActive: node.isActive !== false,
isClubActive: node.isClubActive || false,
activationWeekNumber: node.activationWeekNumber,
joinedAt: node.joinedAt
};
result.push(flatNode);
// Process left child
if (node.leftChild) {
traverse(node.leftChild, flatNode.id);
}
// Process right child
if (node.rightChild) {
traverse(node.rightChild, flatNode.id);
}
}
traverse(rootNode, '');
return result;
}
};