Compare commits

..

4 Commits

Author SHA1 Message Date
masoodafar-web 025e8d3c3e feat: add withdrawal requests feature and update VAT loading mechanism
Build and Deploy / build (push) Successful in 1m25s
2025-12-20 04:03:11 +03:30
masoodafar-web 9ee464b8b4 feat: implement VAT management service and integrate VAT calculations across components 2025-12-19 00:30:33 +03:30
masoodafar-web 6b457d0ce6 feat: integrate d3-org-chart for network visualization and add SignalR token notification service 2025-12-18 03:19:06 +03:30
masoodafar-web 27c2c0259b feat: add club membership contract dialog and city autocomplete 2025-12-17 22:46:07 +03:30
59 changed files with 3693 additions and 851 deletions
@@ -18,6 +18,8 @@ using FrontOffice.BFF.ClubMembership.Protobuf.Protos.ClubMembership;
using FrontOffice.BFF.Commission.Protobuf.Protos.Commission; using FrontOffice.BFF.Commission.Protobuf.Protos.Commission;
using FrontOffice.BFF.NetworkMembership.Protobuf.Protos.NetworkMembership; using FrontOffice.BFF.NetworkMembership.Protobuf.Protos.NetworkMembership;
using FrontOffice.BFF.DiscountShop.Protobuf.Protos.DiscountShop; using FrontOffice.BFF.DiscountShop.Protobuf.Protos.DiscountShop;
using FrontOffice.BFF.City.Protobuf;
using FrontOffice.BFF.Configuration.Protobuf.Protos.Configuration;
using FrontOffice.Main.Utilities; using FrontOffice.Main.Utilities;
namespace Microsoft.Extensions.DependencyInjection; namespace Microsoft.Extensions.DependencyInjection;
@@ -51,16 +53,21 @@ public static class ConfigureServices
services.AddScoped<CategoryService>(); services.AddScoped<CategoryService>();
services.AddScoped<OrderService>(); services.AddScoped<OrderService>();
services.AddScoped<WalletService>(); services.AddScoped<WalletService>();
services.AddScoped<VATService>(); // Singleton برای cache روزانه
// Package service // Package service
services.AddScoped<PackageService>(); services.AddScoped<PackageService>();
// New services for Club, Network, Commission // New services for Club, Network, Commission
services.AddScoped<ClubMembershipService>(); services.AddScoped<ClubMembershipService>();
services.AddScoped<ClubConfigurationService>();
services.AddScoped<NetworkMembershipService>(); services.AddScoped<NetworkMembershipService>();
services.AddScoped<CommissionService>(); services.AddScoped<CommissionService>();
// Device detection: very light, dependency-free // Device detection: very light, dependency-free
services.AddTransient<IDeviceDetector, DeviceDetector>(); services.AddTransient<IDeviceDetector, DeviceDetector>();
// PDF generation (Chromium only) // PDF generation (Chromium only)
services.AddSingleton<FrontOffice.Main.Utilities.Pdf.IChromiumPdfService, FrontOffice.Main.Utilities.Pdf.ChromiumPdfService>(); services.AddSingleton<FrontOffice.Main.Utilities.Pdf.IChromiumPdfService, FrontOffice.Main.Utilities.Pdf.ChromiumPdfService>();
// SignalR Token Notification Service
services.AddScoped<TokenNotificationService>();
return services; return services;
} }
@@ -99,8 +106,10 @@ public static class ConfigureServices
// New gRPC clients for Club, Network, Commission, DiscountShop // New gRPC clients for Club, Network, Commission, DiscountShop
services.AddScoped(CreateAuthenticatedClient<ClubMembershipContract.ClubMembershipContractClient>); services.AddScoped(CreateAuthenticatedClient<ClubMembershipContract.ClubMembershipContractClient>);
services.AddScoped(CreateAuthenticatedClient<CommissionContract.CommissionContractClient>); services.AddScoped(CreateAuthenticatedClient<CommissionContract.CommissionContractClient>);
services.AddScoped(CreateAuthenticatedClient<ConfigurationContract.ConfigurationContractClient>);
services.AddScoped(CreateAuthenticatedClient<NetworkMembershipContract.NetworkMembershipContractClient>); services.AddScoped(CreateAuthenticatedClient<NetworkMembershipContract.NetworkMembershipContractClient>);
services.AddScoped(CreateAuthenticatedClient<DiscountShopContract.DiscountShopContractClient>); services.AddScoped(CreateAuthenticatedClient<DiscountShopContract.DiscountShopContractClient>);
services.AddScoped(CreateAuthenticatedClient<CityContract.CityContractClient>);
return services; return services;
} }
+40 -16
View File
@@ -10,20 +10,29 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="DateTimeConverterCL" Version="1.0.0" /> <PackageReference Include="DateTimeConverterCL" Version="1.0.0" />
<PackageReference Include="Foursat.FrontOffice.BFF.ClubMembership.Protobuf" Version="0.0.3" /> <PackageReference Include="Foursat.FrontOffice.BFF.City.Protobuf" Version="0.0.2" />
<PackageReference Include="Foursat.FrontOffice.BFF.Commission.Protobuf" Version="0.0.2" /> <PackageReference Include="Foursat.FrontOffice.BFF.ClubMembership.Protobuf" Version="0.0.4" />
<PackageReference Include="Foursat.FrontOffice.BFF.DiscountShop.Protobuf" Version="0.0.2" /> <PackageReference Include="Foursat.FrontOffice.BFF.Commission.Protobuf" Version="0.0.3" />
<PackageReference Include="Foursat.FrontOffice.BFF.NetworkMembership.Protobuf" Version="0.0.2" /> <PackageReference Include="Foursat.FrontOffice.BFF.Configuration.Protobuf" Version="0.0.3" />
<!-- <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.3" />
<PackageReference Include="Foursat.FrontOffice.BFF.NetworkMembership.Protobuf" Version="0.0.4" />
<PackageReference Include="Foursat.FrontOffice.BFF.Package.Protobuf" Version="0.0.114" />
<!-- <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.Package.Protobuf" Version="0.0.113" /> -->
<PackageReference Include="Foursat.FrontOffice.BFF.Products.Protobuf" Version="0.0.17" /> <PackageReference Include="Foursat.FrontOffice.BFF.Products.Protobuf" Version="0.0.18" />
<PackageReference Include="Foursat.FrontOffice.BFF.Transaction.Protobuf" Version="0.0.112" /> <PackageReference Include="Foursat.FrontOffice.BFF.Transaction.Protobuf" Version="0.0.113" />
<PackageReference Include="Foursat.FrontOffice.BFF.Category.Protobuf" Version="0.0.13" /> <PackageReference Include="Foursat.FrontOffice.BFF.Category.Protobuf" Version="0.0.14" />
<PackageReference Include="Foursat.FrontOffice.BFF.User.Protobuf" Version="0.0.117" /> <PackageReference Include="Foursat.FrontOffice.BFF.User.Protobuf" Version="0.0.118" />
<PackageReference Include="Foursat.FrontOffice.BFF.UserAddress.Protobuf" Version="0.0.115" /> <!-- <PackageReference Include="Foursat.FrontOffice.BFF.User.Protobuf" Version="0.0.117" /> -->
<PackageReference Include="Foursat.FrontOffice.BFF.UserOrder.Protobuf" Version="0.0.115" /> <PackageReference Include="Foursat.FrontOffice.BFF.UserAddress.Protobuf" Version="0.0.116" />
<PackageReference Include="Foursat.FrontOffice.BFF.ShopingCart.Protobuf" Version="0.0.16" /> <!-- <PackageReference Include="Foursat.FrontOffice.BFF.UserOrder.Protobuf" Version="0.0.115" />-->
<PackageReference Include="Foursat.FrontOffice.BFF.UserWallet.Protobuf" Version="0.0.15" /> <PackageReference Include="Foursat.FrontOffice.BFF.ShopingCart.Protobuf" Version="0.0.17" />
<!-- UserWallet moved to ProjectReference for latest proto --> <PackageReference Include="Foursat.FrontOffice.BFF.UserOrder.Protobuf" Version="0.0.116" />
<PackageReference Include="Foursat.FrontOffice.BFF.UserWallet.Protobuf" Version="0.0.16" />
<!-- UserWallet moved to ProjectReference for latest proto with WeekDefinitionId -->
<!-- <PackageReference Include="Foursat.FrontOffice.BFF.UserWallet.Protobuf" Version="0.0.15" /> -->
<PackageReference Include="MudBlazor" Version="8.14.0" /> <PackageReference Include="MudBlazor" Version="8.14.0" />
<PackageReference Include="Blazored.LocalStorage" Version="4.5.0" /> <PackageReference Include="Blazored.LocalStorage" Version="4.5.0" />
<PackageReference Include="Mapster" Version="7.4.0" /> <PackageReference Include="Mapster" Version="7.4.0" />
@@ -32,12 +41,21 @@
<!-- Keep only PuppeteerSharp for Chromium-based PDF generation --> <!-- Keep only PuppeteerSharp for Chromium-based PDF generation -->
<PackageReference Include="PuppeteerSharp" Version="11.0.0" /> <PackageReference Include="PuppeteerSharp" Version="11.0.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" /> <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.0" />
</ItemGroup> </ItemGroup>
<!-- Local Proto Project Reference for development --> <!-- Local Proto Project Reference for development -->
<ItemGroup> <!-- <ItemGroup>-->
<ProjectReference Include="..\..\..\FrontOffice.BFF\src\Protobufs\FrontOffice.BFF.Package.Protobuf\FrontOffice.BFF.Package.Protobuf.csproj" /> <!-- <ProjectReference Include="..\..\..\FrontOffice.BFF\src\Protobufs\FrontOffice.BFF.Package.Protobuf\FrontOffice.BFF.Package.Protobuf.csproj" />-->
</ItemGroup> <!-- <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.Configuration.Protobuf\FrontOffice.BFF.Configuration.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" />-->
<!-- <ProjectReference Include="..\..\..\FrontOffice.BFF\src\Protobufs\FrontOffice.BFF.UserOrder.Protobuf\FrontOffice.BFF.UserOrder.Protobuf.csproj" />-->
<!-- <ProjectReference Include="..\..\..\FrontOffice.BFF\src\Protobufs\FrontOffice.BFF.Commission.Protobuf\FrontOffice.BFF.Commission.Protobuf.csproj" />-->
<!-- <ProjectReference Include="..\..\..\FrontOffice.BFF\src\Protobufs\FrontOffice.BFF.UserWallet.Protobuf\FrontOffice.BFF.UserWallet.Protobuf.csproj" />-->
<!-- </ItemGroup>-->
<!-- New Proto Projects (local references until NuGet publish) --> <!-- New Proto Projects (local references until NuGet publish) -->
<!-- <ItemGroup>--> <!-- <ItemGroup>-->
@@ -58,6 +76,12 @@
<Content Include="..\.dockerignore"> <Content Include="..\.dockerignore">
<Link>.dockerignore</Link> <Link>.dockerignore</Link>
</Content> </Content>
<Content Remove="Pages\Package\Packages.razor" />
</ItemGroup>
<ItemGroup>
<Compile Remove="Pages\Package\Packages.razor.cs" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+166 -125
View File
@@ -1,6 +1,8 @@
@attribute [Route(RouteConstants.Club.Features)] @attribute [Route(RouteConstants.Club.Features)]
@using FrontOffice.Main.Utilities @using FrontOffice.Main.Utilities
@using MudBlazor @using MudBlazor
@inject ClubConfigurationService ClubConfigService
@inject IDialogService DialogService
<PageTitle>ویژگی‌های باشگاه مشتریان</PageTitle> <PageTitle>ویژگی‌های باشگاه مشتریان</PageTitle>
@@ -11,142 +13,181 @@
<MudButton Variant="Variant.Text" StartIcon="@Icons.Material.Filled.ArrowBack" Href="@RouteConstants.Club.Membership">بازگشت</MudButton> <MudButton Variant="Variant.Text" StartIcon="@Icons.Material.Filled.ArrowBack" Href="@RouteConstants.Club.Membership">بازگشت</MudButton>
</MudStack> </MudStack>
<!-- توضیحات کلی باشگاه -->
<MudPaper Elevation="2" Class="pa-4 rounded-lg"> <MudPaper Elevation="2" Class="pa-4 rounded-lg">
<MudText Typo="Typo.h6" Class="mb-3">چرا باشگاه مشتریان؟</MudText> <MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="mb-3">
<MudIcon Icon="@Icons.Material.Filled.Stars" Color="Color.Primary" Size="Size.Large" />
<MudText Typo="Typo.h6" Color="Color.Primary">ویژگی‌های اختصاصی شما</MudText>
</MudStack>
<MudText Typo="Typo.body1" Class="mb-2"> <MudText Typo="Typo.body1" Class="mb-2">
با عضویت در باشگاه مشتریان فورست، از تخفیف‌های ویژه، امتیازات خرید و خدمات اختصاصی بهره‌مند شوید. علاوه بر مزایای پایه عضویت در باشگاه مشتریان، شما به مجموعه‌ای از ویژگی‌های اختصاصی دسترسی دارید. برخی از این ویژگی‌ها ممکن است نیاز به زمان برای فعال‌سازی داشته باشند.
با کلیک روی دکمه «جزئیات» هر ویژگی، می‌توانید اطلاعات بیشتری درباره نحوه استفاده از آن امکان را مشاهده کنید.
</MudText> </MudText>
</MudPaper> </MudPaper>
<MudGrid Spacing="3"> <!-- لیست فیچرها -->
<MudItem xs="12" md="6"> <MudText Typo="Typo.h6" Class="mt-4">ویژگی‌های شما</MudText>
<MudPaper Elevation="2" Class="pa-4 rounded-lg" Style="height: 100%;">
<MudStack Spacing="2"> @if (_isLoading)
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2"> {
<MudIcon Icon="@Icons.Material.Filled.Discount" Color="Color.Primary" Size="Size.Large" /> <MudProgressLinear Color="Color.Primary" Indeterminate="true" />
<MudText Typo="Typo.h6" Color="Color.Primary">تخفیف‌های ویژه</MudText> }
</MudStack> else if (_features == null || !_features.Any())
<MudText Typo="Typo.body2"> {
• تخفیف 10% تا 30% برای تمام محصولات<br /> <MudAlert Severity="Severity.Info">
• تخفیف‌های فصلی و مناسبتی اختصاصی<br /> هنوز ویژگی‌ای برای شما فعال نشده است.
• پیشنهادات ویژه برای اعضای باشگاه<br /> </MudAlert>
• کد تخفیف اختصاصی ماهانه }
</MudText> else
</MudStack> {
</MudPaper> <MudPaper Elevation="2" Class="pa-2 rounded-lg">
</MudItem> <MudList T="ClubFeatureDto" Dense="true">
@foreach (var feature in _features)
<MudItem xs="12" md="6"> {
<MudPaper Elevation="2" Class="pa-4 rounded-lg" Style="height: 100%;"> <MudListItem>
<MudStack Spacing="2"> <MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Style="width: 100%;">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2"> <MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.Diamond" Color="Color.Secondary" Size="Size.Large" /> <MudIcon Icon="@Icons.Material.Filled.CheckCircle"
<MudText Typo="Typo.h6" Color="Color.Secondary">امتیاز خرید</MudText> Color="@(feature.IsEnabled ? Color.Success : Color.Default)" />
</MudStack> <MudText>@feature.Title</MudText>
<MudText Typo="Typo.body2"> </MudStack>
• دریافت امتیاز برای هر خرید<br /> <MudButton Variant="Variant.Text"
• تبدیل امتیاز به تخفیف باشگاه<br /> Color="Color.Primary"
• امتیاز ویژه در روزهای خاص<br /> Size="Size.Small"
• قابلیت انتقال امتیاز به دوستان OnClick="@(() => ShowFeatureDetails(feature))">
</MudText> جزئیات
</MudStack> </MudButton>
</MudPaper> </MudStack>
</MudItem> </MudListItem>
@if (feature != _features.Last())
<MudItem xs="12" md="6"> {
<MudPaper Elevation="2" Class="pa-4 rounded-lg" Style="height: 100%;"> <MudDivider />
<MudStack Spacing="2"> }
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2"> }
<MudIcon Icon="@Icons.Material.Filled.LocalShipping" Color="Color.Success" Size="Size.Large" /> </MudList>
<MudText Typo="Typo.h6" Color="Color.Success">ارسال رایگان</MudText> </MudPaper>
</MudStack> }
<MudText Typo="Typo.body2">
• ارسال رایگان برای خریدهای بالای 500 هزار تومان<br />
• اولویت در ارسال سفارشات<br />
• ارسال اکسپرس با تخفیف 50%<br />
• امکان ارسال به چند آدرس
</MudText>
</MudStack>
</MudPaper>
</MudItem>
<MudItem xs="12" md="6">
<MudPaper Elevation="2" Class="pa-4 rounded-lg" Style="height: 100%;">
<MudStack Spacing="2">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.Support" Color="Color.Info" Size="Size.Large" />
<MudText Typo="Typo.h6" Color="Color.Info">پشتیبانی اختصاصی</MudText>
</MudStack>
<MudText Typo="Typo.body2">
• پشتیبانی 24/7 برای اعضای باشگاه<br />
• مشاوره خرید تخصصی<br />
• خط ویژه پاسخگویی<br />
• پیگیری سریع‌تر سفارشات
</MudText>
</MudStack>
</MudPaper>
</MudItem>
<MudItem xs="12" md="6">
<MudPaper Elevation="2" Class="pa-4 rounded-lg" Style="height: 100%;">
<MudStack Spacing="2">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.Groups" Color="Color.Warning" Size="Size.Large" />
<MudText Typo="Typo.h6" Color="Color.Warning">درآمد شبکه</MudText>
</MudStack>
<MudText Typo="Typo.body2">
• دریافت کمیسیون از خریدهای زیرمجموعه<br />
• سیستم باینری درختی<br />
• محاسبه خودکار درآمد هفتگی<br />
• امکان برداشت درآمد شبکه
</MudText>
</MudStack>
</MudPaper>
</MudItem>
<MudItem xs="12" md="6">
<MudPaper Elevation="2" Class="pa-4 rounded-lg" Style="height: 100%;">
<MudStack Spacing="2">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.Event" Color="Color.Tertiary" Size="Size.Large" />
<MudText Typo="Typo.h6" Color="Color.Tertiary">رویدادهای ویژه</MudText>
</MudStack>
<MudText Typo="Typo.body2">
• دعوت به رویدادهای اختصاصی<br />
• پیش‌فروش محصولات جدید<br />
• وبینارهای آموزشی رایگان<br />
• جوایز و مسابقات ماهانه
</MudText>
</MudStack>
</MudPaper>
</MudItem>
</MudGrid>
<MudPaper Elevation="2" Class="pa-4 rounded-lg">
<MudText Typo="Typo.h6" Class="mb-3">چگونه عضو شویم؟</MudText>
<MudStepper Orientation="Orientation.Horizontal" Color="Color.Primary">
<MudStep Title="انتخاب بسته">
<MudText>بسته عضویت مناسب خود را انتخاب کنید</MudText>
</MudStep>
<MudStep Title="پرداخت">
<MudText>هزینه عضویت را پرداخت کنید</MudText>
</MudStep>
<MudStep Title="فعال‌سازی">
<MudText>عضویت شما فورا فعال می‌شود</MudText>
</MudStep>
<MudStep Title="استفاده">
<MudText>از مزایای باشگاه لذت ببرید</MudText>
</MudStep>
</MudStepper>
</MudPaper>
<MudButton Variant="Variant.Filled" <MudButton Variant="Variant.Filled"
Color="Color.Primary" Color="Color.Primary"
FullWidth="true" FullWidth="true"
Size="Size.Large" Size="Size.Large"
StartIcon="@Icons.Material.Filled.CheckCircle" StartIcon="@Icons.Material.Filled.ArrowBack"
Href="@RouteConstants.Club.Membership"> Href="@RouteConstants.Club.Membership">
همین حالا عضو شوید بازگشت به صفحه باشگاه
</MudButton> </MudButton>
</MudStack> </MudStack>
</MudContainer> </MudContainer>
<!-- مدال جزئیات فیچر -->
<MudDialog @bind-Visible="_showDetailDialog" Options="@_dialogOptions">
<TitleContent>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.Star" Color="Color.Primary" />
<MudText Typo="Typo.h6">@_selectedFeature?.Title</MudText>
</MudStack>
</TitleContent>
<DialogContent>
@if (_selectedFeature != null)
{
<MudStack Spacing="3">
<!-- وضعیت -->
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudText Typo="Typo.subtitle2">وضعیت:</MudText>
@if (_selectedFeature.IsEnabled)
{
<MudChip T="string" Color="Color.Success" Size="Size.Small">فعال</MudChip>
}
else
{
<MudChip T="string" Color="Color.Default" Size="Size.Small">غیرفعال</MudChip>
}
</MudStack>
<!-- توضیحات کوتاه -->
@if (!string.IsNullOrEmpty(_selectedFeature.Description))
{
<MudText Typo="Typo.body1">@_selectedFeature.Description</MudText>
}
<!-- تاریخ‌ها -->
<MudDivider />
<MudGrid>
<MudItem xs="6">
<MudText Typo="Typo.caption" Color="Color.Default">تاریخ ایجاد:</MudText>
<MudText Typo="Typo.body2">
@(_selectedFeature.CreatedAt?.ToLocalTime().ToString("yyyy/MM/dd HH:mm") ?? "-")
</MudText>
</MudItem>
<MudItem xs="6">
<MudText Typo="Typo.caption" Color="Color.Default">تاریخ فعال‌سازی:</MudText>
<MudText Typo="Typo.body2">
@(_selectedFeature.GrantedAt.HasValue && _selectedFeature.GrantedAt.Value > DateTime.MinValue
? _selectedFeature.GrantedAt.Value.ToLocalTime().ToString("yyyy/MM/dd HH:mm")
: "-")
</MudText>
</MudItem>
</MudGrid>
<!-- یادداشت (Notes) - محتوای اصلی مدال -->
@if (!string.IsNullOrEmpty(_selectedFeature.Notes))
{
<MudDivider />
<MudText Typo="Typo.subtitle2" Class="mb-2">توضیحات تکمیلی:</MudText>
<MudPaper Elevation="0" Class="pa-3 rounded" Style="background-color: var(--mud-palette-background-grey);">
@((MarkupString)_selectedFeature.Notes)
</MudPaper>
}
</MudStack>
}
</DialogContent>
<DialogActions>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(() => _showDetailDialog = false)">
بستن
</MudButton>
</DialogActions>
</MudDialog>
@code {
private List<ClubFeatureDto>? _features;
private bool _isLoading = true;
private bool _showDetailDialog;
private ClubFeatureDto? _selectedFeature;
private DialogOptions _dialogOptions = new()
{
MaxWidth = MaxWidth.Small,
FullWidth = true,
CloseButton = true,
CloseOnEscapeKey = true
};
protected override async Task OnInitializedAsync()
{
await LoadFeatures();
}
private async Task LoadFeatures()
{
_isLoading = true;
try
{
_features = await ClubConfigService.GetClubFeaturesAsync();
}
catch (Exception ex)
{
Console.WriteLine($"Error loading features: {ex.Message}");
_features = new List<ClubFeatureDto>();
}
finally
{
_isLoading = false;
}
}
private void ShowFeatureDetails(ClubFeatureDto feature)
{
_selectedFeature = feature;
_showDetailDialog = true;
}
}
@@ -56,6 +56,19 @@
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined"> <MudAlert Severity="Severity.Warning" Variant="Variant.Outlined">
<MudText>شما هنوز عضو باشگاه مشتریان نیستید. برای استفاده از مزایای ویژه، اکنون فعال کنید!</MudText> <MudText>شما هنوز عضو باشگاه مشتریان نیستید. برای استفاده از مزایای ویژه، اکنون فعال کنید!</MudText>
</MudAlert> </MudAlert>
@if (_clubConfig != null)
{
<MudPaper Elevation="0" Class="pa-3 mt-3 rounded-lg" Style="background-color: var(--mud-palette-info-lighten);">
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudStack Spacing="1">
<MudText Typo="Typo.subtitle2" Color="Color.Info">هزینه عضویت در باشگاه مشتریان</MudText>
<MudText Typo="Typo.caption">شامل @((_clubConfig.MembershipGiftValue / 10000).ToString("N0")) تومان هدیه</MudText>
</MudStack>
<MudText Typo="Typo.h6" Color="Color.Info">@((_clubConfig.ActivationFee / 10000).ToString("N0")) تومان</MudText>
</MudStack>
</MudPaper>
}
} }
</MudStack> </MudStack>
</MudPaper> </MudPaper>
@@ -63,41 +76,31 @@
<!-- مزایای باشگاه --> <!-- مزایای باشگاه -->
<MudPaper Elevation="2" Class="pa-4 rounded-lg"> <MudPaper Elevation="2" Class="pa-4 rounded-lg">
<MudText Typo="Typo.h6" Class="mb-3">مزایای عضویت باشگاه</MudText> <MudText Typo="Typo.h6" Class="mb-3">مزایای عضویت باشگاه</MudText>
<MudGrid Spacing="2"> <MudStack Spacing="2">
<MudItem xs="12" sm="6" md="4"> <MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="pa-2">
<MudPaper Elevation="0" Class="pa-3 rounded-lg" Style="background-color: var(--mud-palette-primary-lighten);"> <MudIcon Icon="@Icons.Material.Filled.AccountBalanceWallet" Color="Color.Success" Size="Size.Large" />
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2"> <MudStack Spacing="0">
<MudIcon Icon="@Icons.Material.Filled.Discount" Color="Color.Primary" Size="Size.Large" /> <MudText Typo="Typo.subtitle1">شارژ کیف پول فروشگاه تخفیفی</MudText>
<MudStack Spacing="0"> <MudText Typo="Typo.caption" Color="Color.Default">شارژ ۵۶ میلیون تومان کیف پول فروشگاه تخفیفی</MudText>
<MudText Typo="Typo.subtitle1" Color="Color.Primary">تخفیف ویژه</MudText> </MudStack>
<MudText Typo="Typo.caption">تا 30% تخفیف</MudText> </MudStack>
</MudStack> <MudDivider />
</MudStack> <MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="pa-2">
</MudPaper> <MudIcon Icon="@Icons.Material.Filled.TrendingUp" Color="Color.Primary" Size="Size.Large" />
</MudItem> <MudStack Spacing="0">
<MudItem xs="12" sm="6" md="4"> <MudText Typo="Typo.subtitle1">عضویت در شبکه بازاریابی</MudText>
<MudPaper Elevation="0" Class="pa-3 rounded-lg" Style="background-color: var(--mud-palette-secondary-lighten);"> <MudText Typo="Typo.caption" Color="Color.Default">عضویت در شبکه بازاریابی و دریافت پورسانت</MudText>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2"> </MudStack>
<MudIcon Icon="@Icons.Material.Filled.Diamond" Color="Color.Secondary" Size="Size.Large" /> </MudStack>
<MudStack Spacing="0"> <MudDivider />
<MudText Typo="Typo.subtitle1" Color="Color.Secondary">امتیاز خرید</MudText> <MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="pa-2">
<MudText Typo="Typo.caption">برای هر خرید</MudText> <MudIcon Icon="@Icons.Material.Filled.GroupAdd" Color="Color.Secondary" Size="Size.Large" />
</MudStack> <MudStack Spacing="0">
</MudStack> <MudText Typo="Typo.subtitle1">جذب زیرمجموعه</MudText>
</MudPaper> <MudText Typo="Typo.caption" Color="Color.Default">امکان جذب زیرمجموعه و گسترش شبکه</MudText>
</MudItem> </MudStack>
<MudItem xs="12" sm="6" md="4"> </MudStack>
<MudPaper Elevation="0" Class="pa-3 rounded-lg" Style="background-color: var(--mud-palette-success-lighten);"> </MudStack>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.LocalShipping" Color="Color.Success" Size="Size.Large" />
<MudStack Spacing="0">
<MudText Typo="Typo.subtitle1" Color="Color.Success">ارسال رایگان</MudText>
<MudText Typo="Typo.caption">برای سفارشات بالای 500 هزار تومان</MudText>
</MudStack>
</MudStack>
</MudPaper>
</MudItem>
</MudGrid>
</MudPaper> </MudPaper>
<!-- فعال‌سازی یا تمدید عضویت --> <!-- فعال‌سازی یا تمدید عضویت -->
@@ -120,7 +123,7 @@
<MudAlert Severity="Severity.Error" Variant="Variant.Filled"> <MudAlert Severity="Severity.Error" Variant="Variant.Filled">
خطا در دریافت اطلاعات عضویت. لطفا دوباره تلاش کنید. خطا در دریافت اطلاعات عضویت. لطفا دوباره تلاش کنید.
</MudAlert> </MudAlert>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="LoadMembershipAsync">تلاش مجدد</MudButton> <MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="LoadDataAsync">تلاش مجدد</MudButton>
} }
</MudStack> </MudStack>
</MudContainer> </MudContainer>
@@ -7,23 +7,33 @@ namespace FrontOffice.Main.Pages.Club;
public partial class MembershipPage : ComponentBase public partial class MembershipPage : ComponentBase
{ {
[Inject] private ClubMembershipService ClubService { get; set; } = default!; [Inject] private ClubMembershipService ClubService { get; set; } = default!;
[Inject] private ClubConfigurationService ConfigService { get; set; } = default!;
private ClubMembershipDto? _membership; private ClubMembershipDto? _membership;
private ClubConfigDto? _clubConfig;
private bool _isLoading = true; private bool _isLoading = true;
private bool _hasError = false; private bool _hasError = false;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
await LoadMembershipAsync(); await LoadDataAsync();
} }
private async Task LoadMembershipAsync() private async Task LoadDataAsync()
{ {
try try
{ {
_isLoading = true; _isLoading = true;
_hasError = false; _hasError = false;
_membership = await ClubService.GetMyMembershipAsync();
// بارگذاری همزمان عضویت و تنظیمات
var membershipTask = ClubService.GetMyMembershipAsync();
var configTask = ConfigService.GetClubConfigurationAsync();
await Task.WhenAll(membershipTask, configTask);
_membership = await membershipTask;
_clubConfig = await configTask;
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -38,7 +48,7 @@ public partial class MembershipPage : ComponentBase
private async Task HandleActivationSuccess() private async Task HandleActivationSuccess()
{ {
await LoadMembershipAsync(); await LoadDataAsync();
Snackbar.Add("عضویت باشگاه با موفقیت فعال شد!", Severity.Success); Snackbar.Add("عضویت باشگاه با موفقیت فعال شد!", Severity.Success);
} }
@@ -58,7 +58,7 @@
</HeaderContent> </HeaderContent>
<RowTemplate> <RowTemplate>
<MudTd> <MudTd>
<MudChip T="string" Color="Color.Default" Size="Size.Small">@context.WeekLabel</MudChip> <MudChip T="string" Color="Color.Default" Size="Size.Small">@context.WeekDisplayName</MudChip>
</MudTd> </MudTd>
<MudTd>@context.BalancesEarned</MudTd> <MudTd>@context.BalancesEarned</MudTd>
<MudTd> <MudTd>
@@ -75,7 +75,7 @@
Variant="Variant.Text" Variant="Variant.Text"
Color="Color.Info" Color="Color.Info"
StartIcon="@Icons.Material.Filled.Info" StartIcon="@Icons.Material.Filled.Info"
Href="@($"{RouteConstants.Commission.WeeklyBalance}?week={context.WeekNumber}")"> Href="@($"{RouteConstants.Commission.WeeklyBalance}?week={context.WeekDefinitionId}")">
جزئیات جزئیات
</MudButton> </MudButton>
</MudTd> </MudTd>
@@ -90,7 +90,7 @@
<MudPaper Class="pa-3 rounded-lg" Outlined="true"> <MudPaper Class="pa-3 rounded-lg" Outlined="true">
<MudStack Spacing="1"> <MudStack Spacing="1">
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center"> <MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudChip T="string" Color="Color.Default" Size="Size.Small">@payout.WeekLabel</MudChip> <MudChip T="string" Color="Color.Default" Size="Size.Small">@payout.WeekDisplayName</MudChip>
<MudChip T="string" Color="@GetStatusColor(payout.StatusBadgeColor)" Size="Size.Small" Variant="Variant.Outlined"> <MudChip T="string" Color="@GetStatusColor(payout.StatusBadgeColor)" Size="Size.Small" Variant="Variant.Outlined">
@payout.Status @payout.Status
</MudChip> </MudChip>
@@ -103,7 +103,7 @@
Color="Color.Info" Color="Color.Info"
FullWidth="true" FullWidth="true"
StartIcon="@Icons.Material.Filled.Info" StartIcon="@Icons.Material.Filled.Info"
Href="@($"{RouteConstants.Commission.WeeklyBalance}?week={payout.WeekNumber}")"> Href="@($"{RouteConstants.Commission.WeeklyBalance}?week={payout.WeekDefinitionId}")">
مشاهده جزئیات هفته مشاهده جزئیات هفته
</MudButton> </MudButton>
</MudStack> </MudStack>
@@ -52,7 +52,7 @@
</HeaderContent> </HeaderContent>
<RowTemplate> <RowTemplate>
<MudTd DataLabel="هفته"> <MudTd DataLabel="هفته">
<MudChip T="string" Color="Color.Primary" Size="Size.Small" Variant="Variant.Outlined">@context.WeekLabel</MudChip> <MudChip T="string" Color="Color.Primary" Size="Size.Small" Variant="Variant.Outlined">@context.WeekDisplayName</MudChip>
</MudTd> </MudTd>
<MudTd DataLabel="تعادل‌ها"> <MudTd DataLabel="تعادل‌ها">
<MudText>@context.BalancesEarned</MudText> <MudText>@context.BalancesEarned</MudText>
@@ -73,7 +73,7 @@
Variant="Variant.Text" Variant="Variant.Text"
Color="Color.Info" Color="Color.Info"
StartIcon="@Icons.Material.Filled.Visibility" StartIcon="@Icons.Material.Filled.Visibility"
Href="@($"{RouteConstants.Commission.WeeklyBalance}?week={context.WeekNumber}")"> Href="@($"{RouteConstants.Commission.WeeklyBalance}?week={context.WeekDefinitionId}")">
مشاهده مشاهده
</MudButton> </MudButton>
</MudTd> </MudTd>
@@ -1,5 +1,6 @@
@attribute [Route(RouteConstants.Commission.WeeklyBalance)] @attribute [Route(RouteConstants.Commission.WeeklyBalance)]
@using FrontOffice.Main.Utilities @using FrontOffice.Main.Utilities
@using FrontOffice.Main.Shared
@using MudBlazor @using MudBlazor
<PageTitle>تعادل هفتگی</PageTitle> <PageTitle>تعادل هفتگی</PageTitle>
@@ -13,20 +14,38 @@
<!-- انتخاب هفته --> <!-- انتخاب هفته -->
<MudPaper Elevation="2" Class="pa-3 rounded-lg"> <MudPaper Elevation="2" Class="pa-3 rounded-lg">
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.End"> <MudGrid Spacing="2">
<MudNumericField @bind-Value="_selectedWeekNumber" <MudItem xs="8" sm="6" md="6">
Label="شماره هفته" <WeekSelector @ref="_weekSelector"
Variant="Variant.Outlined" @bind-Value="_selectedWeekDefinition"
Min="1" Label="انتخاب هفته"
HelperText="هفته مورد نظر را انتخاب کنید (خالی = هفته جاری)" Placeholder="هفته را جستجو یا انتخاب کنید"
Style="max-width: 200px;" /> Variant="Variant.Outlined"
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="LoadWeeklyBalanceAsync" StartIcon="@Icons.Material.Filled.Search"> Dense="false"
مشاهده OnlyActive="true" />
</MudButton> </MudItem>
<MudButton Variant="Variant.Outlined" OnClick="LoadCurrentWeekAsync" StartIcon="@Icons.Material.Filled.Today"> @* <MudItem xs="4" sm="3" md="3"> *@
هفته جاری @* <MudButton Variant="Variant.Outlined" *@
</MudButton> @* OnClick="LoadCurrentWeekAsync" *@
</MudStack> @* StartIcon="@Icons.Material.Filled.Today" *@
@* FullWidth="true" *@
@* Style="height: 56px;"> *@
@* هفته جاری *@
@* </MudButton> *@
@* </MudItem> *@
<MudItem xs="4" sm="3" md="3" Class="pt-6">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
OnClick="LoadWeeklyBalanceAsync"
StartIcon="@Icons.Material.Filled.Search"
Disabled="@(_selectedWeekDefinition == null)"
FullWidth="true"
Style="height: 50px;">
جستجو
</MudButton>
</MudItem>
</MudGrid>
</MudPaper> </MudPaper>
@if (_isLoading) @if (_isLoading)
@@ -35,113 +54,89 @@
} }
else if (_weeklyBalance is not null) else if (_weeklyBalance is not null)
{ {
<!-- اطلاعات هفته --> <!-- اطلاعات هفته - کامپکت -->
<MudPaper Elevation="2" Class="pa-4 rounded-lg"> <MudPaper Elevation="2" Class="pa-3 rounded-lg">
<MudStack Spacing="2"> <MudGrid Spacing="1">
<MudText Typo="Typo.h6">هفته @_weeklyBalance.WeekNumber - @_weeklyBalance.WeekLabel</MudText> <MudItem xs="12" sm="4">
<MudDivider /> <MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<MudGrid Spacing="2"> <MudIcon Icon="@Icons.Material.Filled.DateRange" Size="Size.Small" Color="Color.Primary" />
<MudItem xs="12" sm="6"> <MudText Typo="Typo.body2"><strong>@_weeklyBalance.WeekDisplayName</strong></MudText>
<MudText Typo="Typo.subtitle2" Class="mud-text-secondary">تاریخ شروع:</MudText> </MudStack>
<MudText Typo="Typo.body1"><strong>@_weeklyBalance.StartDatePersian</strong></MudText> </MudItem>
</MudItem> <MudItem xs="6" sm="4">
<MudItem xs="12" sm="6"> <MudText Typo="Typo.caption" Class="mud-text-secondary">شروع: <strong>@_weeklyBalance.StartDatePersian</strong></MudText>
<MudText Typo="Typo.subtitle2" Class="mud-text-secondary">تاریخ پایان:</MudText> </MudItem>
<MudText Typo="Typo.body1"><strong>@_weeklyBalance.EndDatePersian</strong></MudText> <MudItem xs="6" sm="4">
</MudItem> <MudText Typo="Typo.caption" Class="mud-text-secondary">پایان: <strong>@_weeklyBalance.EndDatePersian</strong></MudText>
</MudGrid> </MudItem>
</MudStack> </MudGrid>
</MudPaper> </MudPaper>
<!-- تعادل چپ و راست --> <!-- تعادل چپ و راست - کارت‌های بزرگ -->
<MudGrid Spacing="2"> <MudGrid Spacing="2">
<MudItem xs="12" md="6"> <MudItem xs="6">
<MudPaper Elevation="2" Class="pa-4 rounded-lg" Style="height: 100%;"> <MudPaper Elevation="3" Class="pa-4 rounded-lg text-center" Style="background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%);">
<MudStack Spacing="2"> <MudStack Spacing="1" AlignItems="AlignItems.Center">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2"> <MudIcon Icon="@Icons.Material.Filled.ChevronLeft" Color="Color.Info" Size="Size.Large" />
<MudIcon Icon="@Icons.Material.Filled.ChevronLeft" Color="Color.Info" Size="Size.Large" /> <MudText Typo="Typo.subtitle2" Color="Color.Info">شاخه چپ</MudText>
<MudText Typo="Typo.h6" Color="Color.Info">شاخه چپ</MudText> <MudText Typo="Typo.h5" Color="Color.Info" Style="font-weight: bold;">@_weeklyBalance.LeftBalanceFormatted</MudText>
</MudStack>
<MudText Typo="Typo.h4" Color="Color.Info">@_weeklyBalance.LeftBalanceFormatted</MudText>
<MudProgressLinear Color="Color.Info" <MudProgressLinear Color="Color.Info"
Value="@GetLeftPercentage()" Value="@GetLeftPercentage()"
Size="Size.Large" Size="Size.Medium"
Class="rounded-lg" /> Class="rounded-lg mt-2"
Style="width: 100%;" />
<MudText Typo="Typo.caption" Class="mud-text-secondary"> <MudText Typo="Typo.caption" Class="mud-text-secondary">
@GetLeftPercentage().ToString("F1")% از کل تعادل @GetLeftPercentage().ToString("F1")% از کل
</MudText> </MudText>
</MudStack> </MudStack>
</MudPaper> </MudPaper>
</MudItem> </MudItem>
<MudItem xs="12" md="6"> <MudItem xs="6">
<MudPaper Elevation="2" Class="pa-4 rounded-lg" Style="height: 100%;"> <MudPaper Elevation="3" Class="pa-4 rounded-lg text-center" Style="background: linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%);">
<MudStack Spacing="2"> <MudStack Spacing="1" AlignItems="AlignItems.Center">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2"> <MudIcon Icon="@Icons.Material.Filled.ChevronRight" Color="Color.Success" Size="Size.Large" />
<MudIcon Icon="@Icons.Material.Filled.ChevronRight" Color="Color.Success" Size="Size.Large" /> <MudText Typo="Typo.subtitle2" Color="Color.Success">شاخه راست</MudText>
<MudText Typo="Typo.h6" Color="Color.Success">شاخه راست</MudText> <MudText Typo="Typo.h5" Color="Color.Success" Style="font-weight: bold;">@_weeklyBalance.RightBalanceFormatted</MudText>
</MudStack>
<MudText Typo="Typo.h4" Color="Color.Success">@_weeklyBalance.RightBalanceFormatted</MudText>
<MudProgressLinear Color="Color.Success" <MudProgressLinear Color="Color.Success"
Value="@GetRightPercentage()" Value="@GetRightPercentage()"
Size="Size.Large" Size="Size.Medium"
Class="rounded-lg" /> Class="rounded-lg mt-2"
Style="width: 100%;" />
<MudText Typo="Typo.caption" Class="mud-text-secondary"> <MudText Typo="Typo.caption" Class="mud-text-secondary">
@GetRightPercentage().ToString("F1")% از کل تعادل @GetRightPercentage().ToString("F1")% از کل
</MudText> </MudText>
</MudStack> </MudStack>
</MudPaper> </MudPaper>
</MudItem> </MudItem>
</MudGrid> </MudGrid>
<!-- محاسبات کمیسیون --> <!-- محاسبات کمیسیون - فشرده‌تر -->
<MudPaper Elevation="2" Class="pa-4 rounded-lg"> <MudPaper Elevation="2" Class="pa-4 rounded-lg">
<MudText Typo="Typo.h6" Class="mb-3">محاسبات کمیسیون</MudText> <MudText Typo="Typo.subtitle1" Class="mb-2" Style="font-weight: bold;">محاسبات کمیسیون</MudText>
<MudStack Spacing="2"> <MudSimpleTable Dense="true" Hover="true" Style="background: transparent;">
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center"> <tbody>
<MudText Typo="Typo.body1">تعادل چپ:</MudText> <tr>
<MudText Typo="Typo.body1" Color="Color.Info"><strong>@_weeklyBalance.LeftBalanceFormatted</strong></MudText> <td><MudText Typo="Typo.body2">حداقل تعادل (پایه کمیسیون):</MudText></td>
</MudStack> <td class="text-end"><MudText Typo="Typo.body1" Color="Color.Warning"><strong>@_weeklyBalance.MinBalanceFormatted</strong></MudText></td>
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center"> </tr>
<MudText Typo="Typo.body1">تعادل راست:</MudText> <tr>
<MudText Typo="Typo.body1" Color="Color.Success"><strong>@_weeklyBalance.RightBalanceFormatted</strong></MudText> <td><MudText Typo="Typo.body2">تعداد تعادل (دفعات):</MudText></td>
</MudStack> <td class="text-end"><MudText Typo="Typo.body1" Color="Color.Secondary"><strong>@_weeklyBalance.BalanceCount</strong></MudText></td>
<MudDivider /> </tr>
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center"> <tr style="background-color: var(--mud-palette-primary-lighten);">
<MudText Typo="Typo.body1">حداقل تعادل (پایه کمیسیون):</MudText> <td><MudText Typo="Typo.subtitle1"><strong>کمیسیون محاسبه شده:</strong></MudText></td>
<MudText Typo="Typo.h6" Color="Color.Warning"><strong>@_weeklyBalance.MinBalanceFormatted</strong></MudText> <td class="text-end"><MudText Typo="Typo.h6" Color="Color.Primary"><strong>@_weeklyBalance.CalculatedCommissionFormatted</strong></MudText></td>
</MudStack> </tr>
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center"> </tbody>
<MudText Typo="Typo.body1">تعداد تعادل (دفعات):</MudText> </MudSimpleTable>
<MudText Typo="Typo.h6" Color="Color.Secondary"><strong>@_weeklyBalance.BalanceCount</strong></MudText>
</MudStack>
<MudDivider />
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.h6">کمیسیون محاسبه شده:</MudText>
<MudText Typo="Typo.h5" Color="Color.Primary"><strong>@_weeklyBalance.CalculatedCommissionFormatted</strong></MudText>
</MudStack>
@if (_weeklyBalance.LeftCarryover > 0 || _weeklyBalance.RightCarryover > 0) @if (_weeklyBalance.LeftCarryover > 0 || _weeklyBalance.RightCarryover > 0)
{ {
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined"> <MudAlert Severity="Severity.Info" Variant="Variant.Text" Dense="true" Class="mt-2">
<MudText Typo="Typo.body2"> <strong>انتقال به هفته بعد:</strong> چپ: @_weeklyBalance.LeftCarryoverFormatted | راست: @_weeklyBalance.RightCarryoverFormatted
<strong>Carryover (انتقال به هفته بعد):</strong><br /> </MudAlert>
چپ: @_weeklyBalance.LeftCarryoverFormatted | راست: @_weeklyBalance.RightCarryoverFormatted }
</MudText>
</MudAlert>
}
</MudStack>
</MudPaper>
<!-- نمودار مقایسه -->
<MudPaper Elevation="2" Class="pa-4 rounded-lg">
<MudText Typo="Typo.h6" Class="mb-3">نمودار مقایسه</MudText>
<MudChart ChartType="ChartType.Bar"
Width="100%"
Height="300px"
InputData="@(new double[] { _weeklyBalance.LeftBalance, _weeklyBalance.RightBalance, _weeklyBalance.MinBalance })"
InputLabels="@(new[] { "چپ", "راست", "حداقل" })"
ChartOptions="@_chartOptions" />
</MudPaper> </MudPaper>
} }
else if (_hasError) else if (_hasError)
@@ -1,4 +1,5 @@
using FrontOffice.Main.Utilities; using FrontOffice.Main.Utilities;
using FrontOffice.Main.Shared;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using MudBlazor; using MudBlazor;
@@ -7,13 +8,15 @@ namespace FrontOffice.Main.Pages.Commission;
public partial class WeeklyBalancePage : ComponentBase public partial class WeeklyBalancePage : ComponentBase
{ {
[Inject] private CommissionService CommissionService { get; set; } = default!; [Inject] private CommissionService CommissionService { get; set; } = default!;
[Inject] private ISnackbar SnackbarService { get; set; } = default!;
[Parameter] [Parameter]
[SupplyParameterFromQuery(Name = "week")] [SupplyParameterFromQuery(Name = "week")]
public int? QueryWeekNumber { get; set; } public string? QueryWeekNumber { get; set; }
private WeekSelector? _weekSelector;
private WeekDefinitionDto? _selectedWeekDefinition;
private WeeklyBalanceDto? _weeklyBalance; private WeeklyBalanceDto? _weeklyBalance;
private int? _selectedWeekNumber;
private bool _isLoading = true; private bool _isLoading = true;
private bool _hasError = false; private bool _hasError = false;
@@ -27,12 +30,44 @@ public partial class WeeklyBalancePage : ComponentBase
} }
}; };
protected override async Task OnInitializedAsync() protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
if (QueryWeekNumber.HasValue) if (firstRender)
_selectedWeekNumber = QueryWeekNumber; {
// Wait for WeekSelector to initialize and load its cached weeks
if (_weekSelector != null)
{
await _weekSelector.EnsureLoadedAsync();
// Handle query parameter ?week=46 (WeekOrder) or ?week=123 (Id)
if (!string.IsNullOrEmpty(QueryWeekNumber))
{
// Try to parse as integer (first try as WeekOrder, then as Id)
if (int.TryParse(QueryWeekNumber, out int weekOrder))
{
_selectedWeekDefinition = _weekSelector.FindByWeekOrder(weekOrder);
if (_selectedWeekDefinition == null && long.TryParse(QueryWeekNumber, out long id))
{
_selectedWeekDefinition = _weekSelector.FindById(id);
}
}
}
// If no week selected and no query param, select current week
if (_selectedWeekDefinition == null)
{
_selectedWeekDefinition = _weekSelector.GetCurrentWeek();
}
StateHasChanged();
await LoadWeeklyBalanceAsync();
}
}
}
await LoadWeeklyBalanceAsync(); protected override void OnInitialized()
{
_isLoading = true;
} }
private async Task LoadWeeklyBalanceAsync() private async Task LoadWeeklyBalanceAsync()
@@ -41,12 +76,27 @@ public partial class WeeklyBalancePage : ComponentBase
{ {
_isLoading = true; _isLoading = true;
_hasError = false; _hasError = false;
_weeklyBalance = await CommissionService.GetMyWeeklyBalanceAsync(_selectedWeekNumber);
long? weekDefinitionId = _selectedWeekDefinition?.Id;
_weeklyBalance = await CommissionService.GetMyWeeklyBalanceAsync(weekDefinitionId);
// Update week display name from selected definition if available
if (_selectedWeekDefinition != null && _weeklyBalance != null)
{
_weeklyBalance.WeekDisplayName = _selectedWeekDefinition.DisplayName;
return;
}
_weeklyBalance = new WeeklyBalanceDto()
{
};
} }
catch (Exception ex) catch (Exception ex)
{ {
_hasError = true; _hasError = true;
Snackbar.Add($"خطا در دریافت تعادل: {ex.Message}", Severity.Error); SnackbarService.Add($"خطا در دریافت تعادل: {ex.Message}", Severity.Error);
} }
finally finally
{ {
@@ -56,7 +106,11 @@ public partial class WeeklyBalancePage : ComponentBase
private async Task LoadCurrentWeekAsync() private async Task LoadCurrentWeekAsync()
{ {
_selectedWeekNumber = null; if (_weekSelector != null)
{
await _weekSelector.SelectCurrentWeekAsync();
}
_selectedWeekDefinition = _weekSelector?.GetCurrentWeek();
await LoadWeeklyBalanceAsync(); await LoadWeeklyBalanceAsync();
} }
+32 -32
View File
@@ -21,7 +21,7 @@ public partial class Index:IDisposable
{ {
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
}; };
await LoadPackagesAsync(); // await LoadPackagesAsync();
//string mobileNumber = "09387342688"; //string mobileNumber = "09387342688";
@@ -50,37 +50,37 @@ public partial class Index:IDisposable
await base.OnAfterRenderAsync(firstRender); await base.OnAfterRenderAsync(firstRender);
} }
private async Task LoadPackagesAsync() // private async Task LoadPackagesAsync()
{ // {
_isLoadingPackages = true; // _isLoadingPackages = true;
try // try
{ // {
var response = await PackageClient.GetAllPackageByFilterAsync(request: new()); // var response = await PackageClient.GetAllPackageByFilterAsync(request: new());
if (response?.Models?.Any() == true) // if (response?.Models?.Any() == true)
{ // {
_packs = response.Models.Select(p => new Pack( // _packs = response.Models.Select(p => new Pack(
Id: p.Id, // Id: p.Id,
Title: p.Title, // Title: p.Title,
Body: p.Description, // Body: p.Description,
Image: UrlUtility.DownloadUrl + p.ImagePath, // Image: UrlUtility.DownloadUrl + p.ImagePath,
Price: p.Price // Price: p.Price
)).ToList(); // )).ToList();
} // }
else // else
_packs = new List<Pack>(); // _packs = new List<Pack>();
} // }
catch (Exception ex) // catch (Exception ex)
{ // {
Snackbar.Add($"خطا در بارگذاری پکیج‌ها: {ex.Message}", Severity.Error); // Snackbar.Add($"خطا در بارگذاری پکیج‌ها: {ex.Message}", Severity.Error);
// Fallback to empty list // // Fallback to empty list
_packs = new List<Pack>(); // _packs = new List<Pack>();
} // }
finally // finally
{ // {
_isLoadingPackages = false; // _isLoadingPackages = false;
await InvokeAsync(StateHasChanged); // await InvokeAsync(StateHasChanged);
} // }
} // }
private void JoinWaitlist() private void JoinWaitlist()
{ {
@@ -40,7 +40,7 @@ public partial class Addresses : ComponentBase
{ {
var dialog = await DialogService.ShowAsync<AddAddressDialog>("افزودن آدرس جدید"); var dialog = await DialogService.ShowAsync<AddAddressDialog>("افزودن آدرس جدید");
var result = await dialog.Result; var result = await dialog.Result;
if (!result.Canceled) if (result is not null && !result.Canceled)
await LoadAddresses(); await LoadAddresses();
} }
@@ -51,7 +51,7 @@ public partial class Addresses : ComponentBase
{ x => x.Model, address } { x => x.Model, address }
}); });
var result = await dialog.Result; var result = await dialog.Result;
if (!result.Canceled) if (result is not null && !result.Canceled)
await LoadAddresses(); await LoadAddresses();
} }
@@ -1,5 +1,5 @@
<MudDialog> <MudDialog >
<TitleContent> <TitleContent>
<MudText Typo="Typo.h4" Align="Align.Center">افزودن آدرس جدید</MudText> <MudText Typo="Typo.h4" Align="Align.Center">افزودن آدرس جدید</MudText>
</TitleContent> </TitleContent>
@@ -29,13 +29,34 @@
Required="true" Required="true"
RequiredError="کد پستی الزامی است." /> RequiredError="کد پستی الزامی است." />
<MudTextField @bind-Value="_request.CityId" <MudAutocomplete T="CityProto.GetAllCitiesByFilterResponseModel"
For="@(() => _request.CityId)" @bind-Value="_selectedCity"
Label="شناسه شهر" Label="شهر"
Variant="Variant.Outlined" Variant="Variant.Outlined"
InputType="InputType.Number" SearchFunc="SearchCities"
Required="true" ToStringFunc="@(city => city != null ? $"{city.Native} ({city.StateName})" : string.Empty)"
RequiredError="شهر الزامی است." /> Required="true"
RequiredError="شهر الزامی است."
Clearable="true"
ResetValueOnEmptyText="true"
CoerceText="false"
CoerceValue="false"
Dense="true"
DebounceInterval="300"
MaxItems="20"
MinCharacters="2"
ProgressIndicatorColor="Color.Primary">
<ItemTemplate Context="city">
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center">
<MudIcon Icon="@Icons.Material.Filled.LocationCity" Size="Size.Small" />
<MudText>@city.Native</MudText>
<MudText Typo="Typo.caption" Class="mud-text-secondary">(@city.StateName)</MudText>
</MudStack>
</ItemTemplate>
<NoItemsTemplate>
<MudText Typo="Typo.body2" Class="mud-text-secondary pa-2">شهری یافت نشد</MudText>
</NoItemsTemplate>
</MudAutocomplete>
</MudStack> </MudStack>
</MudForm> </MudForm>
</DialogContent> </DialogContent>
@@ -3,26 +3,57 @@ using FrontOffice.BFF.UserAddress.Protobuf.Validator;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using MudBlazor; using MudBlazor;
using Severity = MudBlazor.Severity; using Severity = MudBlazor.Severity;
using CityProto = FrontOffice.BFF.City.Protobuf;
namespace FrontOffice.Main.Pages.Profile.Components; namespace FrontOffice.Main.Pages.Profile.Components;
public partial class AddAddressDialog : ComponentBase public partial class AddAddressDialog : ComponentBase
{ {
[CascadingParameter] private IDialogReference MudDialog { get; set; } = default!; [CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = default!;
[Inject] private UserAddressContract.UserAddressContractClient UserAddressContract { get; set; } = default!; [Inject] private UserAddressContract.UserAddressContractClient UserAddressContract { get; set; } = default!;
[Inject] private CityProto.CityContract.CityContractClient CityContract { get; set; } = default!;
private MudForm? _form; private MudForm? _form;
private readonly CreateNewUserAddressRequestValidator _validator = new(); private readonly CreateNewUserAddressRequestValidator _validator = new();
private bool _isSaving; private bool _isSaving;
private CreateNewUserAddressRequest _request = new(); private CreateNewUserAddressRequest _request = new();
private CityProto.GetAllCitiesByFilterResponseModel? _selectedCity;
private async Task<IEnumerable<CityProto.GetAllCitiesByFilterResponseModel>> SearchCities(string value,
CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(value) || value.Length < 2)
return Enumerable.Empty<CityProto.GetAllCitiesByFilterResponseModel>();
try
{
var response = await CityContract.GetAllCitiesByFilterAsync(new CityProto.GetAllCitiesByFilterRequest
{
PaginationState = new CityProto.PaginationState { PageNumber = 1, PageSize = 20 },
Filter = new CityProto.GetAllCitiesByFilterFilter { Name = value }
}, cancellationToken: ct);
return response?.Models?.ToList() ?? new List<CityProto.GetAllCitiesByFilterResponseModel>();
}
catch
{
return Enumerable.Empty<CityProto.GetAllCitiesByFilterResponseModel>();
}
}
private async Task SaveAddress() private async Task SaveAddress()
{ {
if (_form == null) return; if (_form == null) return;
await _form.Validate(); await _form.Validate();
if (!_form.IsValid) return; if (!_form.IsValid || _selectedCity == null)
{
if (_selectedCity == null)
Snackbar.Add("لطفاً شهر را انتخاب کنید.", Severity.Warning);
return;
}
_request.CityId = _selectedCity.Id;
_isSaving = true; _isSaving = true;
try try
{ {
@@ -41,5 +72,6 @@ public partial class AddAddressDialog : ComponentBase
} }
} }
private void Cancel() => MudDialog.Close(DialogResult.Cancel()); private void Cancel() => MudDialog.Close(DialogResult.Cancel());
} }
@@ -28,13 +28,34 @@
Required="true" Required="true"
RequiredError="کد پستی الزامی است." /> RequiredError="کد پستی الزامی است." />
<MudTextField @bind-Value="_request.CityId" <MudAutocomplete T="CityProto.GetAllCitiesByFilterResponseModel"
For="@(() => _request.CityId)" @bind-Value="_selectedCity"
Label="شناسه شهر" Label="شهر"
Variant="Variant.Outlined" Variant="Variant.Outlined"
InputType="InputType.Number" SearchFunc="SearchCities"
Required="true" ToStringFunc="@(city => city != null ? $"{city.Native} ({city.StateName})" : string.Empty)"
RequiredError="شهر الزامی است." /> Required="true"
RequiredError="شهر الزامی است."
Clearable="true"
ResetValueOnEmptyText="true"
CoerceText="false"
CoerceValue="false"
Dense="true"
DebounceInterval="300"
MaxItems="20"
MinCharacters="2"
ProgressIndicatorColor="Color.Primary">
<ItemTemplate Context="city">
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center">
<MudIcon Icon="@Icons.Material.Filled.LocationCity" Size="Size.Small" />
<MudText>@city.Native</MudText>
<MudText Typo="Typo.caption" Class="mud-text-secondary">(@city.StateName)</MudText>
</MudStack>
</ItemTemplate>
<NoItemsTemplate>
<MudText Typo="Typo.body2" Class="mud-text-secondary pa-2">شهری یافت نشد</MudText>
</NoItemsTemplate>
</MudAutocomplete>
</MudStack> </MudStack>
</MudForm> </MudForm>
</DialogContent> </DialogContent>
@@ -4,14 +4,15 @@ using Mapster;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using MudBlazor; using MudBlazor;
using Severity = MudBlazor.Severity; using Severity = MudBlazor.Severity;
using CityProto = FrontOffice.BFF.City.Protobuf;
namespace FrontOffice.Main.Pages.Profile.Components; namespace FrontOffice.Main.Pages.Profile.Components;
public partial class EditAddressDialog : ComponentBase public partial class EditAddressDialog : ComponentBase
{ {
[CascadingParameter] private IDialogReference MudDialog { get; set; } = default!; // updated type [CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = default!;
[Inject] private UserAddressContract.UserAddressContractClient UserAddressContract { get; set; } = default!; [Inject] private UserAddressContract.UserAddressContractClient UserAddressContract { get; set; } = default!;
// removed duplicate Snackbar injection; provided by Razor partial via _Imports [Inject] private CityProto.CityContract.CityContractClient CityContract { get; set; } = default!;
[Parameter] public GetAllUserAddressByFilterResponseModel? Model { get; set; } [Parameter] public GetAllUserAddressByFilterResponseModel? Model { get; set; }
@@ -19,12 +20,60 @@ public partial class EditAddressDialog : ComponentBase
private readonly UpdateUserAddressRequestValidator _validator = new(); private readonly UpdateUserAddressRequestValidator _validator = new();
private bool _isSaving; private bool _isSaving;
private UpdateUserAddressRequest _request = new(); private UpdateUserAddressRequest _request = new();
private CityProto.GetAllCitiesByFilterResponseModel? _selectedCity;
protected override void OnInitialized() protected override async Task OnInitializedAsync()
{ {
base.OnInitialized(); await base.OnInitializedAsync();
if (Model != null) if (Model != null)
{
_request = Model.Adapt<UpdateUserAddressRequest>(); _request = Model.Adapt<UpdateUserAddressRequest>();
// Load selected city if CityId exists
if (Model.CityId > 0)
{
await LoadCurrentCity(Model.CityId);
}
}
}
private async Task LoadCurrentCity(long cityId)
{
try
{
var response = await CityContract.GetAllCitiesByFilterAsync(new CityProto.GetAllCitiesByFilterRequest
{
PaginationState = new CityProto.PaginationState { PageNumber = 1, PageSize = 1 },
Filter = new CityProto.GetAllCitiesByFilterFilter { Id = cityId }
});
_selectedCity = response?.Models?.FirstOrDefault();
}
catch
{
// Ignore error loading city
}
}
private async Task<IEnumerable<CityProto.GetAllCitiesByFilterResponseModel>> SearchCities(string value, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(value) || value.Length < 2)
return Enumerable.Empty<CityProto.GetAllCitiesByFilterResponseModel>();
try
{
var response = await CityContract.GetAllCitiesByFilterAsync(new CityProto.GetAllCitiesByFilterRequest
{
PaginationState = new CityProto.PaginationState { PageNumber = 1, PageSize = 20 },
Filter = new CityProto.GetAllCitiesByFilterFilter { Native = value }
}, cancellationToken: ct);
return response?.Models?.ToList() ?? new List<CityProto.GetAllCitiesByFilterResponseModel>();
}
catch
{
return Enumerable.Empty<CityProto.GetAllCitiesByFilterResponseModel>();
}
} }
private async Task SaveAddress() private async Task SaveAddress()
@@ -32,8 +81,14 @@ public partial class EditAddressDialog : ComponentBase
if (_form == null) return; if (_form == null) return;
await _form.Validate(); await _form.Validate();
if (!_form.IsValid) return; if (!_form.IsValid || _selectedCity == null)
{
if (_selectedCity == null)
Snackbar.Add("لطفاً شهر را انتخاب کنید.", Severity.Warning);
return;
}
_request.CityId = _selectedCity.Id;
_isSaving = true; _isSaving = true;
try try
{ {
@@ -1,62 +1,82 @@
@if (_currentUser != null) @implements IAsyncDisposable
{
<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>
}
@if (_currentUser?.Children?.Any() == true) <div class="org-chart-wrapper">
{ @* Toolbar *@
<button class="expand-btn" @onclick="ToggleExpand" data-user-id="@_currentUser.Id"> <div class="org-chart-toolbar">
<MudIcon Icon="@(_isExpanded ? Icons.Material.Filled.Remove : Icons.Material.Filled.Add)" Size="Size.Small" />
</button>
} <MudSelect T="int" @bind-Value="_selectedDepth" Label="عمق" Variant="Variant.Outlined"
</div> Dense="true" Margin="Margin.Dense" Style="width: 90px; min-width: 90px;">
<div class="node-info"> <MudSelectItem Value="2">2 سطح</MudSelectItem>
@if (!string.IsNullOrWhiteSpace(_currentUser.FirstName) || !string.IsNullOrWhiteSpace(_currentUser.LastName)) <MudSelectItem Value="3">3 سطح</MudSelectItem>
{ <MudSelectItem Value="4">4 سطح</MudSelectItem>
<div class="node-name">@_currentUser.FirstName @_currentUser.LastName</div> <MudSelectItem Value="5">5 سطح</MudSelectItem>
} <MudSelectItem Value="6">6 سطح</MudSelectItem>
else <MudSelectItem Value="15">همه</MudSelectItem>
{ </MudSelect>
<div class="node-name">@_currentUser.Mobile</div>
} <MudButtonGroup Variant="Variant.Outlined" Size="Size.Small" OverrideStyles="false">
@* <div class="node-amounts"> <MudIconButton Icon="@Icons.Material.Filled.UnfoldMore" OnClick="ExpandAll" Title="باز کردن همه" Size="Size.Small" />
<div class="personal-amount"> <MudIconButton Icon="@Icons.Material.Filled.UnfoldLess" OnClick="CollapseAll" Title="بستن همه" Size="Size.Small" />
<span class="label">خرید شخصی:</span> <MudIconButton Icon="@Icons.Material.Filled.CenterFocusStrong" OnClick="CenterChart" Title="مرکز" Size="Size.Small" />
<span class="amount">@(_currentUser?.PersonalPurchase?.ToThousands().ToCurrencyUnitIRT() ?? "0 تومان")</span> <MudIconButton Icon="@Icons.Material.Filled.ZoomOutMap" OnClick="FitToScreen" Title="نمایش کامل" Size="Size.Small" />
</div> <MudIconButton Icon="@Icons.Material.Filled.Refresh" OnClick="RefreshData" Color="Color.Primary" Title="بروزرسانی" Size="Size.Small" />
<div class="team-amount"> </MudButtonGroup>
<span class="label">خرید تیمی:</span> @if (_currentViewUserId.HasValue)
<span class="amount">@(_currentUser?.TeamPurchase?.ToThousands().ToCurrencyUnitIRT() ?? "0 تومان")</span> {
</div> <MudButtonGroup Variant="Variant.Outlined" Size="Size.Small" OverrideStyles="false">
</div> *@ <MudIconButton Icon="@Icons.Material.Filled.ArrowForward" OnClick="GoBack" Title="بازگشت" Size="Size.Small" Color="Color.Secondary" />
</div> <MudIconButton Icon="@Icons.Material.Filled.Home" OnClick="GoToMyTree" Title="درخت من" Size="Size.Small" Color="Color.Secondary" />
</div> </MudButtonGroup>
</div> }
@if (_isExpanded && _currentUser?.Children?.Any() == true)
{
<CascadingValue Value="this">
<OrganizationChartLevel Nodes="_currentUser.Children" Level="1" />
</CascadingValue>
}
</div>
</div> </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 FrontOffice.Main.Utilities;
using Mapster;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using static FrontOffice.Main.Pages.Profile.Components.OrganizationChartLevel; using Microsoft.JSInterop;
namespace FrontOffice.Main.Pages.Profile.Components; namespace FrontOffice.Main.Pages.Profile.Components;
public partial class OrganizationChart
{
private UserNode? _currentUser;
private bool _isExpanded;
[Inject] private UserContract.UserContractClient UserContract { get; set; } = default!; public partial class OrganizationChart : IAsyncDisposable
private GetUserResponse _userProfile = new(); {
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() 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 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) catch (Exception ex)
{ {
// Handle the case when user is not authenticated or API fails Console.WriteLine($"Error loading network data: {ex.Message}");
_userProfile = new GetUserResponse(); _hasError = true;
} }
StateHasChanged(); finally
}
private async Task LoadCurrentUser()
{
await LoadUserProfile();
// Mock data - replace with actual API call
_currentUser = new UserNode
{ {
Id = _userProfile.Id, _isLoading = false;
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;
StateHasChanged(); StateHasChanged();
} }
} }
private UserNode? FindNode(UserNode? node, long userId) private async Task InitializeChart()
{ {
if (node == null) return null; if (_networkTree?.RootNode == null) return;
if (node.Id == userId) return node;
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); await InitializeChart();
if (found != null) return found;
} }
} }
}
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; using FrontOffice.Main.Utilities;
namespace FrontOffice.Main.Pages.Profile.Components; 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 public partial class OrganizationChartLevel
{ {
[Parameter] public List<UserNode>? Nodes { get; set; } [Parameter] public List<UserNode>? Nodes { get; set; }
@@ -25,18 +31,12 @@ public partial class OrganizationChartLevel
private void ToggleNodeExpand(long userId) private void ToggleNodeExpand(long userId)
{ {
if (ParentChart != null) // No longer needed - d3-org-chart handles this internally
{
ParentChart.ToggleNodeExpand(userId);
}
} }
private void SafeToggleNodeExpand(long userId) private void SafeToggleNodeExpand(long userId)
{ {
if (ParentChart != null) // No longer needed - d3-org-chart handles this internally
{
ParentChart.ToggleNodeExpand(userId);
}
} }
private async Task CopyReferralCode(string referralCode) private async Task CopyReferralCode(string referralCode)
@@ -192,6 +192,17 @@
</MudCard> </MudCard>
</MudLink> </MudLink>
</MudItem> </MudItem>
<MudItem xs="6" sm="6" md="3">
<MudLink Href="@RouteConstants.Profile.WithdrawalRequests" Underline="Underline.None" Class="tile-link">
<MudCard Elevation="1" Class="rounded-lg profile-tile">
<MudCardContent Class="d-flex flex-column align-center pa-4">
<MudIcon Icon="@Icons.Material.Filled.RequestPage" Size="Size.Large" Color="Color.Warning" />
<MudText Typo="Typo.subtitle1" Class="mt-2">درخواست برداشت</MudText>
<MudText Typo="Typo.caption" Class="mud-text-secondary">ثبت و پیگیری برداشت</MudText>
</MudCardContent>
</MudCard>
</MudLink>
</MudItem>
<MudItem xs="6" sm="6" md="3"> <MudItem xs="6" sm="6" md="3">
<MudLink Href="@RouteConstants.Club.Membership" Underline="Underline.None" Class="tile-link"> <MudLink Href="@RouteConstants.Club.Membership" Underline="Underline.None" Class="tile-link">
<MudCard Elevation="1" Class="rounded-lg profile-tile"> <MudCard Elevation="1" Class="rounded-lg profile-tile">
@@ -56,10 +56,16 @@ public partial class Index
await base.OnAfterRenderAsync(firstRender); await base.OnAfterRenderAsync(firstRender);
if (firstRender) if (firstRender)
{ {
// Refresh token to get latest user claims
await AuthService.RefreshTokenAsync();
await LoadUserAuthInfo(); await LoadUserAuthInfo();
await LoadUserProfile(); await LoadUserProfile();
await LoadAddresses(); await LoadAddresses();
await LoadWallet(); await LoadWallet();
// نمایش Modal قرارداد باشگاه اگر پکیج خریده ولی باشگاه فعال نشده
await CheckAndShowClubContractModal();
} }
} }
@@ -78,6 +84,39 @@ public partial class Index
} }
StateHasChanged(); StateHasChanged();
} }
/// <summary>
/// بررسی و نمایش Modal قرارداد باشگاه مشتریان
/// اگر کاربر پکیج خریده ولی هنوز قرارداد باشگاه را امضا نکرده
/// </summary>
private async Task CheckAndShowClubContractModal()
{
// اگر پکیج خریده ولی باشگاه فعال نیست = باید قرارداد امضا کند
if (_hasPurchasedPackage && !_isClubMemberActive)
{
var options = new DialogOptions
{
BackdropClick = false, // غیرقابل بسته شدن با کلیک بیرون
CloseOnEscapeKey = false, // غیرقابل بسته شدن با Escape
CloseButton = false, // بدون دکمه بستن
MaxWidth = MaxWidth.Medium,
FullWidth = true
};
var dialog = await DialogService.ShowAsync<Shared.ClubMembershipContractDialog>(
"قرارداد باشگاه مشتریان",
options);
var result = await dialog.Result;
// اگر موفق بود، صفحه را refresh کنیم
if (!result.Canceled)
{
await LoadUserAuthInfo();
StateHasChanged();
}
}
}
private string _walletCredit = "-"; private string _walletCredit = "-";
private string _walletDiscount = "-"; private string _walletDiscount = "-";
+38 -103
View File
@@ -17,14 +17,9 @@
</MudPaper> </MudPaper>
</MudItem> </MudItem>
<MudItem xs="12" sm="6" md="4"> <MudItem xs="12" sm="6" md="4">
<MudPaper Elevation="2" Class="pa-4 rounded-lg" Style="background-color: var(--mud-palette-warning-lighten);"> <MudPaper Elevation="2" Class="pa-4 rounded-lg">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2"> <MudText Typo="Typo.subtitle2" Class="mud-text-secondary">موجودی فروشگاه تخفیفی</MudText>
<MudIcon Icon="@Icons.Material.Filled.Discount" Color="Color.Warning" Size="Size.Large" /> <MudText Typo="Typo.h4" Color="Color.Warning">@_balances.Discount</MudText>
<MudStack Spacing="0">
<MudText Typo="Typo.subtitle2" Style="color: var(--mud-palette-warning-darken);">موجودی تخفیف باشگاه</MudText>
<MudText Typo="Typo.h4" Style="color: var(--mud-palette-warning-darken);"><strong>@_balances.Discount</strong></MudText>
</MudStack>
</MudStack>
</MudPaper> </MudPaper>
</MudItem> </MudItem>
<MudItem xs="12" sm="6" md="4"> <MudItem xs="12" sm="6" md="4">
@@ -35,61 +30,45 @@
</MudItem> </MudItem>
</MudGrid> </MudGrid>
<MudPaper Elevation="2" Class="pa-4 rounded-lg"> <!-- دکمه دسترسی سریع به درخواست برداشت -->
<MudText Typo="Typo.h6" Class="mb-2">درخواست برداشت از موجودی شبکه</MudText> <MudButton Variant="Variant.Filled"
<MudStack Spacing="2"> Color="Color.Warning"
<MudTextField @bind-Value="_withdrawPayoutId" Size="Size.Large"
Label="شناسه واریز (PayoutId)" FullWidth="true"
Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.RequestPage"
Type="MudBlazor.InputType.Number" Href="@RouteConstants.Profile.WithdrawalRequests"
Required="true" /> Class="rounded-lg">
<MudRadioGroup T="WithdrawalMethodClient" @bind-SelectedOption="_withdrawMethod" Row="true"> درخواست‌های برداشت
<MudRadio T="WithdrawalMethodClient" Option="@WithdrawalMethodClient.Cash" Label="برداشت نقدی (نیاز به شبا)" /> </MudButton>
<MudRadio T="WithdrawalMethodClient" Option="@WithdrawalMethodClient.Diamond" Label="الماس/غیرنقدی" />
</MudRadioGroup>
<MudTextField @bind-Value="_withdrawIban"
Label="شماره شبا"
Variant="Variant.Outlined"
Disabled="_withdrawMethod == WithdrawalMethodClient.Diamond"
Placeholder="IRxxxxxxxxxxxx"
Adornment="Adornment.Start"
AdornmentText="IR" />
<MudText Typo="Typo.caption" Class="mud-text-secondary">
حداقل مبلغ برداشت: @FormatPrice(_minWithdrawalAmount)
</MudText>
<MudButton Disabled="_isSubmittingWithdrawal"
Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Outbound"
OnClick="SubmitWithdrawal">
@(_isSubmittingWithdrawal ? "در حال ثبت..." : "ثبت درخواست برداشت")
</MudButton>
</MudStack>
</MudPaper>
<MudPaper Elevation="2" Class="pa-4 rounded-lg"> <MudPaper Elevation="2" Class="pa-4 rounded-lg">
<MudText Typo="Typo.h6" Class="mb-2">تراکنش‌ها و مسیرهای شارژ</MudText> <MudText Typo="Typo.h6" Class="mb-2">تراکنش‌ها و مسیرهای شارژ</MudText>
<MudStack Row="true" Spacing="2" Class="mb-3" AlignItems="AlignItems.End"> <MudGrid Spacing="2" Class="mb-3">
<MudTextField @bind-Value="_filterReferenceId" <MudItem xs="6" md="4">
Label="شناسه ارجاع" <MudTextField @bind-Value="_filterReferenceId"
Variant="Variant.Outlined" Label="شناسه ارجاع"
Adornment="Adornment.Start" Variant="Variant.Outlined"
AdornmentIcon="@Icons.Material.Filled.Search" /> Adornment="Adornment.Start"
<MudSelect T="string" @bind-Value="_filterType" Label="نوع تراکنش" Variant="Variant.Outlined"> AdornmentIcon="@Icons.Material.Filled.Search"
<MudSelectItem T="string" Value="@("all")">همه</MudSelectItem> FullWidth="true" />
<MudSelectItem T="string" Value="@("in")">ورودی (شارژ)</MudSelectItem> </MudItem>
<MudSelectItem T="string" Value="@("out")">خروجی (برداشت/خرید)</MudSelectItem> <MudItem xs="6" md="4">
</MudSelect> <MudSelect T="string" @bind-Value="_filterType" Label="نوع تراکنش" Variant="Variant.Outlined" FullWidth="true">
<MudSelect T="string" @bind-Value="_withdrawStatusFilter" Label="وضعیت برداشت" Variant="Variant.Outlined"> <MudSelectItem T="string" Value="@("all")">همه</MudSelectItem>
<MudSelectItem T="string" Value="@("all")">همه</MudSelectItem> <MudSelectItem T="string" Value="@("in")">ورودی (شارژ)</MudSelectItem>
<MudSelectItem T="string" Value="@("pending")">Pending</MudSelectItem> <MudSelectItem T="string" Value="@("out")">خروجی (برداشت/خرید)</MudSelectItem>
<MudSelectItem T="string" Value="@("requested")">Requested</MudSelectItem> </MudSelect>
<MudSelectItem T="string" Value="@("withdrawn")">Withdrawn</MudSelectItem> </MudItem>
<MudSelectItem T="string" Value="@("cancelled")">Cancelled</MudSelectItem> <MudItem xs="12" md="4" Class="d-flex align-end">
</MudSelect> <MudButton Variant="Variant.Outlined"
<MudButton Variant="Variant.Outlined" OnClick="ApplyFilters" StartIcon="@Icons.Material.Filled.FilterList">اعمال فیلتر</MudButton> OnClick="ApplyFilters"
</MudStack> StartIcon="@Icons.Material.Filled.FilterList"
FullWidth="true">
اعمال فیلتر
</MudButton>
</MudItem>
</MudGrid>
<MudHidden Breakpoint="Breakpoint.MdAndUp" Invert="true"> <MudHidden Breakpoint="Breakpoint.MdAndUp" Invert="true">
<MudTable Items="_txs" Dense="true"> <MudTable Items="_txs" Dense="true">
@@ -126,49 +105,5 @@
</MudStack> </MudStack>
</MudHidden> </MudHidden>
</MudPaper> </MudPaper>
<MudPaper Elevation="2" Class="pa-4 rounded-lg">
<MudText Typo="Typo.h6" Class="mb-2">درخواست‌های برداشت ثبت‌شده</MudText>
<MudHidden Breakpoint="Breakpoint.MdAndUp" Invert="true">
<MudTable Items="_withdrawals" Dense="true">
<HeaderContent>
<MudTh>هفته</MudTh>
<MudTh>مبلغ</MudTh>
<MudTh>وضعیت</MudTh>
<MudTh>روش</MudTh>
<MudTh>تاریخ</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.WeekNumber</MudTd>
<MudTd>@FormatPrice(context.Amount)</MudTd>
<MudTd>
<MudChip T="string" Color="@(ResolveStatusColor(context.Status))" Variant="Variant.Outlined" Size="Size.Small">
@ResolveStatusText(context.Status)
</MudChip>
</MudTd>
<MudTd>@ResolveMethodText(context.Method)</MudTd>
<MudTd>@context.Created</MudTd>
</RowTemplate>
</MudTable>
</MudHidden>
<MudHidden Breakpoint="Breakpoint.MdAndUp">
<MudStack Spacing="2">
@foreach (var wd in _withdrawals)
{
<MudPaper Class="pa-3 rounded-lg" Outlined="true">
<MudStack Spacing="1">
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText>@wd.WeekNumber</MudText>
<MudText Color="Color.Primary">@FormatPrice(wd.Amount)</MudText>
</MudStack>
<MudText Typo="Typo.caption" Class="mud-text-secondary">وضعیت: @ResolveStatusText(wd.Status) | روش: @ResolveMethodText(wd.Method)</MudText>
<MudText Typo="Typo.caption" Class="mud-text-secondary">@wd.Created</MudText>
</MudStack>
</MudPaper>
}
</MudStack>
</MudHidden>
</MudPaper>
</MudStack> </MudStack>
</MudContainer> </MudContainer>
@@ -6,71 +6,20 @@ namespace FrontOffice.Main.Pages.Profile;
public partial class Wallet : ComponentBase public partial class Wallet : ComponentBase
{ {
private long _minWithdrawalAmount = 1_000_000; // مقدار پیش‌فرض، از CMS خوانده می‌شود
private (string Credit, string Discount, string Network) _balances = ("-", "-", "-"); private (string Credit, string Discount, string Network) _balances = ("-", "-", "-");
private List<WalletTransaction> _txs = new(); private List<WalletTransaction> _txs = new();
private List<WalletWithdrawal> _withdrawals = new();
private string? _filterReferenceId; private string? _filterReferenceId;
private string _filterType = "all"; private string _filterType = "all";
private string _withdrawStatusFilter = "all";
private bool _isSubmittingWithdrawal;
private long _withdrawPayoutId;
private WithdrawalMethodClient _withdrawMethod = WithdrawalMethodClient.Cash;
private string? _withdrawIban;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
var b = await WalletService.GetBalancesAsync(); var b = await WalletService.GetBalancesAsync();
_balances = (FormatPrice(b.CreditBalance), FormatPrice(b.DiscountBalance), FormatPrice(b.NetworkBalance)); _balances = (FormatPrice(b.CreditBalance), FormatPrice(b.DiscountBalance), FormatPrice(b.NetworkBalance));
_txs = await WalletService.GetTransactionsAsync(); _txs = await WalletService.GetTransactionsAsync();
_withdrawals = await WalletService.GetWithdrawalsAsync();
var settings = await WalletService.GetWithdrawalSettingsAsync();
if (settings.MinWithdrawalAmount > 0)
_minWithdrawalAmount = settings.MinWithdrawalAmount;
} }
private static string FormatPrice(long price) => string.Format("{0:N0} تومان", price); private static string FormatPrice(long price) => string.Format("{0:N0} تومان", price);
private async Task SubmitWithdrawal()
{
var target = _withdrawals.FirstOrDefault(x => x.Id == _withdrawPayoutId);
if (target is null)
{
_withdrawals = await WalletService.GetWithdrawalsAsync();
target = _withdrawals.FirstOrDefault(x => x.Id == _withdrawPayoutId);
}
if (target != null && target.Amount < _minWithdrawalAmount)
{
Snackbar.Add($"مبلغ این واریز کمتر از حداقل برداشت ({_minWithdrawalAmount:N0} تومان) است.", Severity.Warning);
return;
}
if (_withdrawPayoutId <= 0)
{
Snackbar.Add("شناسه واریز (PayoutId) الزامی است.", Severity.Warning);
return;
}
if (_withdrawMethod == WithdrawalMethodClient.Cash && string.IsNullOrWhiteSpace(_withdrawIban))
{
Snackbar.Add("برای برداشت نقدی، شماره شبا لازم است.", Severity.Warning);
return;
}
try
{
_isSubmittingWithdrawal = true;
await WalletService.RequestWithdrawalAsync(_withdrawPayoutId, _withdrawMethod, _withdrawIban);
Snackbar.Add("درخواست برداشت ثبت شد.", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"خطا در ثبت برداشت: {ex.Message}", Severity.Error);
}
finally
{
_isSubmittingWithdrawal = false;
}
}
private async Task ApplyFilters() private async Task ApplyFilters()
{ {
long? refId = null; long? refId = null;
@@ -85,39 +34,5 @@ public partial class Wallet : ComponentBase
_ => null _ => null
}; };
_txs = await WalletService.GetTransactionsAsync(refId, isIncrease); _txs = await WalletService.GetTransactionsAsync(refId, isIncrease);
int? status = _withdrawStatusFilter switch
{
"pending" => 1,
"requested" => 2,
"withdrawn" => 3,
"cancelled" => 4,
_ => null
};
_withdrawals = await WalletService.GetWithdrawalsAsync(status);
} }
private static string ResolveStatusText(int status) => status switch
{
0 => "ایجاد شده",
1 => "پرداخت شده",
2 => "درخواست برداشت",
3 => "برداشت شده",
4 => "لغو شده",
_ => status.ToString()
};
private static Color ResolveStatusColor(int status) => status switch
{
2 => Color.Warning,
3 => Color.Success,
4 => Color.Error,
_ => Color.Info
};
private static string ResolveMethodText(int? method) => method switch
{
0 => "نقدی",
1 => "الماس",
_ => "-"
};
} }
@@ -0,0 +1,125 @@
@attribute [Route(RouteConstants.Profile.WithdrawalRequests)]
<PageTitle>درخواست‌های برداشت</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="py-6">
<MudStack Spacing="3">
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.h5">درخواست‌های برداشت</MudText>
<MudButton Variant="Variant.Text" StartIcon="@Icons.Material.Filled.ArrowBack" Href="@RouteConstants.Profile.Wallet">بازگشت به کیف پول</MudButton>
</MudStack>
<!-- فرم درخواست برداشت جدید -->
<MudPaper Elevation="2" Class="pa-4 rounded-lg">
<MudText Typo="Typo.h6" Class="mb-2">ثبت درخواست برداشت جدید</MudText>
<MudStack Spacing="2">
<MudTextField @bind-Value="_withdrawPayoutId"
Label="شناسه واریز (PayoutId)"
Variant="Variant.Outlined"
Type="MudBlazor.InputType.Number"
Required="true" />
<MudRadioGroup T="WithdrawalMethodClient" @bind-Value="_withdrawMethod" Row="true">
<MudRadio T="WithdrawalMethodClient" Option="@WithdrawalMethodClient.Cash" Color="Color.Primary">برداشت نقدی (نیاز به شبا)</MudRadio>
<MudRadio T="WithdrawalMethodClient" Option="@WithdrawalMethodClient.Diamond" Color="Color.Secondary">الماس/غیرنقدی</MudRadio>
</MudRadioGroup>
<MudTextField @bind-Value="_withdrawIban"
Label="شماره شبا"
Variant="Variant.Outlined"
Disabled="_withdrawMethod == WithdrawalMethodClient.Diamond"
Placeholder="IRxxxxxxxxxxxx"
Adornment="Adornment.Start"
AdornmentText="IR" />
<MudText Typo="Typo.caption" Class="mud-text-secondary">
حداقل مبلغ برداشت: @FormatPrice(_minWithdrawalAmount)
</MudText>
<MudButton Disabled="_isSubmittingWithdrawal"
Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Outbound"
OnClick="SubmitWithdrawal">
@(_isSubmittingWithdrawal ? "در حال ثبت..." : "ثبت درخواست برداشت")
</MudButton>
</MudStack>
</MudPaper>
<!-- فیلتر وضعیت -->
<MudPaper Elevation="2" Class="pa-4 rounded-lg">
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.End">
<MudSelect T="string" @bind-Value="_statusFilter" Label="فیلتر وضعیت" Variant="Variant.Outlined" Style="min-width: 180px;">
<MudSelectItem T="string" Value="@("all")">همه</MudSelectItem>
<MudSelectItem T="string" Value="@("pending")">در انتظار</MudSelectItem>
<MudSelectItem T="string" Value="@("requested")">درخواست شده</MudSelectItem>
<MudSelectItem T="string" Value="@("withdrawn")">برداشت شده</MudSelectItem>
<MudSelectItem T="string" Value="@("cancelled")">لغو شده</MudSelectItem>
</MudSelect>
<MudButton Variant="Variant.Outlined" OnClick="ApplyFilter" StartIcon="@Icons.Material.Filled.FilterList">اعمال فیلتر</MudButton>
</MudStack>
</MudPaper>
<!-- لیست درخواست‌های برداشت -->
<MudPaper Elevation="2" Class="pa-4 rounded-lg">
<MudText Typo="Typo.h6" Class="mb-3">لیست درخواست‌ها</MudText>
@if (_isLoading)
{
<MudProgressLinear Color="Color.Primary" Indeterminate="true" />
}
else if (!_withdrawals.Any())
{
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined">
هنوز درخواست برداشتی ثبت نشده است.
</MudAlert>
}
else
{
<!-- نمایش دسکتاپ -->
<MudHidden Breakpoint="Breakpoint.MdAndUp" Invert="true">
<MudTable Items="_withdrawals" Dense="true" Hover="true" Striped="true">
<HeaderContent>
<MudTh>هفته</MudTh>
<MudTh>مبلغ</MudTh>
<MudTh>وضعیت</MudTh>
<MudTh>روش برداشت</MudTh>
<MudTh>تاریخ ثبت</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="هفته">@context.WeekDisplayName</MudTd>
<MudTd DataLabel="مبلغ">@FormatPrice(context.Amount)</MudTd>
<MudTd DataLabel="وضعیت">
<MudChip T="string" Color="@(ResolveStatusColor(context.Status))" Variant="Variant.Filled" Size="Size.Small">
@ResolveStatusText(context.Status)
</MudChip>
</MudTd>
<MudTd DataLabel="روش">@ResolveMethodText(context.Method)</MudTd>
<MudTd DataLabel="تاریخ">@context.Created</MudTd>
</RowTemplate>
</MudTable>
</MudHidden>
<!-- نمایش موبایل -->
<MudHidden Breakpoint="Breakpoint.MdAndUp">
<MudStack Spacing="2">
@foreach (var wd in _withdrawals)
{
<MudPaper Class="pa-3 rounded-lg" Outlined="true">
<MudStack Spacing="1">
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.subtitle2">@wd.WeekDisplayName</MudText>
<MudChip T="string" Color="@(ResolveStatusColor(wd.Status))" Variant="Variant.Filled" Size="Size.Small">
@ResolveStatusText(wd.Status)
</MudChip>
</MudStack>
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.h6" Color="Color.Primary">@FormatPrice(wd.Amount)</MudText>
<MudText Typo="Typo.caption" Class="mud-text-secondary">@ResolveMethodText(wd.Method)</MudText>
</MudStack>
<MudText Typo="Typo.caption" Class="mud-text-secondary">@wd.Created</MudText>
</MudStack>
</MudPaper>
}
</MudStack>
</MudHidden>
}
</MudPaper>
</MudStack>
</MudContainer>
@@ -0,0 +1,123 @@
using FrontOffice.Main.Utilities;
using Microsoft.AspNetCore.Components;
using MudBlazor;
namespace FrontOffice.Main.Pages.Profile;
public partial class WithdrawalRequests : ComponentBase
{
private long _minWithdrawalAmount = 1_000_000;
private List<WalletWithdrawal> _withdrawals = new();
private string _statusFilter = "all";
private bool _isLoading = true;
private bool _isSubmittingWithdrawal;
private long _withdrawPayoutId;
private WithdrawalMethodClient _withdrawMethod = WithdrawalMethodClient.Cash;
private string? _withdrawIban;
protected override async Task OnInitializedAsync()
{
await LoadData();
}
private async Task LoadData()
{
_isLoading = true;
try
{
_withdrawals = await WalletService.GetWithdrawalsAsync();
var settings = await WalletService.GetWithdrawalSettingsAsync();
if (settings.MinWithdrawalAmount > 0)
_minWithdrawalAmount = settings.MinWithdrawalAmount;
}
finally
{
_isLoading = false;
}
}
private static string FormatPrice(long price) => string.Format("{0:N0} تومان", price);
private async Task SubmitWithdrawal()
{
if (_withdrawPayoutId <= 0)
{
Snackbar.Add("شناسه واریز (PayoutId) الزامی است.", Severity.Warning);
return;
}
if (_withdrawMethod == WithdrawalMethodClient.Cash && string.IsNullOrWhiteSpace(_withdrawIban))
{
Snackbar.Add("برای برداشت نقدی، شماره شبا لازم است.", Severity.Warning);
return;
}
try
{
_isSubmittingWithdrawal = true;
await WalletService.RequestWithdrawalAsync(_withdrawPayoutId, _withdrawMethod, _withdrawIban);
Snackbar.Add("درخواست برداشت ثبت شد.", Severity.Success);
// بروزرسانی لیست
await LoadData();
// ریست فرم
_withdrawPayoutId = 0;
_withdrawIban = null;
}
catch (Exception ex)
{
Snackbar.Add($"خطا در ثبت برداشت: {ex.Message}", Severity.Error);
}
finally
{
_isSubmittingWithdrawal = false;
}
}
private async Task ApplyFilter()
{
_isLoading = true;
try
{
int? status = _statusFilter switch
{
"pending" => 1,
"requested" => 2,
"withdrawn" => 3,
"cancelled" => 4,
_ => null
};
_withdrawals = await WalletService.GetWithdrawalsAsync(status);
}
finally
{
_isLoading = false;
}
}
private static string ResolveStatusText(int status) => status switch
{
0 => "ایجاد شده",
1 => "در انتظار پرداخت",
2 => "درخواست برداشت",
3 => "برداشت شده",
4 => "لغو شده",
_ => status.ToString()
};
private static Color ResolveStatusColor(int status) => status switch
{
1 => Color.Info,
2 => Color.Warning,
3 => Color.Success,
4 => Color.Error,
_ => Color.Default
};
private static string ResolveMethodText(int? method) => method switch
{
0 => "نقدی (شبا)",
1 => "الماس",
_ => "-"
};
}
+39 -11
View File
@@ -141,11 +141,22 @@
</MudHidden> </MudHidden>
@if (DeviceDetector.IsMobile()) @if (DeviceDetector.IsMobile())
{ {
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" <MudStack Spacing="1" Class="mobile-actions-stack px-5">
Class="mobile-actions-stack px-5"> <MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.subtitle2"> تخفیف کل: <b>@FormatPrice(CartData.TotalDiscount)</b></MudText> <MudText Typo="Typo.body2">جمع کالاها:</MudText>
<MudText Typo="Typo.h5"> مبلغ کل: <b>@FormatPrice(CartData.Total)</b></MudText> <MudText Typo="Typo.body2">@FormatPrice(CartData.Total) تومان</MudText>
</MudStack>
@if (VAT.IsEnabled)
{
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.caption" Class="mud-text-secondary">مالیات (@VAT.VatPercentage%):</MudText>
<MudText Typo="Typo.caption" Class="mud-text-secondary">@FormatPrice(VATAmount) تومان</MudText>
</MudStack>
}
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.h6" Style="font-weight:bold;">مبلغ قابل پرداخت:</MudText>
<MudText Typo="Typo.h6" Color="Color.Primary" Style="font-weight:bold;">@FormatPrice(TotalWithVAT) تومان</MudText>
</MudStack>
</MudStack> </MudStack>
<MudStack Row="true" Justify="Justify.SpaceBetween" Spacing="2" Class="mobile-actions-stack"> <MudStack Row="true" Justify="Justify.SpaceBetween" Spacing="2" Class="mobile-actions-stack">
@@ -159,12 +170,29 @@
} }
else else
{ {
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" <MudPaper Class="pa-4 rounded-lg" Style="background-color: var(--mud-palette-background-grey);">
Class="mobile-actions-stack px-5"> <MudStack Spacing="1">
<MudText Typo="Typo.subtitle2"> تخفیف کل: <b>@FormatPrice(CartData.TotalDiscount)</b></MudText> <MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.h5"> مبلغ کل: <b>@FormatPrice(CartData.Total)</b></MudText> <MudText Typo="Typo.body2">جمع کالاها:</MudText>
<MudText Typo="Typo.body2">@FormatPrice(CartData.Total) تومان</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" </MudStack>
@if (VAT.IsEnabled)
{
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2" Class="mud-text-secondary">مالیات بر ارزش افزوده (@VAT.VatPercentage%):</MudText>
<MudText Typo="Typo.body2" Class="mud-text-secondary">@FormatPrice(VATAmount) تومان</MudText>
</MudStack>
}
<MudDivider Class="my-1" />
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.subtitle1" Style="font-weight:bold;">مبلغ قابل پرداخت:</MudText>
<MudText Typo="Typo.subtitle1" Color="Color.Primary" Style="font-weight:bold;">@FormatPrice(TotalWithVAT) تومان</MudText>
</MudStack>
</MudStack>
</MudPaper>
<MudStack Row="true" Justify="Justify.FlexEnd" Spacing="2" Class="mt-3">
<MudButton Variant="Variant.Outlined" Color="Color.Primary"
OnClick="() => Navigation.NavigateTo(RouteConstants.Store.Products)">افزودن محصول OnClick="() => Navigation.NavigateTo(RouteConstants.Store.Products)">افزودن محصول
</MudButton> </MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Success" OnClick="ProceedCheckout" <MudButton Variant="Variant.Filled" Color="Color.Success" OnClick="ProceedCheckout"
+21 -2
View File
@@ -7,14 +7,27 @@ namespace FrontOffice.Main.Pages.Store;
public partial class Cart : ComponentBase, IDisposable public partial class Cart : ComponentBase, IDisposable
{ {
[Inject] private CartService CartService { get; set; } = default!; [Inject] private CartService CartService { get; set; } = default!;
[Inject] private VATService VAT { get; set; } = default!;
// Navigation and Snackbar are available via _Imports.razor // Navigation and Snackbar are available via _Imports.razor
private CartService CartData => CartService; private CartService CartData => CartService;
protected override void OnInitialized() protected override async Task OnInitializedAsync()
{ {
// لود سبد خرید (فقط اگر کاربر لاگین کرده باشد)
await CartService.EnsureInitializedAsync();
CartService.OnChange += StateHasChanged; CartService.OnChange += StateHasChanged;
} }
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
// بارگذاری نرخ VAT
await VAT.LoadAsync();
}
await base.OnAfterRenderAsync(firstRender);
}
private async Task IncrementQty(long productId) private async Task IncrementQty(long productId)
{ {
var item = CartData.Items.FirstOrDefault(i => i.ProductId == productId); var item = CartData.Items.FirstOrDefault(i => i.ProductId == productId);
@@ -44,7 +57,13 @@ public partial class Cart : ComponentBase, IDisposable
Navigation.NavigateTo(RouteConstants.Store.CheckoutSummary); Navigation.NavigateTo(RouteConstants.Store.CheckoutSummary);
} }
private static string FormatPrice(long price) => string.Format("{0:N0} ", price); private string FormatPrice(long price) => $"{price:N0} ";
private string FormatPriceWithVAT(long price) => $"{VAT.AddVAT(price):N0} ";
private long TotalWithVAT => VAT.AddVAT(CartData.Total);
private long VATAmount => VAT.CalculateVAT(CartData.Total);
private static string GetProductImageUrl(string? imageUrl) private static string GetProductImageUrl(string? imageUrl)
=> string.IsNullOrWhiteSpace(imageUrl) ? "/images/product-placeholder.svg" : imageUrl; => string.IsNullOrWhiteSpace(imageUrl) ? "/images/product-placeholder.svg" : imageUrl;
@@ -108,14 +108,17 @@
<MudText Typo="Typo.body2">جمع کالاها:</MudText> <MudText Typo="Typo.body2">جمع کالاها:</MudText>
<MudText Typo="Typo.body2">@FormatPrice(Cart.Total)</MudText> <MudText Typo="Typo.body2">@FormatPrice(Cart.Total)</MudText>
</MudStack> </MudStack>
<MudStack Row="true" Justify="Justify.SpaceBetween"> @if (VAT.IsEnabled)
<MudText Typo="Typo.body2" Class="mud-text-secondary">مالیات بر ارزش افزوده (۹%):</MudText> {
<MudText Typo="Typo.body2" Class="mud-text-secondary">@FormatPrice(CalculateVAT())</MudText> <MudStack Row="true" Justify="Justify.SpaceBetween">
</MudStack> <MudText Typo="Typo.body2" Class="mud-text-secondary">مالیات بر ارزش افزوده (@VAT.VatPercentage%):</MudText>
<MudText Typo="Typo.body2" Class="mud-text-secondary">@FormatPrice(CalculateVAT())</MudText>
</MudStack>
}
<MudDivider Class="my-1" /> <MudDivider Class="my-1" />
<MudStack Row="true" Justify="Justify.SpaceBetween"> <MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.subtitle1" Style="font-weight: bold;">مبلغ قابل پرداخت:</MudText> <MudText Typo="Typo.subtitle1" Style="font-weight: bold;">مبلغ قابل پرداخت:</MudText>
<MudText Typo="Typo.subtitle1" Color="Color.Primary" Style="font-weight: bold;">@FormatPrice(Cart.Total + CalculateVAT())</MudText> <MudText Typo="Typo.subtitle1" Color="Color.Primary" Style="font-weight: bold;">@FormatPrice(VAT.AddVAT(Cart.Total))</MudText>
</MudStack> </MudStack>
</MudStack> </MudStack>
@@ -10,6 +10,7 @@ public partial class CheckoutSummary : ComponentBase
{ {
[Inject] private CartService Cart { get; set; } = default!; [Inject] private CartService Cart { get; set; } = default!;
[Inject] private OrderService OrderService { get; set; } = default!; [Inject] private OrderService OrderService { get; set; } = default!;
[Inject] private VATService VAT { get; set; } = default!;
[Inject] private UserAddressContract.UserAddressContractClient UserAddressContract { get; set; } = default!; [Inject] private UserAddressContract.UserAddressContractClient UserAddressContract { get; set; } = default!;
[Inject] private UserOrderContract.UserOrderContractClient UserOrderContract { get; set; } = default!; [Inject] private UserOrderContract.UserOrderContractClient UserOrderContract { get; set; } = default!;
// Snackbar and Navigation are injected via _Imports.razor // Snackbar and Navigation are injected via _Imports.razor
@@ -25,10 +26,22 @@ public partial class CheckoutSummary : ComponentBase
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
// لود سبد خرید (فقط اگر کاربر لاگین کرده باشد)
await Cart.EnsureInitializedAsync();
await LoadAddresses(); await LoadAddresses();
await LoadWalletBalance(); await LoadWalletBalance();
} }
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
// بارگذاری نرخ VAT
await VAT.LoadAsync();
}
await base.OnAfterRenderAsync(firstRender);
}
private async Task LoadWalletBalance() private async Task LoadWalletBalance()
{ {
var walletResult = await WalletService.GetBalancesAsync(); var walletResult = await WalletService.GetBalancesAsync();
@@ -75,7 +88,7 @@ public partial class CheckoutSummary : ComponentBase
{ {
var request = new SubmitShopBuyOrderRequest var request = new SubmitShopBuyOrderRequest
{ {
TotalAmount = Cart.Total TotalAmount = VAT.AddVAT(Cart.Total)
}; };
var response = await UserOrderContract.SubmitShopBuyOrderAsync(request); var response = await UserOrderContract.SubmitShopBuyOrderAsync(request);
@@ -92,9 +105,9 @@ public partial class CheckoutSummary : ComponentBase
private static string FormatPrice(long price) => string.Format("{0:N0} تومان", price); private static string FormatPrice(long price) => string.Format("{0:N0} تومان", price);
/// <summary> /// <summary>
/// محاسبه مالیات بر ارزش افزوده (۹%) /// محاسبه مالیات بر ارزش افزوده
/// </summary> /// </summary>
private long CalculateVAT() => (long)(Cart.Total * 0.09); private long CalculateVAT() => VAT.CalculateVAT(Cart.Total);
private static string GetProductImageUrl(string? imageUrl) private static string GetProductImageUrl(string? imageUrl)
=> string.IsNullOrWhiteSpace(imageUrl) ? "/images/product-placeholder.svg" : imageUrl; => string.IsNullOrWhiteSpace(imageUrl) ? "/images/product-placeholder.svg" : imageUrl;
@@ -78,8 +78,6 @@ else
@* نمایش جزئیات مالی *@ @* نمایش جزئیات مالی *@
@{ @{
var subtotal = _order.FactorDetails.Sum(s => s.UnitPrice.Value * s.Count.Value); var subtotal = _order.FactorDetails.Sum(s => s.UnitPrice.Value * s.Count.Value);
var vatAmount = (long)(subtotal * 0.09);
var totalWithVat = subtotal + vatAmount;
} }
<MudStack Spacing="1" Class="pa-2" Style="background-color: var(--mud-palette-background-grey); border-radius: 8px;"> <MudStack Spacing="1" Class="pa-2" Style="background-color: var(--mud-palette-background-grey); border-radius: 8px;">
<MudStack Row="true" Justify="Justify.SpaceBetween"> <MudStack Row="true" Justify="Justify.SpaceBetween">
@@ -87,15 +85,26 @@ else
<MudText Typo="Typo.body2">@FormatPrice(subtotal)</MudText> <MudText Typo="Typo.body2">@FormatPrice(subtotal)</MudText>
</MudStack> </MudStack>
<MudStack Row="true" Justify="Justify.SpaceBetween"> @if (_order.VatInfo is not null)
<MudText Typo="Typo.body2" Class="mud-text-secondary">مالیات بر ارزش افزوده (۹%):</MudText> {
<MudText Typo="Typo.body2" Class="mud-text-secondary">@FormatPrice(vatAmount)</MudText> var vatPercent = (int)(_order.VatInfo.VatRate * 100);
</MudStack> <MudStack Row="true" Justify="Justify.SpaceBetween">
<MudDivider Class="my-1" /> <MudText Typo="Typo.body2" Class="mud-text-secondary">مالیات بر ارزش افزوده (@vatPercent%):</MudText>
<MudStack Row="true" Justify="Justify.SpaceBetween"> <MudText Typo="Typo.body2" Class="mud-text-secondary">@FormatPrice(_order.VatInfo.VatAmount)</MudText>
<MudText Typo="Typo.subtitle1" Style="font-weight: bold;">مبلغ قابل پرداخت:</MudText> </MudStack>
<MudText Typo="Typo.subtitle1" Color="Color.Primary" Style="font-weight: bold;">@FormatPrice(totalWithVat)</MudText> <MudDivider Class="my-1" />
</MudStack> <MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.subtitle1" Style="font-weight: bold;">مبلغ قابل پرداخت:</MudText>
<MudText Typo="Typo.subtitle1" Color="Color.Primary" Style="font-weight: bold;">@FormatPrice(_order.VatInfo.TotalAmount)</MudText>
</MudStack>
}
else
{
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.subtitle1" Style="font-weight: bold;">مبلغ قابل پرداخت:</MudText>
<MudText Typo="Typo.subtitle1" Color="Color.Primary" Style="font-weight: bold;">@FormatPrice(subtotal)</MudText>
</MudStack>
}
</MudStack> </MudStack>
</MudStack> </MudStack>
</MudPaper> </MudPaper>
@@ -9,6 +9,7 @@ namespace FrontOffice.Main.Pages.Store;
public partial class Orders : ComponentBase public partial class Orders : ComponentBase
{ {
[Inject] private OrderService OrderService { get; set; } = default!; [Inject] private OrderService OrderService { get; set; } = default!;
[Inject] private VATService VAT { get; set; } = default!;
private List<GetUserOrderResponse> _orders = new(); private List<GetUserOrderResponse> _orders = new();
private bool _loading; private bool _loading;
@@ -31,6 +32,16 @@ public partial class Orders : ComponentBase
} }
} }
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
// بارگذاری نرخ VAT
await VAT.LoadAsync();
}
await base.OnAfterRenderAsync(firstRender);
}
private static string FormatPrice(long price) => string.Format("{0:N0} تومان", price); private static string FormatPrice(long price) => string.Format("{0:N0} تومان", price);
@@ -77,18 +77,51 @@ else
@* </MudStack> *@ @* </MudStack> *@
@* } *@ @* } *@
<MudDivider Class="my-2"/> <MudDivider Class="my-2"/>
<MudText Typo="Typo.h5" Color="Color.Primary">@FormatPrice(_product.Price)</MudText> <MudStack Spacing="1">
<MudText Typo="Typo.h5" Color="Color.Primary">@FormatPrice(_product.Price)</MudText>
@if (VAT.IsEnabled)
{
<MudText Typo="Typo.caption" Class="mud-text-secondary">
(شامل @VAT.VatPercentage% مالیات بر ارزش افزوده)
</MudText>
}
</MudStack>
<!-- نمایش وضعیت موجودی -->
@if (IsInStock)
{
<MudChip T="string" Color="Color.Success" Variant="Variant.Filled" Size="Size.Small">
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Size="Size.Small" Class="me-1" />
موجود در انبار (@_product.RemainingCount عدد)
</MudChip>
}
else
{
<MudChip T="string" Color="Color.Error" Variant="Variant.Filled" Size="Size.Small">
<MudIcon Icon="@Icons.Material.Filled.Cancel" Size="Size.Small" Class="me-1" />
ناموجود
</MudChip>
}
<!-- Desktop/tablet actions --> <!-- Desktop/tablet actions -->
<MudHidden Breakpoint="Breakpoint.MdAndUp" Invert="true"> <MudHidden Breakpoint="Breakpoint.MdAndUp" Invert="true">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2"> @if (IsInStock)
<MudNumericField T="int" @bind-Value="_qty" Min="@MinQty" Max="@MaxQty" Immediate="true" {
HideSpinButtons="true" Style="max-width:120px"/> <MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudButton Variant="Variant.Filled" Color="Color.Primary" <MudNumericField T="int" @bind-Value="_qty" Min="@MinQty" Max="@MaxQty" Immediate="true"
StartIcon="@Icons.Material.Filled.AddShoppingCart" OnClick="AddToCart">افزودن به HideSpinButtons="true" Style="max-width:120px"/>
سبد <MudButton Variant="Variant.Filled" Color="Color.Primary"
</MudButton> StartIcon="@Icons.Material.Filled.AddShoppingCart" OnClick="AddToCart">افزودن به
</MudStack> سبد
</MudButton>
</MudStack>
}
else
{
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Dense="true">
این محصول در حال حاضر موجود نیست.
</MudAlert>
}
</MudHidden> </MudHidden>
<!-- Mobile actions use sticky bar rendered below --> <!-- Mobile actions use sticky bar rendered below -->
@@ -103,51 +136,58 @@ else
<MudPaper Class="mud-width-full mud-elevation-3 pa-3" <MudPaper Class="mud-width-full mud-elevation-3 pa-3"
Style="position:sticky;bottom:0;left:0;z-index:9;border-top-left-radius:1rem;border-top-right-radius:1rem;"> Style="position:sticky;bottom:0;left:0;z-index:9;border-top-left-radius:1rem;border-top-right-radius:1rem;">
<MudGrid Class="align-center" Justify="Justify.SpaceBetween"> @if (IsInStock)
<MudItem xs="6"> {
<MudStack Spacing="1"> <MudGrid Class="align-center" Justify="Justify.SpaceBetween">
@if (HasDiscount && OriginalPrice is not null) <MudItem xs="6">
{
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<MudText Typo="Typo.caption"
Class="mud-text-secondary mud-line-through">@FormatPrice(OriginalPrice.Value)</MudText>
<MudChip T="string" Color="Color.Error" Variant="Variant.Filled" Size="Size.Small"
Label="true">@($"٪{_product!.Discount}")</MudChip>
</MudStack>
}
<MudText Typo="Typo.h6" Color="Color.Primary">@FormatPrice(TotalPrice)</MudText>
</MudStack>
</MudItem>
@if (IsInCart)
{
<MudItem xs="5" Class="">
<MudPaper Class="mud-width-full d-flex align-center justify-space-between px-2"
Elevation="1" Style="border-radius:1rem;">
<MudIconButton
Icon="@(CurrentCartQuantity == 1 ? Icons.Material.Filled.Delete : Icons.Material.Filled.Remove)"
Color="Color.Error" Variant="Variant.Text" OnClick="RemoveFromCart"/>
<MudText Typo="Typo.subtitle2" Class="mud-font-weight-bold">@CurrentCartQuantity</MudText>
<MudIconButton Icon="@Icons.Material.Filled.Add" Color="Color.Primary"
Variant="Variant.Text" Disabled="@(CurrentCartQuantity >= MaxQty)"
OnClick="AddToCart"/>
</MudPaper>
</MudItem>
}
else
{
<MudItem xs="6" >
<MudStack Spacing="1"> <MudStack Spacing="1">
@if (HasDiscount && OriginalPrice is not null)
<MudButton Variant="Variant.Filled" Size="Size.Large" Color="Color.Error" Class="mud-width-full" {
StartIcon="@Icons.Material.Filled.AddShoppingCart" OnClick="AddToCart"> <MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
افزودن به سبد <MudText Typo="Typo.caption"
</MudButton> Class="mud-text-secondary mud-line-through">@FormatPrice(OriginalPrice.Value)</MudText>
<MudChip T="string" Color="Color.Error" Variant="Variant.Filled" Size="Size.Small"
Label="true">@($"٪{_product!.Discount}")</MudChip>
</MudStack>
}
<MudText Typo="Typo.h6" Color="Color.Primary">@FormatPrice(TotalPrice)</MudText>
</MudStack> </MudStack>
</MudItem> </MudItem>
}
</MudGrid>
@if (IsInCart)
{
<MudItem xs="5" Class="">
<MudPaper Class="mud-width-full d-flex align-center justify-space-between px-2"
Elevation="1" Style="border-radius:1rem;">
<MudIconButton
Icon="@(CurrentCartQuantity == 1 ? Icons.Material.Filled.Delete : Icons.Material.Filled.Remove)"
Color="Color.Error" Variant="Variant.Text" OnClick="RemoveFromCart"/>
<MudText Typo="Typo.subtitle2" Class="mud-font-weight-bold">@CurrentCartQuantity</MudText>
<MudIconButton Icon="@Icons.Material.Filled.Add" Color="Color.Primary"
Variant="Variant.Text" Disabled="@(CurrentCartQuantity >= MaxQty)"
OnClick="AddToCart"/>
</MudPaper>
</MudItem>
}
else
{
<MudItem xs="6" >
<MudStack Spacing="1">
<MudButton Variant="Variant.Filled" Size="Size.Large" Color="Color.Error" Class="mud-width-full"
StartIcon="@Icons.Material.Filled.AddShoppingCart" OnClick="AddToCart">
افزودن به سبد
</MudButton>
</MudStack>
</MudItem>
}
</MudGrid>
}
else
{
<MudAlert Severity="Severity.Warning" Variant="Variant.Filled" Dense="true" Class="mb-0">
این محصول ناموجود است
</MudAlert>
}
</MudPaper> </MudPaper>
</MudHidden> </MudHidden>
@@ -11,6 +11,7 @@ public partial class ProductDetail : ComponentBase, IDisposable
{ {
[Inject] private ProductService ProductService { get; set; } = default!; [Inject] private ProductService ProductService { get; set; } = default!;
[Inject] private CartService Cart { get; set; } = default!; [Inject] private CartService Cart { get; set; } = default!;
[Inject] private VATService VAT { get; set; } = default!;
[Parameter] public long id { get; set; } [Parameter] public long id { get; set; }
@@ -18,7 +19,13 @@ public partial class ProductDetail : ComponentBase, IDisposable
private bool _loading; private bool _loading;
private int _qty = 1; private int _qty = 1;
private const int MinQty = 1; private const int MinQty = 1;
private const int MaxQty = 20;
// حداکثر تعداد قابل خرید بر اساس موجودی انبار
private int MaxQty => _product?.RemainingCount > 0 ? _product.RemainingCount : 0;
// آیا محصول موجود است
private bool IsInStock => _product is not null && _product.RemainingCount > 0;
private IReadOnlyList<ProductGalleryImage> _galleryItems = Array.Empty<ProductGalleryImage>(); private IReadOnlyList<ProductGalleryImage> _galleryItems = Array.Empty<ProductGalleryImage>();
private IReadOnlyList<ProductCategoryPathInfo> _categoryPaths = Array.Empty<ProductCategoryPathInfo>(); private IReadOnlyList<ProductCategoryPathInfo> _categoryPaths = Array.Empty<ProductCategoryPathInfo>();
private ProductGalleryImage? _selectedGalleryImage; private ProductGalleryImage? _selectedGalleryImage;
@@ -44,8 +51,12 @@ public partial class ProductDetail : ComponentBase, IDisposable
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
// لود سبد خرید (فقط اگر کاربر لاگین کرده باشد)
await Cart.EnsureInitializedAsync();
Cart.OnChange += HandleCartChanged; Cart.OnChange += HandleCartChanged;
_loading = true; _loading = true;
_product = await ProductService.GetByIdAsync(id); _product = await ProductService.GetByIdAsync(id);
_loading = false; _loading = false;
@@ -68,7 +79,15 @@ public partial class ProductDetail : ComponentBase, IDisposable
await base.OnInitializedAsync(); await base.OnInitializedAsync();
} }
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (firstRender)
{
// بارگذاری نرخ VAT
await VAT.LoadAsync();
}
}
private async Task AddToCart() private async Task AddToCart()
{ {
@@ -127,7 +146,9 @@ public partial class ProductDetail : ComponentBase, IDisposable
Cart.OnChange -= HandleCartChanged; Cart.OnChange -= HandleCartChanged;
} }
private static string FormatPrice(long price) => string.Format("{0:N0} تومان", price); private string FormatPrice(long price) => $"{VAT.AddVAT(price):N0} تومان";
private string FormatPriceWithoutVAT(long price) => $"{price:N0} تومان";
private static IReadOnlyList<ProductGalleryImage> BuildGalleryItems(Product product) private static IReadOnlyList<ProductGalleryImage> BuildGalleryItems(Product product)
{ {
@@ -59,8 +59,20 @@
<MudCardContent Class="d-flex flex-column pa-1 h-100"> <MudCardContent Class="d-flex flex-column pa-1 h-100">
<div style="height: 60%;background-image: url('@p.ImageUrl');background-size: cover; background-position: center;border-radius: 0.5rem"> <div style="height: 60%;background-image: url('@p.ImageUrl');background-size: cover; background-position: center;border-radius: 0.5rem; position: relative;">
@if (p.RemainingCount <= 0)
{
<div style="position:absolute;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;border-radius:0.5rem;">
<MudChip T="string" Color="Color.Error" Variant="Variant.Filled" Size="Size.Small">ناموجود</MudChip>
</div>
}
else if (p.RemainingCount <= 5)
{
<MudChip T="string" Color="Color.Warning" Variant="Variant.Filled" Size="Size.Small"
Style="position:absolute;top:8px;right:8px;">
فقط @p.RemainingCount عدد
</MudChip>
}
</div> </div>
<div class="pa-1 flex-grow-1 d-flex flex-column justify-space-between"> <div class="pa-1 flex-grow-1 d-flex flex-column justify-space-between">
<MudText Typo="Typo.subtitle1">@p.Title</MudText> <MudText Typo="Typo.subtitle1">@p.Title</MudText>
@@ -73,7 +85,9 @@
@* Href="@($"{RouteConstants.Store.ProductDetail}{p.Id}")">جزئیات *@ @* Href="@($"{RouteConstants.Store.ProductDetail}{p.Id}")">جزئیات *@
@* </MudButton> *@ @* </MudButton> *@
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="() => AddToCart(p)" <MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="() => AddToCart(p)"
StartIcon="@Icons.Material.Filled.AddShoppingCart">افزودن StartIcon="@Icons.Material.Filled.AddShoppingCart"
Disabled="@(p.RemainingCount <= 0)">
@(p.RemainingCount <= 0 ? "ناموجود" : "افزودن")
</MudButton> </MudButton>
</MudCardActions> </MudCardActions>
</MudCard> </MudCard>
@@ -12,6 +12,7 @@ public partial class Products : ComponentBase, IDisposable
[Inject] private ProductService ProductService { get; set; } = default!; [Inject] private ProductService ProductService { get; set; } = default!;
[Inject] private CategoryService CategoryService { get; set; } = default!; [Inject] private CategoryService CategoryService { get; set; } = default!;
[Inject] private CartService Cart { get; set; } = default!; [Inject] private CartService Cart { get; set; } = default!;
[Inject] private VATService VAT { get; set; } = default!;
private string _query = string.Empty; private string _query = string.Empty;
private bool _loading; private bool _loading;
@@ -21,11 +22,23 @@ public partial class Products : ComponentBase, IDisposable
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
// لود سبد خرید (فقط اگر کاربر لاگین کرده باشد)
await Cart.EnsureInitializedAsync();
Cart.OnChange += StateHasChanged; Cart.OnChange += StateHasChanged;
Navigation.LocationChanged += HandleLocationChanged; Navigation.LocationChanged += HandleLocationChanged;
await Load(); await Load();
} }
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
// بارگذاری نرخ VAT
await VAT.LoadAsync();
}
await base.OnAfterRenderAsync(firstRender);
}
private async Task Load() private async Task Load()
{ {
_loading = true; _loading = true;
@@ -47,7 +60,7 @@ public partial class Products : ComponentBase, IDisposable
await Cart.Add(p, 1); await Cart.Add(p, 1);
} }
private static string FormatPrice(long price) => string.Format("{0:N0} تومان", price); private string FormatPrice(long price) => $"{VAT.AddVAT(price):N0} تومان";
public void Dispose() public void Dispose()
{ {
+9
View File
@@ -17,6 +17,8 @@
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" /> <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) --> <!-- Ensure latest MudBlazor CSS is used (cache-busting) -->
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" asp-append-version="true" /> <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> </head>
<body> <body>
<component type="typeof(App)" render-mode="Server" /> <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 --> <!-- 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> <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 src="_framework/blazor.server.js"></script>
<script> <script>
window.fetchAndDownloadPdf = async function(html, fileName){ window.fetchAndDownloadPdf = async function(html, fileName){
@@ -0,0 +1,271 @@
@using FrontOffice.BFF.ClubMembership.Protobuf.Protos.ClubMembership
@using Blazored.LocalStorage
@using FrontOffice.Main.Utilities
@using MudBlazor
@inject ClubMembershipContract.ClubMembershipContractClient ClubMembershipClient
@inject ILocalStorageService LocalStorage
@inject AuthService AuthService
@inject ISnackbar Snackbar
@inject NavigationManager Navigation
<MudDialog DisableSidePadding="true">
<DialogContent>
<MudContainer Style="max-height: 80vh; overflow-y: auto;" Class="pa-4">
@if (_step == ContractStep.ReadContract)
{
<MudText Typo="Typo.h5" Class="mb-4" Align="Align.Center">
<MudIcon Icon="@Icons.Material.Filled.Article" Class="ml-2" />
قرارداد باشگاه مشتریان
</MudText>
<MudAlert Severity="Severity.Info" Class="mb-4">
لطفاً قرارداد باشگاه مشتریان را به دقت مطالعه کنید و در صورت موافقت تایید نمایید.
</MudAlert>
<MudPaper Class="pa-4 mb-4" Style="max-height: 400px; overflow-y: auto;">
@((MarkupString)GetClubContractHtml())
</MudPaper>
<MudCheckBox @bind-Value="_acceptedTerms" Label="قوانین و مقررات باشگاه مشتریان را مطالعه کردم و می‌پذیرم" Color="Color.Primary" />
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
FullWidth="true"
Class="mt-4"
Disabled="@(!_acceptedTerms || _isLoading)"
OnClick="RequestOtp">
@if (_isLoading)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="ml-2" />
}
ادامه و دریافت کد تایید
</MudButton>
}
else if (_step == ContractStep.EnterOtp)
{
<MudText Typo="Typo.h5" Class="mb-4" Align="Align.Center">
<MudIcon Icon="@Icons.Material.Filled.Sms" Class="ml-2" />
تایید قرارداد
</MudText>
<MudAlert Severity="Severity.Success" Class="mb-4">
کد تایید به شماره موبایل شما ارسال شد. لطفاً کد را وارد کنید.
</MudAlert>
<MudTextField @bind-Value="_otpCode"
Label="کد تایید ۶ رقمی"
Variant="Variant.Outlined"
MaxLength="6"
InputType="InputType.Number"
Class="mb-4"
Immediate="true" />
@if (_remainingSeconds > 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-2">
زمان باقیمانده: @_remainingSeconds ثانیه
</MudText>
}
else
{
<MudButton Variant="Variant.Text" Color="Color.Primary" OnClick="RequestOtp" Disabled="_isLoading">
ارسال مجدد کد
</MudButton>
}
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
FullWidth="true"
Class="mt-4"
Disabled="@(string.IsNullOrEmpty(_otpCode) || _otpCode.Length != 6 || _isLoading)"
OnClick="AcceptContract">
@if (_isLoading)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="ml-2" />
}
تایید و فعال‌سازی عضویت
</MudButton>
}
else if (_step == ContractStep.Success)
{
<div class="text-center">
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Style="font-size: 80px" />
<MudText Typo="Typo.h5" Color="Color.Success" Class="mt-4">
عضویت شما در باشگاه مشتریان فعال شد!
</MudText>
<MudText Typo="Typo.body1" Class="mt-2">
اکنون می‌توانید از لینک دعوت خود استفاده کنید و دوستان‌تان را دعوت کنید.
</MudText>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
Class="mt-6"
OnClick="CloseAndRefresh">
متوجه شدم
</MudButton>
</div>
}
</MudContainer>
</DialogContent>
</MudDialog>
@code {
[CascadingParameter] IMudDialogInstance MudDialog { get; set; }
private enum ContractStep { ReadContract, EnterOtp, Success }
private ContractStep _step = ContractStep.ReadContract;
private bool _acceptedTerms;
private string _otpCode = "";
private bool _isLoading;
private int _remainingSeconds;
private readonly Guid _signGuid = Guid.NewGuid();
private System.Timers.Timer? _timer;
private const string TokenStorageKey = "auth:token";
// این Modal غیرقابل بسته شدن است - Options در ShowAsync تنظیم می‌شوند
private async Task RequestOtp()
{
_isLoading = true;
try
{
var response = await ClubMembershipClient.RequestClubContractOtpAsync(
new RequestClubContractOtpRequest
{
SignGuid = _signGuid.ToString()
});
if (response.Success)
{
_step = ContractStep.EnterOtp;
_remainingSeconds = response.RemainingSeconds > 0 ? response.RemainingSeconds : 120;
StartCountdown();
}
else
{
Snackbar.Add(response.Message, Severity.Error);
}
}
catch (Exception ex)
{
Snackbar.Add($"خطا در ارسال کد: {ex.Message}", Severity.Error);
}
finally
{
_isLoading = false;
StateHasChanged();
}
}
private async Task AcceptContract()
{
_isLoading = true;
try
{
var response = await ClubMembershipClient.AcceptClubMembershipContractAsync(
new AcceptClubMembershipContractRequest
{
OtpCode = _otpCode,
SignGuid = _signGuid.ToString(),
ContractHtml = GetClubContractHtml()
});
if (response.Success)
{
// ذخیره توکن جدید
if (!string.IsNullOrEmpty(response.Token))
{
await LocalStorage.SetItemAsync(TokenStorageKey, response.Token);
await AuthService.InitUserAuthInfo();
}
_step = ContractStep.Success;
StopCountdown();
}
else
{
Snackbar.Add(response.Message, Severity.Error);
}
}
catch (Exception ex)
{
Snackbar.Add($"خطا در ثبت قرارداد: {ex.Message}", Severity.Error);
}
finally
{
_isLoading = false;
StateHasChanged();
}
}
private void CloseAndRefresh()
{
MudDialog.Close(DialogResult.Ok(true));
Navigation.NavigateTo(Navigation.Uri, forceLoad: true);
}
private void StartCountdown()
{
_timer = new System.Timers.Timer(1000);
_timer.Elapsed += (s, e) =>
{
_remainingSeconds--;
if (_remainingSeconds <= 0)
{
StopCountdown();
}
InvokeAsync(StateHasChanged);
};
_timer.Start();
}
private void StopCountdown()
{
_timer?.Stop();
_timer?.Dispose();
_timer = null;
}
public void Dispose()
{
StopCountdown();
}
private string GetClubContractHtml()
{
return @"
<div style='direction: rtl; font-family: Tahoma, sans-serif; line-height: 2; text-align: justify;'>
<h2 style='text-align: center; margin-bottom: 20px;'>قرارداد عضویت در باشگاه مشتریان کارابازار</h2>
<p><strong>ماده ۱ - تعاریف</strong></p>
<p>۱-۱. باشگاه مشتریان: سامانه‌ای که امکان معرفی دوستان و دریافت پاداش را فراهم می‌کند.</p>
<p>۱-۲. لینک دعوت: لینک اختصاصی هر کاربر برای معرفی دیگران.</p>
<p>۱-۳. پاداش معرفی: مبلغی که به ازای هر معرفی موفق به کاربر تعلق می‌گیرد.</p>
<p><strong>ماده ۲ - شرایط عضویت</strong></p>
<p>۲-۱. کاربر با پرداخت مبلغ ۵۶ میلیون تومان (پکیج پایه)، امکان عضویت در باشگاه مشتریان را خواهد داشت.</p>
<p>۲-۲. پس از امضای این قرارداد، لینک دعوت کاربر فعال خواهد شد.</p>
<p>۲-۳. کاربر می‌تواند حداکثر ۲ نفر را مستقیماً دعوت کند (یک نفر در شاخه چپ و یک نفر در شاخه راست).</p>
<p><strong>ماده ۳ - تعهدات کاربر</strong></p>
<p>۳-۱. کاربر متعهد است از لینک دعوت فقط برای معرفی افراد واقعی استفاده کند.</p>
<p>۳-۲. هرگونه سوءاستفاده از سیستم منجر به لغو عضویت خواهد شد.</p>
<p>۳-۳. کاربر حق انتقال یا فروش عضویت خود را ندارد.</p>
<p><strong>ماده ۴ - حقوق کاربر</strong></p>
<p>۴-۱. دریافت پاداش معرفی به ازای هر عضو جدید که از طریق شبکه کاربر عضو شود.</p>
<p>۴-۲. دسترسی به داشبورد مدیریت شبکه و مشاهده وضعیت زیرمجموعه‌ها.</p>
<p>۴-۳. امکان برداشت موجودی کیف پول طبق قوانین سایت.</p>
<p><strong>ماده ۵ - شرایط فسخ</strong></p>
<p>۵-۱. در صورت نقض هر یک از مفاد این قرارداد، کارابازار حق لغو عضویت کاربر را دارد.</p>
<p>۵-۲. در صورت فسخ قرارداد، موجودی کیف پول طبق قوانین سایت تسویه خواهد شد.</p>
<p><strong>ماده ۶ - حل اختلاف</strong></p>
<p>۶-۱. در صورت بروز اختلاف، طرفین ابتدا از طریق مذاکره اقدام به حل و فصل خواهند کرد.</p>
<p>۶-۲. در صورت عدم توافق، مراجع قضایی صلاحیت‌دار رسیدگی خواهند کرد.</p>
<p style='text-align: center; margin-top: 30px;'><strong>شناسه قرارداد: " + _signGuid + @"</strong></p>
</div>";
}
}
@@ -58,6 +58,11 @@
<div class="d-flex align-center gap-2"> <div class="d-flex align-center gap-2">
@if (_isAuthenticated) @if (_isAuthenticated)
{ {
@* <MudBadge Content="@_cartCount" Color="Color.Error" Overlap="true" Visible="@(_cartCount > 0)"> *@
@* *@
@* </MudBadge> *@
<MudIconButton Icon="@Icons.Material.Filled.ShoppingCart" Color="@(_cartCount > 0?Color.Success:Color.Inherit)"
Href="@(RouteConstants.Store.Cart)" />
<MudMenu Icon="@Icons.Material.Filled.Person" Color="Color.Inherit" Size="Size.Medium"> <MudMenu Icon="@Icons.Material.Filled.Person" Color="Color.Inherit" Size="Size.Medium">
<MudMenuItem OnClick="NavigateToProfile" Disabled="@(!AuthService.IsCompleteRegister())"> <MudMenuItem OnClick="NavigateToProfile" Disabled="@(!AuthService.IsCompleteRegister())">
پروفایل پروفایل
@@ -6,7 +6,7 @@ using MudBlazor;
using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Authorization;
namespace FrontOffice.Main.Shared; namespace FrontOffice.Main.Shared;
public partial class MainLayout public partial class MainLayout : IDisposable
{ {
private const string TokenStorageKey = "auth:token"; private const string TokenStorageKey = "auth:token";
@@ -15,10 +15,12 @@ public partial class MainLayout
private bool _drawerOpen; private bool _drawerOpen;
private bool _isAuthenticated; private bool _isAuthenticated;
private string? _email; private string? _email;
private int _cartCount;
[Inject] private ILocalStorageService LocalStorage { get; set; } = default!; [Inject] private ILocalStorageService LocalStorage { get; set; } = default!;
[Inject] private AuthService AuthService { get; set; } = default!; [Inject] private AuthService AuthService { get; set; } = default!;
[Inject] private AuthDialogService AuthDialogService { get; set; } = default!; [Inject] private AuthDialogService AuthDialogService { get; set; } = default!;
[Inject] private CartService CartService { get; set; } = default!;
private void ToggleTheme() => _isDark = !_isDark; private void ToggleTheme() => _isDark = !_isDark;
private void ToggleDrawer() => _drawerOpen = !_drawerOpen; private void ToggleDrawer() => _drawerOpen = !_drawerOpen;
@@ -33,10 +35,25 @@ public partial class MainLayout
{ {
await JSRuntime.InvokeVoidAsync("changeNavBgOnBodyScroll", "top", null, 1); await JSRuntime.InvokeVoidAsync("changeNavBgOnBodyScroll", "top", null, 1);
await CheckAuthStatus(); await CheckAuthStatus();
if (_isAuthenticated)
{
// لود سبد خرید فقط برای کاربر لاگین شده
await CartService.EnsureInitializedAsync();
CartService.OnChange += OnCartChanged;
_cartCount = CartService.Count;
}
StateHasChanged(); StateHasChanged();
} }
} }
private void OnCartChanged()
{
_cartCount = CartService.Count;
InvokeAsync(StateHasChanged);
}
private async Task CheckAuthStatus() private async Task CheckAuthStatus()
{ {
_isAuthenticated = await AuthService.IsAuthenticatedAsync(); _isAuthenticated = await AuthService.IsAuthenticatedAsync();
@@ -64,6 +81,12 @@ public partial class MainLayout
{ {
await AuthService.LogoutAsync(); await AuthService.LogoutAsync();
_isAuthenticated = false; _isAuthenticated = false;
_cartCount = 0;
StateHasChanged(); StateHasChanged();
} }
public void Dispose()
{
CartService.OnChange -= OnCartChanged;
}
} }
@@ -0,0 +1,38 @@
@using FrontOffice.Main.Utilities
@using MudBlazor
<MudAutocomplete T="WeekDefinitionDto"
@bind-Value="_selectedWeek"
Label="@Label"
Placeholder="@Placeholder"
SearchFunc="SearchWeeksAsync"
ToStringFunc="@(w => w?.DropdownDisplay ?? string.Empty)"
Variant="@Variant"
Dense="@Dense"
Margin="@Margin"
Clearable="@Clearable"
ShowProgressIndicator="true"
ProgressIndicatorColor="Color.Primary"
AdornmentIcon="@Icons.Material.Filled.CalendarMonth"
AdornmentColor="Color.Primary"
Class="@Class"
Style="@Style">
<ItemTemplate>
<MudStack Spacing="0" Class="py-1">
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.body1">
@context.DisplayName
@if (context.IsCurrentWeek)
{
<MudChip T="string" Size="Size.Small" Color="Color.Primary" Class="ms-2">هفته جاری</MudChip>
}
</MudText>
<MudText Typo="Typo.caption" Class="mud-text-secondary">@context.PersianYear</MudText>
</MudStack>
<MudText Typo="Typo.caption" Class="mud-text-secondary">@context.DateRangeDisplay</MudText>
</MudStack>
</ItemTemplate>
<NoItemsTemplate>
<MudText Typo="Typo.body2" Class="pa-3">هفته‌ای یافت نشد</MudText>
</NoItemsTemplate>
</MudAutocomplete>
@@ -0,0 +1,210 @@
using FrontOffice.Main.Utilities;
using Microsoft.AspNetCore.Components;
using MudBlazor;
namespace FrontOffice.Main.Shared;
public partial class WeekSelector : ComponentBase
{
[Inject] private CommissionService CommissionService { get; set; } = default!;
/// <summary>
/// Selected week definition
/// </summary>
[Parameter] public WeekDefinitionDto? Value { get; set; }
/// <summary>
/// Callback when week selection changes
/// </summary>
[Parameter] public EventCallback<WeekDefinitionDto?> ValueChanged { get; set; }
/// <summary>
/// Label for the input
/// </summary>
[Parameter] public string Label { get; set; } = "انتخاب هفته";
/// <summary>
/// Placeholder text
/// </summary>
[Parameter] public string Placeholder { get; set; } = "هفته را انتخاب کنید...";
/// <summary>
/// Input variant
/// </summary>
[Parameter] public Variant Variant { get; set; } = Variant.Outlined;
/// <summary>
/// Dense mode
/// </summary>
[Parameter] public bool Dense { get; set; } = false;
/// <summary>
/// Input margin
/// </summary>
[Parameter] public Margin Margin { get; set; } = Margin.Normal;
/// <summary>
/// Allow clearing the selection
/// </summary>
[Parameter] public bool Clearable { get; set; } = true;
/// <summary>
/// CSS class
/// </summary>
[Parameter] public string? Class { get; set; }
/// <summary>
/// Inline style
/// </summary>
[Parameter] public string? Style { get; set; }
/// <summary>
/// Persian year filter
/// </summary>
[Parameter] public int? PersianYear { get; set; }
/// <summary>
/// Gregorian year filter
/// </summary>
[Parameter] public int? GregorianYear { get; set; }
/// <summary>
/// Only show active weeks
/// </summary>
[Parameter] public bool? OnlyActive { get; set; } = true;
private WeekDefinitionDto? _selectedWeek
{
get => Value;
set
{
if (Value != value)
{
Value = value;
ValueChanged.InvokeAsync(value);
}
}
}
private List<WeekDefinitionDto> _cachedWeeks = new();
private bool _isLoading = false;
protected override async Task OnInitializedAsync()
{
// Pre-load weeks
await LoadWeeksAsync();
}
private async Task LoadWeeksAsync()
{
try
{
_isLoading = true;
_cachedWeeks = await CommissionService.GetWeekDefinitionsAsync(
searchText: null,
gregorianYear: GregorianYear,
persianYear: PersianYear,
isActive: OnlyActive,
pageSize: 100
);
}
catch
{
_cachedWeeks = new List<WeekDefinitionDto>();
}
finally
{
_isLoading = false;
}
}
private async Task<IEnumerable<WeekDefinitionDto>> SearchWeeksAsync(string searchText, CancellationToken cancellationToken)
{
// If cached weeks are empty, load them
if (!_cachedWeeks.Any())
{
await LoadWeeksAsync();
}
// Filter locally from cache
if (string.IsNullOrWhiteSpace(searchText))
return _cachedWeeks;
return _cachedWeeks.Where(w =>
w.DisplayName.Contains(searchText, StringComparison.OrdinalIgnoreCase) ||
w.StartDatePersian.Contains(searchText, StringComparison.OrdinalIgnoreCase) ||
w.EndDatePersian.Contains(searchText, StringComparison.OrdinalIgnoreCase));
}
private async Task OnWeekChanged(WeekDefinitionDto? week)
{
_selectedWeek = week;
}
/// <summary>
/// Get current week from cached weeks
/// </summary>
public WeekDefinitionDto? GetCurrentWeek()
{
return _cachedWeeks.FirstOrDefault(w => w.IsCurrentWeek);
}
/// <summary>
/// Select current week
/// </summary>
public async Task SelectCurrentWeekAsync()
{
var currentWeek = GetCurrentWeek();
if (currentWeek != null)
{
_selectedWeek = currentWeek;
StateHasChanged();
}
await Task.CompletedTask;
}
/// <summary>
/// Find week by WeekOrder number (e.g., 46 from ?week=46)
/// </summary>
public WeekDefinitionDto? FindByWeekOrder(int weekOrder)
{
return _cachedWeeks.FirstOrDefault(w => w.WeekOrder == weekOrder);
}
/// <summary>
/// Find week by Id
/// </summary>
public WeekDefinitionDto? FindById(long id)
{
return _cachedWeeks.FirstOrDefault(w => w.Id == id);
}
/// <summary>
/// Select week by WeekOrder
/// </summary>
public async Task SelectByWeekOrderAsync(int weekOrder)
{
var week = FindByWeekOrder(weekOrder);
if (week != null)
{
_selectedWeek = week;
StateHasChanged();
}
await Task.CompletedTask;
}
/// <summary>
/// Ensure weeks are loaded (useful for parent components)
/// </summary>
public async Task EnsureLoadedAsync()
{
if (!_cachedWeeks.Any())
{
await LoadWeeksAsync();
}
}
/// <summary>
/// Get all cached weeks
/// </summary>
public IReadOnlyList<WeekDefinitionDto> GetCachedWeeks() => _cachedWeeks.AsReadOnly();
}
+57 -1
View File
@@ -1,4 +1,5 @@
using Blazored.LocalStorage; using Blazored.LocalStorage;
using FrontOffice.BFF.User.Protobuf.Protos.User;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using MudBlazor; using MudBlazor;
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
@@ -11,15 +12,25 @@ public class AuthService
private readonly NavigationManager _navigation; private readonly NavigationManager _navigation;
private readonly ISnackbar _snackbar; private readonly ISnackbar _snackbar;
private readonly UserAuthInfo _userAuthInfo; private readonly UserAuthInfo _userAuthInfo;
private readonly UserContract.UserContractClient _userContract;
private readonly ILogger<AuthService> _logger;
private const string TokenStorageKey = "auth:token"; 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; _localStorage = localStorage;
_navigation = navigation; _navigation = navigation;
_snackbar = snackbar; _snackbar = snackbar;
_userAuthInfo = userAuthInfo; _userAuthInfo = userAuthInfo;
_userContract = userContract;
_logger = logger;
} }
public async Task<bool> IsAuthenticatedAsync() public async Task<bool> IsAuthenticatedAsync()
@@ -99,6 +110,51 @@ public class AuthService
_navigation.NavigateTo(RouteConstants.Main.MainPage); _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() public async Task RequireAuthenticationAsync()
{ {
var isAuthenticated = await IsAuthenticatedAsync(); var isAuthenticated = await IsAuthenticatedAsync();
+61 -3
View File
@@ -1,6 +1,7 @@
using DateTimeConverterCL; using DateTimeConverterCL;
using FrontOffice.BFF.ShopingCart.Protobuf.Protos.ShopingCart; using FrontOffice.BFF.ShopingCart.Protobuf.Protos.ShopingCart;
using Google.Protobuf.WellKnownTypes; using Google.Protobuf.WellKnownTypes;
using Blazored.LocalStorage;
namespace FrontOffice.Main.Utilities; namespace FrontOffice.Main.Utilities;
@@ -16,13 +17,18 @@ public record CartItem(long cartId,long ProductId, string Title, string ImageUrl
public class CartService public class CartService
{ {
private readonly ShopingCartContract.ShopingCartContractClient _client; private readonly ShopingCartContract.ShopingCartContractClient _client;
private readonly ILocalStorageService _localStorage;
private readonly List<CartItem> _items = new(); private readonly List<CartItem> _items = new();
private const string TokenStorageKey = "auth:token";
private bool _isInitialized;
public event Action? OnChange; public event Action? OnChange;
public CartService(ShopingCartContract.ShopingCartContractClient client) public CartService(ShopingCartContract.ShopingCartContractClient client, ILocalStorageService localStorage)
{ {
_client = client; _client = client;
_ = LoadFromServerAsync(); _localStorage = localStorage;
// لود سبد خرید به صورت lazy انجام میشه - نه در constructor
} }
public IReadOnlyList<CartItem> Items => _items.AsReadOnly(); public IReadOnlyList<CartItem> Items => _items.AsReadOnly();
@@ -33,6 +39,13 @@ public class CartService
public async Task Add(Product product, int quantity = 1) public async Task Add(Product product, int quantity = 1)
{ {
if (quantity <= 0) return; if (quantity <= 0) return;
// اطمینان از لود شدن سبد خرید
await EnsureInitializedAsync();
// چک لاگین بودن کاربر
if (!await IsAuthenticatedAsync()) return;
var existing = _items.FirstOrDefault(i => i.ProductId == product.Id); var existing = _items.FirstOrDefault(i => i.ProductId == product.Id);
int newQuantity; int newQuantity;
@@ -82,11 +95,14 @@ public class CartService
public async Task UpdateQuantity(long productId, int quantity) public async Task UpdateQuantity(long productId, int quantity)
{ {
// چک لاگین بودن کاربر
if (!await IsAuthenticatedAsync()) return;
var existing = _items.FirstOrDefault(i => i.ProductId == productId); var existing = _items.FirstOrDefault(i => i.ProductId == productId);
if (existing is null) return; if (existing is null) return;
if (quantity <= 0) if (quantity <= 0)
{ {
Remove(existing.cartId); await Remove(existing.cartId);
return; return;
} }
var idx = _items.IndexOf(existing); var idx = _items.IndexOf(existing);
@@ -109,6 +125,9 @@ public class CartService
public async Task Remove(long cartId) public async Task Remove(long cartId)
{ {
// چک لاگین بودن کاربر
if (!await IsAuthenticatedAsync()) return;
_items.RemoveAll(i => i.cartId == cartId); _items.RemoveAll(i => i.cartId == cartId);
Notify(); Notify();
@@ -129,6 +148,9 @@ public class CartService
public async Task Clear() public async Task Clear()
{ {
// چک لاگین بودن کاربر
if (!await IsAuthenticatedAsync()) return;
var productIds = _items.Select(i => i.ProductId).ToList(); var productIds = _items.Select(i => i.ProductId).ToList();
_items.Clear(); _items.Clear();
Notify(); Notify();
@@ -152,8 +174,44 @@ public class CartService
private void Notify() => OnChange?.Invoke(); private void Notify() => OnChange?.Invoke();
/// <summary>
/// بررسی اینکه کاربر لاگین کرده یا نه
/// </summary>
private async Task<bool> IsAuthenticatedAsync()
{
try
{
var token = await _localStorage.GetItemAsync<string>(TokenStorageKey);
return !string.IsNullOrWhiteSpace(token);
}
catch
{
return false;
}
}
/// <summary>
/// اطمینان از لود شدن سبد خرید - فقط اگر کاربر لاگین کرده باشد
/// </summary>
public async Task EnsureInitializedAsync()
{
if (_isInitialized) return;
if (await IsAuthenticatedAsync())
{
await LoadFromServerAsync();
}
_isInitialized = true;
}
private async Task LoadFromServerAsync() private async Task LoadFromServerAsync()
{ {
// ابتدا چک کن که کاربر لاگین کرده
if (!await IsAuthenticatedAsync())
{
return;
}
try try
{ {
var response = await _client.GetAllUserCartAsync(new Empty()); var response = await _client.GetAllUserCartAsync(new Empty());
@@ -0,0 +1,68 @@
using FrontOffice.BFF.Configuration.Protobuf.Protos.Configuration;
namespace FrontOffice.Main.Utilities;
/// <summary>
/// سرویس تنظیمات باشگاه مشتریان
/// </summary>
public class ClubConfigurationService
{
private readonly ConfigurationContract.ConfigurationContractClient _client;
public ClubConfigurationService(ConfigurationContract.ConfigurationContractClient client)
{
_client = client;
}
/// <summary>
/// دریافت تنظیمات باشگاه مشتریان
/// </summary>
public async Task<ClubConfigDto> GetClubConfigurationAsync()
{
var response = await _client.GetClubConfigurationAsync(new Google.Protobuf.WellKnownTypes.Empty());
return new ClubConfigDto
{
ActivationFee = response.ActivationFee,
MembershipGiftValue = response.MembershipGiftValue
};
}
/// <summary>
/// دریافت لیست فیچرهای باشگاه برای کاربر جاری
/// </summary>
public async Task<List<ClubFeatureDto>> GetClubFeaturesAsync()
{
var response = await _client.GetClubFeaturesAsync(new Google.Protobuf.WellKnownTypes.Empty());
return response.Features.Select(f => new ClubFeatureDto
{
Id = f.Id,
Title = f.Title,
Description = f.Description,
IsEnabled = f.IsEnabled,
DisplayOrder = f.DisplayOrder,
GrantedAt = f.GrantedAt?.ToDateTime(),
CreatedAt = f.CreatedAt?.ToDateTime(),
Notes = f.Notes
}).ToList();
}
}
public class ClubConfigDto
{
public long ActivationFee { get; set; }
public long MembershipGiftValue { get; set; }
}
public class ClubFeatureDto
{
public long Id { get; set; }
public string Title { get; set; } = string.Empty;
public string? Description { get; set; }
public bool IsEnabled { get; set; }
public int DisplayOrder { get; set; }
public DateTime? GrantedAt { get; set; }
public DateTime? CreatedAt { get; set; }
public string? Notes { get; set; }
}
@@ -1,3 +1,5 @@
using DateTimeConverterCL;
namespace FrontOffice.Main.Utilities; namespace FrontOffice.Main.Utilities;
/// <summary> /// <summary>
@@ -6,8 +8,8 @@ namespace FrontOffice.Main.Utilities;
public class CommissionPayoutDto public class CommissionPayoutDto
{ {
public long Id { get; set; } public long Id { get; set; }
public int WeekNumber { get; set; } public long WeekDefinitionId { get; set; }
public string WeekLabel { get; set; } = string.Empty; public string WeekDisplayName { get; set; } = string.Empty;
public int BalancesEarned { get; set; } public int BalancesEarned { get; set; }
public long TotalAmount { get; set; } public long TotalAmount { get; set; }
public string AmountFormatted { get; set; } = string.Empty; public string AmountFormatted { get; set; } = string.Empty;
@@ -32,8 +34,8 @@ public class CommissionPayoutsResponseDto
/// </summary> /// </summary>
public class WeeklyBalanceDto public class WeeklyBalanceDto
{ {
public int WeekNumber { get; set; } public long WeekDefinitionId { get; set; }
public string WeekLabel { get; set; } = string.Empty; public string WeekDisplayName { get; set; } = string.Empty;
public long LeftBalance { get; set; } public long LeftBalance { get; set; }
public long RightBalance { get; set; } public long RightBalance { get; set; }
public long MinBalance { get; set; } public long MinBalance { get; set; }
@@ -45,12 +47,37 @@ public class WeeklyBalanceDto
public DateTime EndDate { get; set; } public DateTime EndDate { get; set; }
// Formatted properties // Formatted properties
public string LeftBalanceFormatted => $"{LeftBalance:N0} تومان"; public string LeftBalanceFormatted => $"{LeftBalance:N0} ";
public string RightBalanceFormatted => $"{RightBalance:N0} تومان"; public string RightBalanceFormatted => $"{RightBalance:N0} ";
public string MinBalanceFormatted => $"{MinBalance:N0} تومان"; public string MinBalanceFormatted => $"{MinBalance:N0} ";
public string CalculatedCommissionFormatted => $"{CalculatedCommission:N0} تومان"; public string CalculatedCommissionFormatted => $"{CalculatedCommission:N0} ";
public string LeftCarryoverFormatted => $"{LeftCarryover:N0} تومان"; public string LeftCarryoverFormatted => $"{LeftCarryover:N0} ";
public string RightCarryoverFormatted => $"{RightCarryover:N0} تومان"; public string RightCarryoverFormatted => $"{RightCarryover:N0} ";
public string StartDatePersian => StartDate.ToString("yyyy/MM/dd"); public string StartDatePersian => StartDate.MiladiToJalali();
public string EndDatePersian => EndDate.ToString("yyyy/MM/dd"); public string EndDatePersian => EndDate.MiladiToJalali();
}
/// <summary>
/// DTO for Week Definition (for dropdowns)
/// </summary>
public class WeekDefinitionDto
{
public long Id { get; set; }
public int WeekOrder { get; set; }
public string DisplayName { get; set; } = string.Empty;
public DateTime StartDate { get; set; }
public DateTime EndDate { get; set; }
public int GregorianYear { get; set; }
public int PersianYear { get; set; }
public bool IsActive { get; set; }
public bool IsCurrentWeek { get; set; }
public string StartDatePersian { get; set; } = string.Empty;
public string EndDatePersian { get; set; } = string.Empty;
// For display in dropdown
public string DropdownDisplay => IsCurrentWeek
? $"{DisplayName} (هفته جاری)"
: DisplayName;
public string DateRangeDisplay => $"{StartDate.MiladiToJalali()} - {EndDate.MiladiToJalali()}";
} }
@@ -15,12 +15,67 @@ public class CommissionService
_client = client; _client = client;
} }
/// <summary>
/// Get week definitions for dropdown
/// Maps to: CommissionCQ.GetWeekDefinitions
/// </summary>
public async Task<List<WeekDefinitionDto>> GetWeekDefinitionsAsync(
string? searchText = null,
int? gregorianYear = null,
int? persianYear = null,
bool? isActive = null,
int pageSize = 100)
{
try
{
var request = new GetWeekDefinitionsRequest
{
PageNumber = 1,
PageSize = pageSize
};
if (!string.IsNullOrWhiteSpace(searchText))
request.SearchText = searchText;
if (gregorianYear.HasValue)
request.GregorianYear = gregorianYear.Value;
if (persianYear.HasValue)
request.PersianYear = persianYear.Value;
if (isActive.HasValue)
request.IsActive = isActive.Value;
var response = await _client.GetWeekDefinitionsAsync(request);
return response.Data.Select(w => new WeekDefinitionDto
{
Id = w.Id,
WeekOrder = w.WeekOrder,
DisplayName = w.DisplayName,
StartDate = w.StartDate?.ToDateTime() ?? DateTime.MinValue,
EndDate = w.EndDate?.ToDateTime() ?? DateTime.MinValue,
GregorianYear = w.GregorianYear,
PersianYear = w.PersianYear,
IsActive = w.IsActive,
IsCurrentWeek = w.IsCurrentWeek,
StartDatePersian = w.StartDatePersian,
EndDatePersian = w.EndDatePersian
}).ToList();
}
catch
{
// Fallback to mock data if backend is unavailable
return GenerateMockWeekDefinitions();
}
}
/// <summary> /// <summary>
/// Get commission payouts with pagination and filters /// Get commission payouts with pagination and filters
/// Maps to: CommissionCQ.GetMyCommissionPayouts /// Maps to: CommissionCQ.GetMyCommissionPayouts
/// </summary> /// </summary>
public async Task<CommissionPayoutsResponseDto> GetMyCommissionPayoutsAsync( public async Task<CommissionPayoutsResponseDto> GetMyCommissionPayoutsAsync(
int? weekNumber, long? weekDefinitionId,
string? status, string? status,
int pageNumber, int pageNumber,
int pageSize) int pageSize)
@@ -33,9 +88,9 @@ public class CommissionService
PageSize = pageSize PageSize = pageSize
}; };
if (weekNumber.HasValue) if (weekDefinitionId.HasValue)
{ {
request.WeekNumber = $"2025-W{weekNumber:D2}"; request.WeekDefinitionId = weekDefinitionId.Value;
} }
// Map status string to int // Map status string to int
@@ -58,8 +113,8 @@ public class CommissionService
Payouts = response.Payouts.Select(p => new CommissionPayoutDto Payouts = response.Payouts.Select(p => new CommissionPayoutDto
{ {
Id = p.Id, Id = p.Id,
WeekNumber = ExtractWeekNumber(p.WeekNumber), WeekDefinitionId = p.WeekDefinitionId,
WeekLabel = p.WeekLabel, WeekDisplayName = p.WeekDisplayName,
BalancesEarned = p.BalancesEarned, BalancesEarned = p.BalancesEarned,
TotalAmount = p.TotalAmount, TotalAmount = p.TotalAmount,
AmountFormatted = p.AmountFormatted, AmountFormatted = p.AmountFormatted,
@@ -75,7 +130,7 @@ public class CommissionService
catch catch
{ {
// Fallback to mock data if backend is unavailable // Fallback to mock data if backend is unavailable
return GenerateMockPayoutsResponse(weekNumber, status, pageNumber, pageSize); return GenerateMockPayoutsResponse(weekDefinitionId, status, pageNumber, pageSize);
} }
} }
@@ -83,7 +138,7 @@ public class CommissionService
/// Get weekly balance details for a specific week /// Get weekly balance details for a specific week
/// Maps to: CommissionCQ.GetMyWeeklyBalances /// Maps to: CommissionCQ.GetMyWeeklyBalances
/// </summary> /// </summary>
public async Task<WeeklyBalanceDto> GetMyWeeklyBalanceAsync(int? weekNumber = null) public async Task<WeeklyBalanceDto> GetMyWeeklyBalanceAsync(long? weekDefinitionId = null)
{ {
try try
{ {
@@ -94,9 +149,9 @@ public class CommissionService
OnlyActive = false OnlyActive = false
}; };
if (weekNumber.HasValue) if (weekDefinitionId.HasValue)
{ {
request.WeekNumber = $"2025-W{weekNumber:D2}"; request.WeekDefinitionId = weekDefinitionId.Value;
} }
var response = await _client.GetMyWeeklyBalancesAsync(request); var response = await _client.GetMyWeeklyBalancesAsync(request);
@@ -106,8 +161,8 @@ public class CommissionService
var balance = response.Balances[0]; var balance = response.Balances[0];
return new WeeklyBalanceDto return new WeeklyBalanceDto
{ {
WeekNumber = ExtractWeekNumber(balance.WeekNumber), WeekDefinitionId = balance.WeekDefinitionId,
WeekLabel = $"هفته {ExtractWeekNumber(balance.WeekNumber)} - سال 1404", WeekDisplayName = balance.WeekDisplayName,
LeftBalance = balance.LeftLegBalances, LeftBalance = balance.LeftLegBalances,
RightBalance = balance.RightLegBalances, RightBalance = balance.RightLegBalances,
MinBalance = Math.Min(balance.LeftLegBalances, balance.RightLegBalances), MinBalance = Math.Min(balance.LeftLegBalances, balance.RightLegBalances),
@@ -120,41 +175,28 @@ public class CommissionService
}; };
} }
return CreateMockWeeklyBalance(weekNumber ?? 45); return CreateMockWeeklyBalance();
} }
catch catch
{ {
return CreateMockWeeklyBalance(weekNumber ?? 45); return CreateMockWeeklyBalance();
} }
} }
#region Helper Methods #region Helper Methods
private static int ExtractWeekNumber(string weekNumberStr) private static WeeklyBalanceDto CreateMockWeeklyBalance()
{
// Format: "2025-W48" → 48
if (string.IsNullOrEmpty(weekNumberStr)) return 0;
var parts = weekNumberStr.Split('W');
if (parts.Length > 1 && int.TryParse(parts[1], out var week))
{
return week;
}
return 0;
}
private static WeeklyBalanceDto CreateMockWeeklyBalance(int weekNumber)
{ {
return new WeeklyBalanceDto return new WeeklyBalanceDto
{ {
WeekNumber = weekNumber, WeekDefinitionId = 0,
WeekLabel = $"هفته {weekNumber} - سال 1404", WeekDisplayName = "داده‌ای یافت نشد",
LeftBalance = 15_000_000, LeftBalance = 0,
RightBalance = 12_000_000, RightBalance = 0,
MinBalance = 12_000_000, MinBalance = 0,
BalanceCount = 12, BalanceCount = 0,
CalculatedCommission = 1_200_000, CalculatedCommission = 0,
LeftCarryover = 3_000_000, LeftCarryover = 0,
RightCarryover = 0, RightCarryover = 0,
StartDate = DateTime.Now.AddDays(-7), StartDate = DateTime.Now.AddDays(-7),
EndDate = DateTime.Now EndDate = DateTime.Now
@@ -162,13 +204,13 @@ public class CommissionService
} }
private static CommissionPayoutsResponseDto GenerateMockPayoutsResponse( private static CommissionPayoutsResponseDto GenerateMockPayoutsResponse(
int? weekNumber, string? status, int pageNumber, int pageSize) long? weekDefinitionId, string? status, int pageNumber, int pageSize)
{ {
var allPayouts = GenerateMockPayouts(50); var allPayouts = GenerateMockPayouts(50);
var filtered = allPayouts.AsEnumerable(); var filtered = allPayouts.AsEnumerable();
if (weekNumber.HasValue) if (weekDefinitionId.HasValue)
filtered = filtered.Where(p => p.WeekNumber == weekNumber.Value); filtered = filtered.Where(p => p.WeekDefinitionId == weekDefinitionId.Value);
if (!string.IsNullOrEmpty(status)) if (!string.IsNullOrEmpty(status))
filtered = filtered.Where(p => p.Status == status); filtered = filtered.Where(p => p.Status == status);
@@ -201,8 +243,8 @@ public class CommissionService
payouts.Add(new CommissionPayoutDto payouts.Add(new CommissionPayoutDto
{ {
Id = i + 1, Id = i + 1,
WeekNumber = weekNum, WeekDefinitionId = i + 1,
WeekLabel = $"هفته {weekNum} - سال 1404", WeekDisplayName = $"هفته {weekNum} - سال 1404",
BalancesEarned = balances, BalancesEarned = balances,
TotalAmount = amount, TotalAmount = amount,
AmountFormatted = $"{amount:N0} تومان", AmountFormatted = $"{amount:N0} تومان",
@@ -215,5 +257,35 @@ public class CommissionService
return payouts; return payouts;
} }
private static List<WeekDefinitionDto> GenerateMockWeekDefinitions()
{
var weeks = new List<WeekDefinitionDto>();
var persianOrdinals = new[] { "یکم", "دوم", "سوم", "چهارم", "پنجم", "ششم", "هفتم", "هشتم", "نهم", "دهم" };
// Generate 10 weeks starting from week 46
for (int i = 0; i < 10; i++)
{
var weekOrder = i + 1;
var startDate = new DateTime(2025, 11, 8).AddDays(i * 7);
weeks.Add(new WeekDefinitionDto
{
Id = i + 1,
WeekOrder = weekOrder,
DisplayName = weekOrder <= 10 ? $"هفته {persianOrdinals[weekOrder - 1]}" : $"هفته {weekOrder}",
StartDate = startDate,
EndDate = startDate.AddDays(6),
GregorianYear = 2025,
PersianYear = 1404,
IsActive = true,
IsCurrentWeek = weekOrder == 6, // Assume week 6 is current
StartDatePersian = $"1404/{8 + i}/17",
EndDatePersian = $"1404/{8 + i}/23"
});
}
return weeks;
}
#endregion #endregion
} }
@@ -9,10 +9,32 @@ public class NetworkNodeDto
public string FullName { get; set; } = string.Empty; public string FullName { get; set; } = string.Empty;
public string Mobile { get; set; } = string.Empty; public string Mobile { get; set; } = string.Empty;
public string? Avatar { get; set; } 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? LeftChild { get; set; }
public NetworkNodeDto? RightChild { get; set; } public NetworkNodeDto? RightChild { get; set; }
public int Level { 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> /// <summary>
@@ -23,6 +45,48 @@ public class NetworkTreeDto
public NetworkNodeDto? RootNode { get; set; } public NetworkNodeDto? RootNode { get; set; }
public int TotalMembers { get; set; } public int TotalMembers { get; set; }
public int CurrentDepth { 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> /// <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> /// <summary>
/// Get current user's network statistics /// Get current user's network statistics
/// Maps to: NetworkMembershipCQ.GetMyNetworkStatistics /// Maps to: NetworkMembershipCQ.GetMyNetworkStatistics
@@ -102,6 +135,10 @@ public class NetworkMembershipService
Avatar = node.Avatar, Avatar = node.Avatar,
Position = node.Position, Position = node.Position,
Level = node.Level, 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), LeftChild = MapNodeFromProto(node.LeftChild),
RightChild = MapNodeFromProto(node.RightChild) RightChild = MapNodeFromProto(node.RightChild)
}; };
@@ -111,8 +148,8 @@ public class NetworkMembershipService
{ {
return new NetworkTreeDto return new NetworkTreeDto
{ {
CurrentDepth = 2, CurrentDepth = 3,
TotalMembers = 5, TotalMembers = 7,
RootNode = new NetworkNodeDto RootNode = new NetworkNodeDto
{ {
UserId = 1, UserId = 1,
@@ -120,13 +157,38 @@ public class NetworkMembershipService
Mobile = "09121234567", Mobile = "09121234567",
Position = "Root", Position = "Root",
Level = 0, Level = 0,
IsActive = true,
IsClubActive = true,
LeftChild = new NetworkNodeDto LeftChild = new NetworkNodeDto
{ {
UserId = 2, UserId = 2,
FullName = "علی محمدی", FullName = "علی محمدی",
Mobile = "09121234568", Mobile = "09121234568",
Position = "Left", 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 RightChild = new NetworkNodeDto
{ {
@@ -134,7 +196,20 @@ public class NetworkMembershipService
FullName = "فاطمه حسینی", FullName = "فاطمه حسینی",
Mobile = "09121234569", Mobile = "09121234569",
Position = "Right", 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)
}
} }
} }
}; };
@@ -79,4 +79,25 @@ public class OrderService
return order; return order;
} }
/// <summary>
/// دریافت نرخ مالیات بر ارزش افزوده
/// </summary>
public async Task<GetVATRateResponse> GetVATRateAsync()
{
try
{
return await _userOrderContractClient.GetVATRateAsync(new Google.Protobuf.WellKnownTypes.Empty());
}
catch
{
// در صورت خطا، مقادیر پیش‌فرض
return new GetVATRateResponse
{
VatRate = 0.10,
VatPercentage = 10,
IsEnabled = true
};
}
}
} }
@@ -21,6 +21,7 @@ public static class RouteConstants
public const string ChangePassword = "/profile/change-password"; public const string ChangePassword = "/profile/change-password";
public const string Tree = "/profile/tree"; public const string Tree = "/profile/tree";
public const string Wallet = "/profile/wallet"; public const string Wallet = "/profile/wallet";
public const string WithdrawalRequests = "/profile/withdrawal-requests";
} }
public static class Club public static class Club
@@ -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; }
}
@@ -0,0 +1,146 @@
using Blazored.LocalStorage;
using FrontOffice.BFF.UserOrder.Protobuf.Protos.UserOrder;
using Microsoft.Extensions.DependencyInjection;
namespace FrontOffice.Main.Utilities;
/// <summary>
/// سرویس مدیریت مالیات بر ارزش افزوده
/// نرخ VAT یک بار در روز از سرور گرفته و در LocalStorage ذخیره می‌شود
/// </summary>
public class VATService
{
private readonly IServiceProvider _serviceProvider;
private readonly ILocalStorageService _localStorageService;
private const string VAT_RATE_KEY = "vat_rate";
private const string VAT_PERCENTAGE_KEY = "vat_percentage";
private const string VAT_DATE_KEY = "vat_date";
// مقادیر پیش‌فرض
private double _vatRate = 0.0999;
private int _vatPercentage = 99;
private bool _isEnabled = true;
private bool _isLoaded = false;
public VATService(IServiceProvider serviceProvider, ILocalStorageService localStorageService)
{
_serviceProvider = serviceProvider;
_localStorageService = localStorageService;
}
/// <summary>
/// نرخ مالیات (مثلاً 0.09)
/// </summary>
public double VatRate => _vatRate;
/// <summary>
/// درصد مالیات (مثلاً 9)
/// </summary>
public int VatPercentage => _vatPercentage;
/// <summary>
/// آیا مالیات فعال است
/// </summary>
public bool IsEnabled => _isEnabled;
/// <summary>
/// بارگذاری نرخ VAT - اگر امروز گرفته شده از cache، وگرنه از سرور
/// </summary>
public async Task LoadAsync()
{
if (_isLoaded) return;
try
{
// using var scope = _serviceProvider.CreateScope();
// var localStorage = scope.ServiceProvider.GetRequiredService<ILocalStorageService>();
//
// چک کن آیا امروز قبلاً گرفته شده
var savedDate = await _localStorageService.GetItemAsStringAsync(VAT_DATE_KEY);
var today = DateTime.Today.ToString("yyyy-MM-dd");
if (savedDate == today)
{
// از cache بخوان
var savedRate = await _localStorageService.GetItemAsync<double>(VAT_RATE_KEY);
var savedPercentage = await _localStorageService.GetItemAsync<int>(VAT_PERCENTAGE_KEY);
if (savedRate > 0 && savedPercentage > 0)
{
_vatRate = savedRate;
_vatPercentage = savedPercentage;
_isLoaded = true;
return;
}
}
// از سرور بگیر
await RefreshFromServerAsync();
}
catch
{
// در صورت خطا از مقادیر پیش‌فرض استفاده شود
_isLoaded = true;
}
}
/// <summary>
/// بروزرسانی از سرور و ذخیره در cache
/// </summary>
public async Task RefreshFromServerAsync()
{
try
{
// ایجاد scope برای دریافت client و localStorage
using var scope = _serviceProvider.CreateScope();
var client = scope.ServiceProvider.GetRequiredService<UserOrderContract.UserOrderContractClient>();
// var localStorage = scope.ServiceProvider.GetRequiredService<ILocalStorageService>();
var response = await client.GetVATRateAsync(new Google.Protobuf.WellKnownTypes.Empty());
_vatRate = response.VatRate;
_vatPercentage = response.VatPercentage;
_isEnabled = response.IsEnabled;
// ذخیره در LocalStorage
await _localStorageService.SetItemAsync(VAT_RATE_KEY, _vatRate);
await _localStorageService.SetItemAsync(VAT_PERCENTAGE_KEY, _vatPercentage);
await _localStorageService.SetItemAsStringAsync(VAT_DATE_KEY, DateTime.Today.ToString("yyyy-MM-dd"));
_isLoaded = true;
}
catch
{
// مقادیر پیش‌فرض
_isLoaded = true;
}
}
/// <summary>
/// محاسبه مبلغ مالیات
/// </summary>
public long CalculateVAT(long amount) => _isEnabled ? (long)(amount * _vatRate) : 0;
/// <summary>
/// محاسبه قیمت با احتساب مالیات
/// </summary>
public long AddVAT(long amount) => _isEnabled ? amount + CalculateVAT(amount) : amount;
/// <summary>
/// فرمت قیمت با نمایش مالیات
/// </summary>
public string FormatPriceWithVAT(long price)
{
var priceWithVat = AddVAT(price);
return $"{priceWithVat:N0} تومان";
}
/// <summary>
/// فرمت قیمت با جزئیات مالیات
/// </summary>
public (long BasePrice, long VatAmount, long TotalPrice) GetPriceBreakdown(long basePrice)
{
var vatAmount = CalculateVAT(basePrice);
return (basePrice, vatAmount, basePrice + vatAmount);
}
}
@@ -6,7 +6,7 @@ namespace FrontOffice.Main.Utilities;
public record WalletBalances(long CreditBalance, long DiscountBalance, long NetworkBalance); public record WalletBalances(long CreditBalance, long DiscountBalance, long NetworkBalance);
public record WalletTransaction(string Date, long Amount, string Channel, string Description); public record WalletTransaction(string Date, long Amount, string Channel, string Description);
public record WalletWithdrawal(long Id, string WeekNumber, long Amount, int Status, int? Method, string? Iban, string Created); public record WalletWithdrawal(long Id, long WeekDefinitionId, string WeekDisplayName, long Amount, int Status, int? Method, string? Iban, string Created);
public enum WithdrawalMethodClient public enum WithdrawalMethodClient
{ {
Cash = 0, Cash = 0,
@@ -134,7 +134,8 @@ public class WalletService
return response.Models return response.Models
.Select(m => new WalletWithdrawal( .Select(m => new WalletWithdrawal(
m.Id, m.Id,
m.WeekNumber, m.WeekDefinitionId,
m.WeekDisplayName,
m.TotalAmount, m.TotalAmount,
m.Status, m.Status,
m.WithdrawalMethod, m.WithdrawalMethod,
+1
View File
@@ -12,6 +12,7 @@
@using MudBlazor @using MudBlazor
@using FrontOffice.Main.Utilities @using FrontOffice.Main.Utilities
@using DateTimeConverterCL @using DateTimeConverterCL
@using CityProto = FrontOffice.BFF.City.Protobuf
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject IDialogService DialogService @inject IDialogService DialogService
+3
View File
@@ -6,6 +6,9 @@
"Key": "kmcQ3XTmH4mrdh8VHziuscyf8LLYjG//Kyni81nH/0E=", "Key": "kmcQ3XTmH4mrdh8VHziuscyf8LLYjG//Kyni81nH/0E=",
"IV": "1wyF3Tt142MOkCpIyCxh/g==" "IV": "1wyF3Tt142MOkCpIyCxh/g=="
}, },
"SignalR": {
"HubPath": "/hubs/token-relay"
},
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "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;
}
};