feat: integrate d3-org-chart for network visualization and add SignalR token notification service
This commit is contained in:
@@ -63,6 +63,9 @@ public static class ConfigureServices
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<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)
|
||||
{
|
||||
<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))
|
||||
<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)
|
||||
{
|
||||
<MudAvatar Size="Size.Large">
|
||||
<MudImage ObjectFit="ObjectFit.Cover"
|
||||
ObjectPosition="ObjectPosition.Center"
|
||||
Src="@_currentUser.Avatar" />
|
||||
</MudAvatar>
|
||||
<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
|
||||
{
|
||||
<MudAvatar Size="Size.Large" Color="Color.Primary" Variant="Variant.Outlined">
|
||||
@(string.IsNullOrWhiteSpace(_currentUser.FirstName) ? "N" : _currentUser.FirstName.Substring(0, 1))
|
||||
</MudAvatar>
|
||||
<div id="org-chart-container" class="org-chart-container"></div>
|
||||
}
|
||||
|
||||
@if (_currentUser?.Children?.Any() == true)
|
||||
@* Stats Bar *@
|
||||
@if (_statistics != null && !_isLoading)
|
||||
{
|
||||
<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 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>
|
||||
<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>
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
finally
|
||||
{
|
||||
await LoadUserProfile();
|
||||
|
||||
|
||||
// Mock data - replace with actual API call
|
||||
_currentUser = new UserNode
|
||||
{
|
||||
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)
|
||||
{
|
||||
var found = FindNode(child, userId);
|
||||
if (found != null) return found;
|
||||
Console.WriteLine($"Error initializing chart: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
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)
|
||||
{
|
||||
await InitializeChart();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user