diff --git a/src/BackOffice/Common/Configure/ConfigureService.cs b/src/BackOffice/Common/Configure/ConfigureService.cs index 455200a..cdea920 100644 --- a/src/BackOffice/Common/Configure/ConfigureService.cs +++ b/src/BackOffice/Common/Configure/ConfigureService.cs @@ -15,12 +15,15 @@ using BackOffice.BFF.Health.Protobuf; using BackOffice.BFF.DiscountProduct.Protobuf.Protos.DiscountProduct; using BackOffice.BFF.DiscountCategory.Protobuf.Protos.DiscountCategory; using BackOffice.BFF.DiscountOrder.Protobuf.Protos.DiscountOrder; +using BackOffice.BFF.Tag.Protobuf.Protos.Tag; +using BackOffice.BFF.ProductTag.Protobuf.Protos.ProductTag; using BackOffice.BFF.PublicMessage.Protobuf.Protos.PublicMessage; using BackOffice.Common.Utilities; using BackOffice.Services.DiscountProduct; using BackOffice.Services.DiscountCategory; using BackOffice.Services.DiscountOrder; using BackOffice.Services.PublicMessage; +using BackOffice.Services.Tag; using Blazored.LocalStorage; using Grpc.Core; using Grpc.Core.Interceptors; @@ -60,6 +63,7 @@ public static class ConfigureServices services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } @@ -111,6 +115,10 @@ public static class ConfigureServices // Public Message Service services.AddTransient(sp => new PublicMessagesContract.PublicMessagesContractClient(sp.GetRequiredService())); + // Tag Management Services + services.AddTransient(sp => new TagContract.TagContractClient(sp.GetRequiredService())); + services.AddTransient(sp => new ProductTagContract.ProductTagContractClient(sp.GetRequiredService())); + return services; } diff --git a/src/BackOffice/Common/Utilities/RouteConstance.cs b/src/BackOffice/Common/Utilities/RouteConstance.cs index a1a4102..97568b6 100644 --- a/src/BackOffice/Common/Utilities/RouteConstance.cs +++ b/src/BackOffice/Common/Utilities/RouteConstance.cs @@ -15,5 +15,6 @@ public static class RouteConstance public const string Products = "/ProductsPage/"; public const string Category = "/CategoryPage/"; public const string ProductCategories = "/ProductCategoriesPage/"; + public const string ProductsBulkEdit = "/ProductsBulkEditPage/"; public const string CategoryProducts = "/CategoryProductsPage/"; } diff --git a/src/BackOffice/Pages/Commission/Models/WithdrawalRequestModel.cs b/src/BackOffice/Pages/Commission/Models/WithdrawalRequestModel.cs index 3dd9365..770ea6d 100644 --- a/src/BackOffice/Pages/Commission/Models/WithdrawalRequestModel.cs +++ b/src/BackOffice/Pages/Commission/Models/WithdrawalRequestModel.cs @@ -15,4 +15,8 @@ public class WithdrawalRequestModel public DateTime RequestDate { get; set; } public DateTime? ProcessedDate { get; set; } public string? AdminNote { get; set; } + public string? BankReferenceId { get; set; } + public string? BankTrackingCode { get; set; } + public string? PaymentFailureReason { get; set; } + public string? IbanNumber { get; set; } } diff --git a/src/BackOffice/Pages/Commission/WithdrawalReports.razor b/src/BackOffice/Pages/Commission/WithdrawalReports.razor index f83d741..bfed7f7 100644 --- a/src/BackOffice/Pages/Commission/WithdrawalReports.razor +++ b/src/BackOffice/Pages/Commission/WithdrawalReports.razor @@ -4,6 +4,8 @@ @using MudBlazor @using BackOffice.BFF.Commission.Protobuf @using Google.Protobuf.WellKnownTypes +@using Microsoft.JSInterop +@using System.Text گزارش برداشت‌ها @@ -69,6 +71,21 @@ جستجو + + + خروجی Excel + + + خروجی PDF + + @@ -117,6 +134,37 @@ + + + + + نمودار مبلغ برداشت‌ها + + + + + + + + + + نمودار تعداد درخواست‌ها + + + + + + + + _periodReports = new(); + private string[] _chartLabels = Array.Empty(); + private List _amountSeries = new(); + private List _countSeries = new(); protected override async Task OnInitializedAsync() { @@ -258,12 +310,17 @@ PendingAmount = p.PendingAmount }) .ToList() ?? new List(); + + BuildCharts(); } catch (Exception ex) { Snackbar.Add($"خطا در بارگذاری گزارش‌ها: {ex.Message}", Severity.Error); _summary = new WithdrawalSummaryViewModel(); _periodReports.Clear(); + _chartLabels = Array.Empty(); + _amountSeries = new List(); + _countSeries = new List(); } finally { @@ -271,6 +328,141 @@ } } + private void BuildCharts() + { + if (_periodReports.Count == 0) + { + _chartLabels = Array.Empty(); + _amountSeries = new List(); + _countSeries = new List(); + return; + } + + var ordered = _periodReports.OrderBy(r => r.StartDate).ToList(); + _chartLabels = ordered.Select(r => r.PeriodLabel).ToArray(); + + _amountSeries = new List + { + new ChartSeries + { + Name = "مبلغ کل", + Data = ordered.Select(r => (double)r.TotalAmount).ToArray() + }, + new ChartSeries + { + Name = "پرداخت شده", + Data = ordered.Select(r => (double)r.PaidAmount).ToArray() + }, + new ChartSeries + { + Name = "در انتظار پرداخت", + Data = ordered.Select(r => (double)r.PendingAmount).ToArray() + } + }; + + _countSeries = new List + { + new ChartSeries + { + Name = "کل درخواست‌ها", + Data = ordered.Select(r => (double)r.TotalRequests).ToArray() + }, + new ChartSeries + { + Name = "موفق", + Data = ordered.Select(r => (double)r.CompletedCount).ToArray() + }, + new ChartSeries + { + Name = "ناموفق", + Data = ordered.Select(r => (double)r.FailedCount).ToArray() + } + }; + } + + private async Task ExportToExcel() + { + if (_periodReports.Count == 0) + { + Snackbar.Add("داده‌ای برای خروجی وجود ندارد.", Severity.Info); + return; + } + + var sb = new StringBuilder(); + sb.AppendLine("Period,StartDate,EndDate,TotalRequests,Completed,Failed,Pending,TotalAmount,PaidAmount,PendingAmount"); + + foreach (var r in _periodReports.OrderBy(r => r.StartDate)) + { + sb.AppendLine(string.Join(",", + EscapeCsv(r.PeriodLabel), + r.StartDate.ToString("yyyy-MM-dd"), + r.EndDate.ToString("yyyy-MM-dd"), + r.TotalRequests, + r.CompletedCount, + r.FailedCount, + r.PendingCount, + r.TotalAmount, + r.PaidAmount, + r.PendingAmount)); + } + + var bytes = Encoding.UTF8.GetBytes(sb.ToString()); + var base64 = Convert.ToBase64String(bytes); + var filename = $"withdrawal-reports-{DateTime.Now:yyyyMMddHHmmss}.csv"; + + await JsRuntime.InvokeVoidAsync("jsSaveAsFile", filename, base64); + Snackbar.Add("خروجی Excel (CSV) آماده شد.", Severity.Success); + } + + private async Task ExportToPdf() + { + if (_periodReports.Count == 0) + { + Snackbar.Add("داده‌ای برای خروجی وجود ندارد.", Severity.Info); + return; + } + + var sb = new StringBuilder(); + sb.AppendLine("گزارش برداشت‌ها"); + sb.AppendLine($"دوره: {_periodType} از تاریخ: {_startDate:yyyy/MM/dd} تا تاریخ: {_endDate:yyyy/MM/dd}"); + sb.AppendLine(); + + sb.AppendLine("خلاصه:"); + sb.AppendLine($"تعداد کل درخواست‌ها: {_summary.TotalRequests}"); + sb.AppendLine($"مبلغ کل برداشت‌ها: {_summary.TotalAmount:N0} ریال"); + sb.AppendLine($"مبلغ پرداخت شده: {_summary.TotalPaid:N0} ریال"); + sb.AppendLine($"مبلغ در انتظار: {_summary.TotalPending:N0} ریال"); + sb.AppendLine($"تعداد کاربران یکتا: {_summary.UniqueUsers}"); + sb.AppendLine($"نرخ موفقیت: {_summary.SuccessRate:N2}%"); + sb.AppendLine(); + + sb.AppendLine("جزئیات دوره‌ای:"); + sb.AppendLine("دوره | تاریخ شروع | تاریخ پایان | تعداد کل | موفق | ناموفق | در انتظار | مبلغ کل | پرداخت شده | در انتظار پرداخت"); + + foreach (var r in _periodReports.OrderBy(r => r.StartDate)) + { + sb.AppendLine( + $"{r.PeriodLabel} | {r.StartDate:yyyy/MM/dd} | {r.EndDate:yyyy/MM/dd} | {r.TotalRequests} | {r.CompletedCount} | {r.FailedCount} | {r.PendingCount} | {r.TotalAmount:N0} | {r.PaidAmount:N0} | {r.PendingAmount:N0}"); + } + + var bytes = Encoding.UTF8.GetBytes(sb.ToString()); + var base64 = Convert.ToBase64String(bytes); + var filename = $"withdrawal-reports-{DateTime.Now:yyyyMMddHHmmss}.txt"; + + await JsRuntime.InvokeVoidAsync("jsSaveAsFile", filename, base64); + Snackbar.Add("خروجی PDF (نسخه متنی) آماده شد.", Severity.Success); + } + + private static string EscapeCsv(string value) + { + if (string.IsNullOrEmpty(value)) + return ""; + + var needsQuotes = value.Contains(',') || value.Contains('"') || value.Contains('\n'); + var escaped = value.Replace("\"", "\"\""); + return needsQuotes ? $"\"{escaped}\"" : escaped; + } + private enum PeriodType { Daily = 1, @@ -306,4 +498,3 @@ public long PendingAmount { get; set; } } } - diff --git a/src/BackOffice/Pages/Commission/WithdrawalRequests.razor b/src/BackOffice/Pages/Commission/WithdrawalRequests.razor index 2813c44..ab143aa 100644 --- a/src/BackOffice/Pages/Commission/WithdrawalRequests.razor +++ b/src/BackOffice/Pages/Commission/WithdrawalRequests.razor @@ -15,16 +15,33 @@ درخواست‌های برداشت + + همه - در انتظار - تایید شده - رد شده - پردازش شده + در انتظار واریز + واریز شده به کیف پول + درخواست برداشت + برداشت شده + خطای پرداخت بانکی + لغو شده + + + + + + + @if (!string.IsNullOrWhiteSpace(context.Item.PaymentFailureReason)) + { + @context.Item.PaymentFailureReason + } + + + + + @context.Item.RequestedAt.ToDateTime().ToLocalTime().ToString("yyyy/MM/dd HH:mm") @@ -87,7 +119,7 @@ Color="Color.Info" OnClick="@(() => ViewDetails(context.Item))" /> - @if (context.Item.Status == 0) + @if (context.Item.Status == 2) { } - @if (context.Item.Status == 1) + @if (context.Item.Status == 2) { _gridData; private int? _filterStatus; + private long? _filterUserId; + private string? _filterIban; private async Task> ServerReload(GridState state) { try { - // TODO: Implement GetWithdrawalRequestsRequest in CMS Protobuf - /* - var request = new GetWithdrawalRequestsRequest + var request = new GrpcGetWithdrawalRequestsRequest { PageIndex = state.Page + 1, PageSize = state.PageSize @@ -28,19 +31,26 @@ public partial class WithdrawalRequests request.Status = _filterStatus.Value; } + if (_filterUserId.HasValue && _filterUserId.Value > 0) + { + request.UserId = _filterUserId.Value; + } + + if (!string.IsNullOrWhiteSpace(_filterIban)) + { + request.IbanNumber = _filterIban; + } + var result = await CommissionContract.GetWithdrawalRequestsAsync(request); - */ - - // Mock data until API is ready - await Task.CompletedTask; - var result = new { Models = new List(), TotalCount = 0 }; if (result?.Models != null && result.Models.Any()) { + var items = result.Models.Select(MapToViewModel).ToList(); + return new GridData { - Items = result.Models.ToList(), - TotalItems = result.TotalCount + Items = items, + TotalItems = result.MetaData != null ? (int)result.MetaData.TotalCount : items.Count }; } @@ -53,6 +63,39 @@ public partial class WithdrawalRequests } } + private static WithdrawalRequestModel MapToViewModel(GrpcWithdrawalRequestModel grpcModel) + { + var requestedAt = grpcModel.RequestedAt ?? grpcModel.Created ?? new Timestamp(); + + return new WithdrawalRequestModel + { + Id = grpcModel.Id, + UserId = grpcModel.UserId, + UserName = grpcModel.UserName, + Amount = grpcModel.Amount, + Status = grpcModel.Status, + Method = MapMethod(grpcModel.WithdrawalMethod), + RequestedAt = requestedAt, + RequestDate = requestedAt.ToDateTime(), + ProcessedDate = grpcModel.ProcessedAt?.ToDateTime(), + AdminNote = grpcModel.Reason, + BankReferenceId = grpcModel.BankReferenceId, + BankTrackingCode = grpcModel.BankTrackingCode, + PaymentFailureReason = grpcModel.PaymentFailureReason, + IbanNumber = grpcModel.IbanNumber + }; + } + + private static string MapMethod(int withdrawalMethod) + { + return withdrawalMethod switch + { + 0 => "Cash", + 1 => "Diamond", + _ => "Unknown" + }; + } + private async Task ApplyFilter() { if (_gridData != null) @@ -65,10 +108,12 @@ public partial class WithdrawalRequests { return status switch { - 0 => Color.Warning, // Pending - 1 => Color.Success, // Approved - 2 => Color.Error, // Rejected - 3 => Color.Info, // Processed + 0 => Color.Warning, // Pending + 1 => Color.Info, // Paid to wallet + 2 => Color.Warning, // WithdrawRequested + 3 => Color.Success, // Withdrawn + 4 => Color.Error, // PaymentFailed + 5 => Color.Default, // Cancelled _ => Color.Default }; } @@ -77,10 +122,12 @@ public partial class WithdrawalRequests { return status switch { - 0 => "در انتظار", - 1 => "تایید شده", - 2 => "رد شده", - 3 => "پردازش شده", + 0 => "در انتظار واریز", + 1 => "واریز شده", + 2 => "درخواست برداشت", + 3 => "برداشت شده", + 4 => "خطای پرداخت بانکی", + 5 => "لغو شده", _ => "نامشخص" }; } @@ -89,9 +136,8 @@ public partial class WithdrawalRequests { return method switch { - "Bank" => "انتقال بانکی", - "Crypto" => "ارز دیجیتال", - "Cash" => "نقدی", + "Cash" => "واریز بانکی/نقدی", + "Diamond" => "الماس شبکه", _ => "نامشخص" }; } @@ -113,7 +159,12 @@ public partial class WithdrawalRequests { try { - // TODO: Call ApproveWithdrawal API + var approveRequest = new ApproveWithdrawalRequest + { + PayoutId = request.Id + }; + + await CommissionContract.ApproveWithdrawalAsync(approveRequest); Snackbar.Add("درخواست با موفقیت تایید شد", Severity.Success); await ApplyFilter(); } @@ -135,7 +186,13 @@ public partial class WithdrawalRequests { try { - // TODO: Call RejectWithdrawal API + var rejectRequest = new RejectWithdrawalRequest + { + PayoutId = request.Id, + Reason = "رد توسط ادمین پنل مدیریت" + }; + + await CommissionContract.RejectWithdrawalAsync(rejectRequest); Snackbar.Add("درخواست رد شد", Severity.Warning); await ApplyFilter(); } @@ -157,7 +214,13 @@ public partial class WithdrawalRequests { try { - // TODO: Call ProcessWithdrawal API + var processRequest = new ProcessWithdrawalRequest + { + PayoutId = request.Id, + IsApproved = true + }; + + await CommissionContract.ProcessWithdrawalAsync(processRequest); Snackbar.Add("درخواست با موفقیت پردازش شد", Severity.Success); await ApplyFilter(); } diff --git a/src/BackOffice/Pages/Dashboard/DiscountShopWidget.razor b/src/BackOffice/Pages/Dashboard/DiscountShopWidget.razor new file mode 100644 index 0000000..5482b5e --- /dev/null +++ b/src/BackOffice/Pages/Dashboard/DiscountShopWidget.razor @@ -0,0 +1,160 @@ +@using BackOffice.Services.DiscountOrder + +@inject IDiscountOrderService DiscountOrderService + + + + + + + آمار فروشگاه تخفیفی (۷ روز اخیر) + + + + + @if (_loading) + { + + } + else + { + + + + تعداد سفارش‌ها (۷ روز) + @_stats.TotalOrdersLast7Days.ToString("N0") + + امروز: @_stats.TodayOrders.ToString("N0") + + + + + + مجموع فروش (۷ روز) + + @_stats.TotalSalesLast7Days.ToString("N0") ریال + + + امروز: @_stats.TodaySales.ToString("N0") ریال + + + + + + میانگین مبلغ هر سفارش + + @_stats.AverageOrderAmount.ToString("N0") ریال + + + + + + + + روند فروش روزانه (۷ روز اخیر) + @if (_dailyLabels.Length == 0) + { + + برای این بازه زمانی سفارشی ثبت نشده است. + + } + else + { + + } + } + + + +@code { + private bool _loading; + private DiscountShopStats _stats = new(); + private string[] _dailyLabels = Array.Empty(); + private List _dailySeries = new(); + + protected override async Task OnInitializedAsync() + { + await LoadStatsAsync(); + } + + private async Task LoadStatsAsync() + { + _loading = true; + try + { + var today = DateTime.Today; + var fromDate = today.AddDays(-6); + + var filter = new OrderFilterDto + { + FromDate = fromDate, + ToDate = today, + PageNumber = 1, + PageSize = 200 + }; + + var orders = await DiscountOrderService.GetOrdersAsync(filter); + + _stats = new DiscountShopStats(); + + if (orders.Count > 0) + { + _stats.TotalOrdersLast7Days = orders.Count; + _stats.TotalSalesLast7Days = orders.Sum(o => o.FinalAmount); + + _stats.TodayOrders = orders.Count(o => o.CreatedAt.Date == today); + _stats.TodaySales = orders.Where(o => o.CreatedAt.Date == today) + .Sum(o => o.FinalAmount); + + _stats.AverageOrderAmount = _stats.TotalOrdersLast7Days > 0 + ? _stats.TotalSalesLast7Days / _stats.TotalOrdersLast7Days + : 0; + + var groups = orders + .GroupBy(o => o.CreatedAt.Date) + .OrderBy(g => g.Key) + .ToList(); + + _dailyLabels = groups.Select(g => g.Key.ToString("MM/dd")).ToArray(); + + _dailySeries = new List + { + new() + { + Name = "مبلغ فروش", + Data = groups.Select(g => (double)g.Sum(o => o.FinalAmount)).ToArray() + } + }; + } + else + { + _dailyLabels = Array.Empty(); + _dailySeries = new List(); + } + } + catch (Exception ex) + { + Snackbar.Add($"خطا در بارگذاری آمار فروشگاه تخفیفی: {ex.Message}", Severity.Warning); + _stats = new DiscountShopStats(); + _dailyLabels = Array.Empty(); + _dailySeries = new List(); + } + finally + { + _loading = false; + } + } + + private class DiscountShopStats + { + public int TotalOrdersLast7Days { get; set; } + public long TotalSalesLast7Days { get; set; } + public int TodayOrders { get; set; } + public long TodaySales { get; set; } + public long AverageOrderAmount { get; set; } + } +} + diff --git a/src/BackOffice/Pages/Dashboard/SystemOverview.razor b/src/BackOffice/Pages/Dashboard/SystemOverview.razor index dc24811..e8b609d 100644 --- a/src/BackOffice/Pages/Dashboard/SystemOverview.razor +++ b/src/BackOffice/Pages/Dashboard/SystemOverview.razor @@ -140,6 +140,9 @@ + + + diff --git a/src/BackOffice/Pages/DiscountShop/Components/ProductImageGallery.razor b/src/BackOffice/Pages/DiscountShop/Components/ProductImageGallery.razor new file mode 100644 index 0000000..99e6dd8 --- /dev/null +++ b/src/BackOffice/Pages/DiscountShop/Components/ProductImageGallery.razor @@ -0,0 +1,167 @@ +@using Microsoft.AspNetCore.Components.Forms + + + گالری تصاویر محصول + + + + + انتخاب تصاویر (چندتایی) + + + + + @if (_items.Count == 0) + { + + هنوز تصویری اضافه نشده است. + + } + else + { + + @foreach (var item in _items) + { + + +
+ @item.Title + +
+ +
+
+ } +
+ } +
+ +@code { + [Parameter] public List Images { get; set; } = new(); + [Parameter] public EventCallback> ImagesChanged { get; set; } + + private readonly List _items = new(); + private ProductImageItem? _dragging; + private readonly long _maxAllowedSize = (1024 * 1024) * 5; + + protected override void OnInitialized() + { + if (Images != null && Images.Count > 0) + { + _items.AddRange(Images.Select(x => new ProductImageItem + { + Id = x.Id == Guid.Empty ? Guid.NewGuid() : x.Id, + Title = x.Title, + PreviewUrl = x.PreviewUrl, + SortOrder = x.SortOrder + }) + .OrderBy(x => x.SortOrder)); + } + } + + private async Task OnFilesSelected(IReadOnlyList files) + { + foreach (var file in files) + { + if (file == null) continue; + + var buffer = new byte[file.Size]; + await file.OpenReadStream(_maxAllowedSize).ReadAsync(buffer); + var preview = $"data:{file.ContentType};base64," + Convert.ToBase64String(buffer); + + _items.Add(new ProductImageItem + { + Id = Guid.NewGuid(), + Title = file.Name, + PreviewUrl = preview, + SortOrder = _items.Count + }); + } + + await OnItemsChanged(); + } + + private async Task OnItemsChanged() + { + if (ImagesChanged.HasDelegate) + { + var ordered = _items + .Select((x, index) => + { + x.SortOrder = index; + return x; + }) + .ToList(); + + await ImagesChanged.InvokeAsync(ordered); + } + } + + private void OnDragStart(ProductImageItem item) + { + _dragging = item; + } + + private void OnDragOver(DragEventArgs args) + { + args.PreventDefault(); + } + + private async void OnDrop(ProductImageItem target) + { + if (_dragging == null || ReferenceEquals(_dragging, target)) + return; + + var fromIndex = _items.IndexOf(_dragging); + var toIndex = _items.IndexOf(target); + + if (fromIndex < 0 || toIndex < 0 || fromIndex == toIndex) + return; + + _items.RemoveAt(fromIndex); + _items.Insert(toIndex, _dragging); + _dragging = null; + + await OnItemsChanged(); + StateHasChanged(); + } + + private async void Remove(ProductImageItem item) + { + _items.Remove(item); + await OnItemsChanged(); + StateHasChanged(); + } + + public class ProductImageItem + { + public Guid Id { get; set; } + public string Title { get; set; } = string.Empty; + public string PreviewUrl { get; set; } = string.Empty; + public int SortOrder { get; set; } + } +} + diff --git a/src/BackOffice/Pages/DiscountShop/SalesReports.razor b/src/BackOffice/Pages/DiscountShop/SalesReports.razor new file mode 100644 index 0000000..946cc6b --- /dev/null +++ b/src/BackOffice/Pages/DiscountShop/SalesReports.razor @@ -0,0 +1,505 @@ +@page "/discount-sales-reports" +@attribute [Authorize(Roles = "Administrator")] + +@using MudBlazor +@using BackOffice.Services.DiscountOrder +@using Microsoft.JSInterop +@using System.Text + +@inject IDiscountOrderService DiscountOrderService +@inject ISnackbar Snackbar +@inject IJSRuntime JsRuntime + + + + + گزارش فروش فروشگاه تخفیفی + + + آمار فروش، تخفیف و وضعیت سفارش‌های فروشگاه تخفیفی بر اساس بازه تاریخ و وضعیت سفارش + + + + + + + + + + + + + همه وضعیت‌ها + در انتظار + پرداخت شده + در حال پردازش + ارسال شده + تحویل شده + لغو شده + مرجوع شده + + + + + + + + + + + بارگذاری گزارش + + + + + خروجی Excel + + + خروجی PDF + + + + + + @if (_loading) + { + + + + } + else if (_orders.Count == 0) + { + + هیچ سفارشی برای بازه و فیلترهای انتخاب‌شده یافت نشد. + + } + else + { + + + + تعداد سفارش‌ها + @_totalOrders.ToString("N0") + + + + + مجموع فروش (مبلغ نهایی) + @_totalSales.ToString("N0") ریال + + + + + مجموع تخفیف + @_totalDiscount.ToString("N0") ریال + + + + + میانگین مبلغ سفارش + @_averageOrder.ToString("N0") ریال + + + + + + + + + روند فروش + + + + + + + + + + محصولات پرفروش (بر اساس مبلغ) + + + @if (_topProductLabels.Length == 0) + { + + برای محاسبه محصولات پرفروش، تعداد سفارش‌ها کافی نیست یا خطایی رخ داده است. + + } + else + { + + } + + + + + + + + لیست سفارش‌ها + + + + + + + + + + @context.Item.CreatedAt.ToString("yyyy/MM/dd HH:mm") + + + + + @context.Item.FinalAmount.ToString("N0") ریال + + + + + @context.Item.TotalDiscount.ToString("N0") ریال + + + + + + @GetStatusText(context.Item.Status) + + + + + + + + } + + +@code { + private readonly List _orders = new(); + private bool _loading; + + private DateTime? _fromDate = DateTime.Today.AddDays(-30); + private DateTime? _toDate = DateTime.Today; + private OrderStatus? _statusFilter; + private string? _searchQuery; + + private int _totalOrders; + private long _totalSales; + private long _totalDiscount; + private long _averageOrder; + + private string[] _salesLabels = Array.Empty(); + private List _salesSeries = new(); + + private string[] _topProductLabels = Array.Empty(); + private List _topProductSeries = new(); + + protected override async Task OnInitializedAsync() + { + await LoadReports(); + } + + private async Task LoadReports() + { + if (_fromDate.HasValue && _toDate.HasValue && _fromDate.Value.Date > _toDate.Value.Date) + { + Snackbar.Add("تاریخ شروع نباید بعد از تاریخ پایان باشد.", Severity.Warning); + return; + } + + _loading = true; + _orders.Clear(); + + try + { + var filter = new OrderFilterDto + { + SearchQuery = _searchQuery, + Status = _statusFilter, + FromDate = _fromDate, + ToDate = _toDate, + PageNumber = 1, + PageSize = 200 + }; + + var result = await DiscountOrderService.GetOrdersAsync(filter); + _orders.AddRange(result); + + BuildSummary(); + BuildSalesChart(); + await BuildTopProductsChart(); + } + catch (Exception ex) + { + Snackbar.Add($"خطا در بارگذاری گزارش فروش: {ex.Message}", Severity.Error); + _orders.Clear(); + _totalOrders = 0; + _totalSales = 0; + _totalDiscount = 0; + _averageOrder = 0; + _salesLabels = Array.Empty(); + _salesSeries = new List(); + _topProductLabels = Array.Empty(); + _topProductSeries = new List(); + } + finally + { + _loading = false; + } + } + + private void BuildSummary() + { + _totalOrders = _orders.Count; + _totalSales = _orders.Sum(o => o.FinalAmount); + _totalDiscount = _orders.Sum(o => o.TotalDiscount); + _averageOrder = _totalOrders > 0 ? _totalSales / _totalOrders : 0; + } + + private void BuildSalesChart() + { + if (_orders.Count == 0) + { + _salesLabels = Array.Empty(); + _salesSeries = new List(); + return; + } + + var grouped = _orders + .GroupBy(o => o.CreatedAt.Date) + .OrderBy(g => g.Key) + .ToList(); + + _salesLabels = grouped + .Select(g => g.Key.ToString("yyyy/MM/dd")) + .ToArray(); + + var totalSeries = grouped + .Select(g => (double)g.Sum(o => o.FinalAmount)) + .ToArray(); + + var discountSeries = grouped + .Select(g => (double)g.Sum(o => o.TotalDiscount)) + .ToArray(); + + _salesSeries = new List + { + new ChartSeries { Name = "مبلغ نهایی", Data = totalSeries }, + new ChartSeries { Name = "تخفیف", Data = discountSeries } + }; + } + + private async Task BuildTopProductsChart() + { + _topProductLabels = Array.Empty(); + _topProductSeries = new List(); + + if (_orders.Count == 0) + return; + + // برای جلوگیری از فشار بیش‌ازحد، فقط روی 50 سفارش اول کار می‌کنیم + var sampleOrders = _orders + .OrderByDescending(o => o.CreatedAt) + .Take(50) + .ToList(); + + var productTotals = new Dictionary(); + + foreach (var order in sampleOrders) + { + DiscountOrderDetailsDto? details; + try + { + details = await DiscountOrderService.GetByIdAsync(order.OrderId); + } + catch + { + continue; + } + + if (details?.Items == null) + continue; + + foreach (var item in details.Items) + { + if (!productTotals.TryGetValue(item.ProductId, out var existing)) + { + productTotals[item.ProductId] = (item.ProductTitle, item.TotalPrice); + } + else + { + productTotals[item.ProductId] = (existing.Name, existing.Amount + item.TotalPrice); + } + } + } + + if (productTotals.Count == 0) + return; + + var top = productTotals.Values + .OrderByDescending(x => x.Amount) + .Take(5) + .ToList(); + + _topProductLabels = top + .Select(x => x.Name) + .ToArray(); + + _topProductSeries = new List + { + new ChartSeries + { + Name = "مبلغ فروش", + Data = top.Select(x => (double)x.Amount).ToArray() + } + }; + } + + private Color GetStatusColor(OrderStatus status) + { + return status switch + { + OrderStatus.Pending => Color.Warning, + OrderStatus.Paid => Color.Success, + OrderStatus.Processing => Color.Info, + OrderStatus.Shipped => Color.Info, + OrderStatus.Delivered => Color.Success, + OrderStatus.Cancelled => Color.Error, + OrderStatus.Returned => Color.Error, + _ => Color.Default + }; + } + + private string GetStatusText(OrderStatus status) + { + return status switch + { + OrderStatus.Pending => "در انتظار", + OrderStatus.Paid => "پرداخت شده", + OrderStatus.Processing => "در حال پردازش", + OrderStatus.Shipped => "ارسال شده", + OrderStatus.Delivered => "تحویل شده", + OrderStatus.Cancelled => "لغو شده", + OrderStatus.Returned => "مرجوع شده", + _ => "نامشخص" + }; + } + + private async Task ExportToExcel() + { + if (_orders.Count == 0) + { + Snackbar.Add("داده‌ای برای خروجی وجود ندارد.", Severity.Info); + return; + } + + var sb = new StringBuilder(); + sb.AppendLine("OrderId,UserId,UserFullName,CreatedAt,FinalAmount,TotalDiscount,Status"); + + foreach (var o in _orders.OrderBy(o => o.CreatedAt)) + { + sb.AppendLine(string.Join(",", + o.OrderId, + o.UserId, + EscapeCsv(o.UserFullName), + o.CreatedAt.ToString("yyyy-MM-dd HH:mm"), + o.FinalAmount, + o.TotalDiscount, + GetStatusText(o.Status))); + } + + var bytes = Encoding.UTF8.GetBytes(sb.ToString()); + var base64 = Convert.ToBase64String(bytes); + var filename = $"discount-sales-{DateTime.Now:yyyyMMddHHmmss}.csv"; + + await JsRuntime.InvokeVoidAsync("jsSaveAsFile", filename, base64); + Snackbar.Add("خروجی Excel (CSV) آماده شد.", Severity.Success); + } + + private async Task ExportToPdf() + { + if (_orders.Count == 0) + { + Snackbar.Add("داده‌ای برای خروجی وجود ندارد.", Severity.Info); + return; + } + + var sb = new StringBuilder(); + sb.AppendLine("گزارش فروش فروشگاه تخفیفی"); + sb.AppendLine($"بازه: از {_fromDate:yyyy/MM/dd} تا {_toDate:yyyy/MM/dd}"); + sb.AppendLine(); + + sb.AppendLine("خلاصه:"); + sb.AppendLine($"تعداد سفارش‌ها: {_totalOrders}"); + sb.AppendLine($"مجموع فروش (مبلغ نهایی): {_totalSales:N0} ریال"); + sb.AppendLine($"مجموع تخفیف: {_totalDiscount:N0} ریال"); + sb.AppendLine($"میانگین مبلغ سفارش: {_averageOrder:N0} ریال"); + sb.AppendLine(); + + sb.AppendLine("جزئیات سفارش‌ها:"); + sb.AppendLine("شناسه | کاربر | تاریخ | مبلغ نهایی | تخفیف | وضعیت"); + + foreach (var o in _orders.OrderBy(o => o.CreatedAt)) + { + sb.AppendLine( + $"{o.OrderId} | {o.UserFullName} | {o.CreatedAt:yyyy/MM/dd HH:mm} | {o.FinalAmount:N0} | {o.TotalDiscount:N0} | {GetStatusText(o.Status)}"); + } + + var bytes = Encoding.UTF8.GetBytes(sb.ToString()); + var base64 = Convert.ToBase64String(bytes); + var filename = $"discount-sales-{DateTime.Now:yyyyMMddHHmmss}.txt"; + + await JsRuntime.InvokeVoidAsync("jsSaveAsFile", filename, base64); + Snackbar.Add("خروجی PDF (نسخه متنی) آماده شد.", Severity.Success); + } + + private static string EscapeCsv(string value) + { + if (string.IsNullOrEmpty(value)) + return ""; + + var needsQuotes = value.Contains(',') || value.Contains('"') || value.Contains('\n'); + var escaped = value.Replace("\"", "\"\""); + return needsQuotes ? $"\"{escaped}\"" : escaped; + } +} diff --git a/src/BackOffice/Pages/Products/BulkEdit.razor b/src/BackOffice/Pages/Products/BulkEdit.razor new file mode 100644 index 0000000..4f911a4 --- /dev/null +++ b/src/BackOffice/Pages/Products/BulkEdit.razor @@ -0,0 +1,191 @@ +@attribute [Route(RouteConstance.ProductsBulkEdit)] + +@using BackOffice.BFF.Products.Protobuf.Protos.Products +@using BackOffice.Common.BaseComponents +@using DataModel = BackOffice.BFF.Products.Protobuf.Protos.Products.GetAllProductsByFilterResponseModel + + + + + + + + + + + + + + + + + + ویرایش گروهی محصولات + + انتخاب‌شده‌ها: @_selectedProductIds.Count + + + + + + + + + + + + + لیست محصولات برای ویرایش گروهی + + + + + + + + + + + + + + + + + + + + + + + + + + تنظیمات ویرایش گروهی + + + + قیمت و تخفیف + + + + + + + + + + موجودی + + + تنظیم روی مقدار ثابت + افزایش به مقدار فعلی + کاهش از مقدار فعلی + + + + + + + وضعیت + + + فعال‌سازی + غیرفعال‌سازی + + + + + پاک کردن فرم + + + اعمال تغییرات + + + + @if (_isApplying) + { + + } + + + + + + + diff --git a/src/BackOffice/Pages/Products/BulkEdit.razor.cs b/src/BackOffice/Pages/Products/BulkEdit.razor.cs new file mode 100644 index 0000000..2e37b4c --- /dev/null +++ b/src/BackOffice/Pages/Products/BulkEdit.razor.cs @@ -0,0 +1,289 @@ +using BackOffice.BFF.Products.Protobuf.Protos.Products; +using CMSMicroservice.Protobuf.Protos; +using Google.Protobuf.WellKnownTypes; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using DataModel = BackOffice.BFF.Products.Protobuf.Protos.Products.GetAllProductsByFilterResponseModel; + +namespace BackOffice.Pages.Products; + +public partial class BulkEdit +{ + [Inject] public ProductsContract.ProductsContractClient ProductsContract { get; set; } = default!; + + private BackOffice.Common.BaseComponents.BasePageComponent _basePage = default!; + private MudDataGrid _gridData = default!; + + private GetAllProductsByFilterRequest _request = new() { Filter = new() }; + private readonly HashSet _selectedProductIds = new(); + + private bool _isLoading; + private bool _isApplying; + + private long? _newPrice; + private int? _newDiscount; + private int? _newClubDiscountPercent; + + private StockUpdateType? _stockUpdateType; + private int? _stockQuantity; + + private bool? _statusEnable; + + private async Task> ServerReload(GridState state) + { + _isLoading = true; + StateHasChanged(); + + _selectedProductIds.Clear(); + + _request.Filter ??= new(); + _request.PaginationState ??= new PaginationState(); + _request.PaginationState.PageNumber = state.Page + 1; + _request.PaginationState.PageSize = state.PageSize; + + var result = await ProductsContract.GetAllProductsByFilterAsync(_request); + + _isLoading = false; + StateHasChanged(); + + if (result != null && result.Models != null && result.Models.Any()) + { + return new GridData + { + Items = result.Models.ToList(), + TotalItems = (int)result.MetaData.TotalCount + }; + } + + return new GridData(); + } + + private void ToggleSelection(long id, bool isSelected) + { + if (isSelected) + { + _selectedProductIds.Add(id); + } + else + { + _selectedProductIds.Remove(id); + } + } + + public async Task OnFilterSubmit() + { + _basePage.IsFiltered = true; + StateHasChanged(); + await ReloadData(); + } + + public async Task OnFilterCleared() + { + _basePage.IsFiltered = false; + StateHasChanged(); + _request = new GetAllProductsByFilterRequest { Filter = new() }; + await ReloadData(); + } + + private async Task ReloadData() + { + if (_gridData != null) + { + await _gridData.ReloadServerData(); + } + } + + private void ClearForm() + { + _newPrice = null; + _newDiscount = null; + _newClubDiscountPercent = null; + _stockUpdateType = null; + _stockQuantity = null; + _statusEnable = null; + } + + private async Task ApplyBulkChanges() + { + if (_selectedProductIds.Count == 0) + { + Snackbar.Add("هیچ محصولی برای ویرایش انتخاب نشده است.", Severity.Warning); + return; + } + + var hasPriceChanges = _newPrice.HasValue; + var hasStockChanges = _stockUpdateType.HasValue && _stockQuantity.HasValue && _stockQuantity.Value > 0; + var hasStatusChange = _statusEnable.HasValue; + + if (!hasPriceChanges && !hasStockChanges && !hasStatusChange) + { + Snackbar.Add("هیچ تغییری برای اعمال انتخاب نشده است.", Severity.Warning); + return; + } + + var confirmationText = BuildConfirmationText(hasPriceChanges, hasStockChanges, hasStatusChange); + + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small }; + bool? confirmed = await DialogService.ShowMessageBox( + "تأیید ویرایش گروهی", + confirmationText, + yesText: "اعمال تغییرات", + cancelText: "لغو", + options: options); + + if (confirmed != true) + return; + + _isApplying = true; + StateHasChanged(); + + try + { + int total = 0; + int succeeded = 0; + int failed = 0; + + if (hasPriceChanges) + { + var priceRequest = new BulkUpdateProductPricesRequest(); + + foreach (var id in _selectedProductIds) + { + var update = new ProductPriceUpdate + { + ProductId = id, + NewPrice = _newPrice!.Value + }; + + if (_newDiscount.HasValue) + { + update.NewDiscount = new Int32Value { Value = _newDiscount.Value }; + } + + if (_newClubDiscountPercent.HasValue) + { + update.NewClubDiscountPercent = new Int32Value { Value = _newClubDiscountPercent.Value }; + } + + priceRequest.Products.Add(update); + } + + var priceResponse = await ProductsContract.BulkUpdateProductPricesAsync(priceRequest); + + if (priceResponse != null) + { + total += priceResponse.Total; + succeeded += priceResponse.Succeeded; + failed += priceResponse.Failed; + } + } + + if (hasStockChanges) + { + var stockRequest = new BulkUpdateProductStockRequest + { + UpdateType = _stockUpdateType!.Value + }; + + foreach (var id in _selectedProductIds) + { + stockRequest.Products.Add(new ProductStockUpdate + { + ProductId = id, + Quantity = _stockQuantity!.Value + }); + } + + var stockResponse = await ProductsContract.BulkUpdateProductStockAsync(stockRequest); + + if (stockResponse != null) + { + total += stockResponse.Total; + succeeded += stockResponse.Succeeded; + failed += stockResponse.Failed; + } + } + + if (hasStatusChange) + { + var toggleRequest = new ToggleProductStatusRequest + { + Enable = _statusEnable!.Value, + DefaultStock = 0 + }; + + toggleRequest.ProductIds.AddRange(_selectedProductIds); + + var toggleResponse = await ProductsContract.ToggleProductStatusAsync(toggleRequest); + + if (toggleResponse != null) + { + total += toggleResponse.Total; + succeeded += toggleResponse.Succeeded; + failed += toggleResponse.Failed; + } + } + + if (failed == 0) + { + Snackbar.Add($"تغییرات گروهی با موفقیت برای {succeeded} مورد اعمال شد.", Severity.Success); + } + else + { + Snackbar.Add($"تغییرات گروهی انجام شد. موفق: {succeeded} / ناموفق: {failed}", Severity.Warning); + } + + await ReloadData(); + _selectedProductIds.Clear(); + } + catch (Exception ex) + { + Snackbar.Add($"خطا در اعمال تغییرات گروهی: {ex.Message}", Severity.Error); + } + finally + { + _isApplying = false; + StateHasChanged(); + } + } + + private string BuildConfirmationText(bool hasPriceChanges, bool hasStockChanges, bool hasStatusChange) + { + var parts = new List(); + + if (hasPriceChanges) + { + var pricePart = $"• قیمت جدید: {_newPrice?.ToString("N0") ?? "-"}"; + + if (_newDiscount.HasValue) + pricePart += $", تخفیف: {_newDiscount.Value}%"; + + if (_newClubDiscountPercent.HasValue) + pricePart += $", تخفیف باشگاه: {_newClubDiscountPercent.Value}%"; + + parts.Add(pricePart); + } + + if (hasStockChanges) + { + var stockAction = _stockUpdateType switch + { + StockUpdateType.Set => "تنظیم موجودی روی", + StockUpdateType.Add => "افزایش موجودی به میزان", + StockUpdateType.Subtract => "کاهش موجودی به میزان", + _ => "تغییر موجودی" + }; + + parts.Add($"• {stockAction} {_stockQuantity}"); + } + + if (hasStatusChange) + { + var statusText = _statusEnable == true ? "فعال‌سازی" : "غیرفعال‌سازی"; + parts.Add($"• {statusText} محصولات انتخاب‌شده"); + } + + return $"آیا از اعمال تغییرات زیر برای {_selectedProductIds.Count} محصول انتخاب‌شده مطمئن هستید؟\n\n{string.Join("\n", parts)}"; + } +} + diff --git a/src/BackOffice/Pages/Products/ProductsMainPage.razor b/src/BackOffice/Pages/Products/ProductsMainPage.razor index 2f86167..979f188 100644 --- a/src/BackOffice/Pages/Products/ProductsMainPage.razor +++ b/src/BackOffice/Pages/Products/ProductsMainPage.razor @@ -7,7 +7,36 @@ - + + + + + + + مدیریت محصولات + @if (_selectedProductIds.Count > 0) + { + + حذف انتخاب‌شده‌ها (@_selectedProductIds.Count) + + + + فعال‌سازی انتخاب‌شده‌ها + + + + غیرفعال‌سازی انتخاب‌شده‌ها + + } + + ویرایش گروهی + + + خروجی Excel + افزودن + + + + + + @@ -81,6 +164,14 @@ OnClick="@(() => OpenCategoryMapping(context.Item))" Style="cursor:pointer;" /> + + + + diff --git a/src/BackOffice/Pages/Products/ProductsMainPage.razor.cs b/src/BackOffice/Pages/Products/ProductsMainPage.razor.cs index 8bc903e..4c8407e 100644 --- a/src/BackOffice/Pages/Products/ProductsMainPage.razor.cs +++ b/src/BackOffice/Pages/Products/ProductsMainPage.razor.cs @@ -1,10 +1,13 @@ using BackOffice.BFF.Products.Protobuf.Protos.Products; using BackOffice.Common.BaseComponents; using BackOffice.Common.Utilities; +using BackOffice.Pages.Tag.Components; using BackOffice.Pages.Products.Components; using Mapster; using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; using MudBlazor; +using System.Text; using DataModel = BackOffice.BFF.Products.Protobuf.Protos.Products.GetAllProductsByFilterResponseModel; namespace BackOffice.Pages.Products; @@ -12,14 +15,18 @@ namespace BackOffice.Pages.Products; public partial class ProductsMainPage { [Inject] public ProductsContract.ProductsContractClient ProductsContract { get; set; } = default!; + [Inject] public IJSRuntime JsRuntime { get; set; } = default!; private bool _isLoading = true; private MudDataGrid _gridData; private BasePageComponent _basePage; private GetAllProductsByFilterRequest _request = new() { Filter = new() }; + private readonly HashSet _selectedProductIds = new(); private async Task> ServerReload(GridState state) { + _selectedProductIds.Clear(); + _request.Filter ??= new(); _request.PaginationState ??= new(); _request.PaginationState.PageNumber = state.Page + 1; @@ -34,6 +41,126 @@ public partial class ProductsMainPage return new GridData(); } + private void ToggleSelection(long id, bool isSelected) + { + if (isSelected) + { + _selectedProductIds.Add(id); + } + else + { + _selectedProductIds.Remove(id); + } + } + + private async Task BulkDelete() + { + if (_selectedProductIds.Count == 0) + return; + + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small }; + bool? result = await DialogService.ShowMessageBox( + "اخطار", + $"آیا از حذف { _selectedProductIds.Count } محصول انتخاب‌شده مطمئن هستید؟", + yesText: "حذف", cancelText: "لغو", + options: options); + + if (result == true) + { + foreach (var id in _selectedProductIds.ToList()) + { + await ProductsContract.DeleteProductsAsync(new DeleteProductsRequest { Id = id }); + } + + _selectedProductIds.Clear(); + ReLoadData(); + Snackbar.Add("محصولات انتخاب‌شده حذف شدند", Severity.Success); + } + } + + private async Task BulkToggleStatus(bool enable) + { + if (_selectedProductIds.Count == 0) + return; + + var actionTitle = enable ? "فعال‌سازی" : "غیرفعال‌سازی"; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small }; + bool? result = await DialogService.ShowMessageBox( + actionTitle, + $"آیا از {actionTitle} { _selectedProductIds.Count } محصول انتخاب‌شده مطمئن هستید؟", + yesText: actionTitle, cancelText: "لغو", + options: options); + + if (result == true) + { + var request = new ToggleProductStatusRequest + { + Enable = enable, + DefaultStock = 0 + }; + request.ProductIds.AddRange(_selectedProductIds); + + var response = await ProductsContract.ToggleProductStatusAsync(request); + + Snackbar.Add( + $"عملیات {actionTitle} انجام شد. موفق: {response.Succeeded} / ناموفق: {response.Failed}", + response.Failed > 0 ? Severity.Warning : Severity.Success); + + _selectedProductIds.Clear(); + ReLoadData(); + } + } + + private async Task ExportToExcel() + { + try + { + var exportRequest = new GetAllProductsByFilterRequest + { + Filter = _request.Filter ?? new GetAllProductsByFilterFilter(), + PaginationState = new CMSMicroservice.Protobuf.Protos.PaginationState + { + PageNumber = 1, + PageSize = 1000 + } + }; + + var result = await ProductsContract.GetAllProductsByFilterAsync(exportRequest); + + if (result?.Models == null || !result.Models.Any()) + { + Snackbar.Add("داده‌ای برای خروجی وجود ندارد.", Severity.Info); + return; + } + + var sb = new StringBuilder(); + sb.AppendLine("Id,Title,Price,Discount,RemainingCount,SaleCount,ViewCount"); + + foreach (var p in result.Models) + { + sb.AppendLine(string.Join(",", + p.Id, + EscapeCsv(p.Title), + p.Price, + p.Discount, + p.RemainingCount, + p.SaleCount, + p.ViewCount)); + } + + var bytes = Encoding.UTF8.GetBytes(sb.ToString()); + var base64 = Convert.ToBase64String(bytes); + var filename = $"products-{DateTime.Now:yyyyMMddHHmmss}.csv"; + + await JsRuntime.InvokeVoidAsync("jsSaveAsFile", filename, base64); + Snackbar.Add("خروجی Excel (CSV) آماده شد.", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"خطا در خروجی Excel: {ex.Message}", Severity.Error); + } + } + public async Task Update(DataModel model) { var parameters = new DialogParameters { { x => x.Model, model.Adapt() } }; @@ -112,6 +239,18 @@ public partial class ProductsMainPage Navigation.NavigateTo($"{RouteConstance.ProductCategories}{model.Id}"); } + public async Task OpenTagAssignment(DataModel model) + { + var parameters = new DialogParameters + { + { x => x.ProductId, model.Id }, + { x => x.ProductTitle, model.Title } + }; + + await DialogService.ShowAsync("مدیریت تگ‌های محصول", parameters, + new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Small, FullWidth = true }); + } + public async Task OpenImagePreview(string imagePath, string title) { if (string.IsNullOrWhiteSpace(imagePath)) @@ -126,4 +265,19 @@ public partial class ProductsMainPage await DialogService.ShowAsync("پیش‌نمایش تصویر محصول", parameters, new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Medium, FullWidth = true }); } + + public void OpenBulkEdit() + { + Navigation.NavigateTo(RouteConstance.ProductsBulkEdit); + } + + private static string EscapeCsv(string value) + { + if (string.IsNullOrEmpty(value)) + return ""; + + var needsQuotes = value.Contains(',') || value.Contains('"') || value.Contains('\n'); + var escaped = value.Replace("\"", "\"\""); + return needsQuotes ? $"\"{escaped}\"" : escaped; + } } diff --git a/src/BackOffice/Pages/PublicMessages/Components/MessageTemplatesDialog.razor b/src/BackOffice/Pages/PublicMessages/Components/MessageTemplatesDialog.razor new file mode 100644 index 0000000..6497c8c --- /dev/null +++ b/src/BackOffice/Pages/PublicMessages/Components/MessageTemplatesDialog.razor @@ -0,0 +1,203 @@ +@using Blazored.LocalStorage +@using BackOffice.Services.PublicMessage +@using static BackOffice.Pages.PublicMessages.Components.MessageFormDialog + +@inject ILocalStorageService LocalStorage + + + + + قالب‌های آماده پیام + + + قالب‌ها فقط در مرورگر فعلی ذخیره می‌شوند و روی سرور ذخیره نمی‌شوند. + + + + + + + + + اطلاعیه + خبر + هشدار + تبلیغات + + + + + + + + + + + افزودن قالب + + + + + + + + + + + + + @GetTypeText(context.Item.Type) + + + + + + + + + @(context.Item.Content.Length > 40 + ? context.Item.Content.Substring(0, 40) + "..." + : context.Item.Content) + + + + + + + + + + + + + + + + + + + + + + بستن + + + +@code { + [CascadingParameter] MudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public EventCallback OnTemplateSelected { get; set; } + + private const string StorageKey = "PublicMessageTemplates"; + + private readonly List _templates = new(); + private string _newTemplateTitle = string.Empty; + private string _newTemplateContent = string.Empty; + private MessageType _newTemplateType = MessageType.Announcement; + private int _newTemplatePriority = 1; + + protected override async Task OnInitializedAsync() + { + var stored = await LocalStorage.GetItemAsync>(StorageKey); + if (stored != null) + { + _templates.AddRange(stored); + } + } + + private async Task PersistAsync() + { + await LocalStorage.SetItemAsync(StorageKey, _templates); + } + + private async Task AddTemplate() + { + var template = new MessageTemplateModel + { + Id = Guid.NewGuid(), + Title = _newTemplateTitle.Trim(), + Content = _newTemplateContent.Trim(), + Type = _newTemplateType, + Priority = _newTemplatePriority + }; + + _templates.Add(template); + await PersistAsync(); + + _newTemplateTitle = string.Empty; + _newTemplateContent = string.Empty; + _newTemplateType = MessageType.Announcement; + _newTemplatePriority = 1; + } + + private async Task DeleteTemplate(MessageTemplateModel template) + { + _templates.Remove(template); + await PersistAsync(); + } + + private async Task UseTemplate(MessageTemplateModel template) + { + if (OnTemplateSelected.HasDelegate) + { + var model = new MessageFormModel + { + Title = template.Title, + Content = template.Content, + Type = template.Type, + Priority = template.Priority + }; + + await OnTemplateSelected.InvokeAsync(model); + } + } + + private void Close() + { + MudDialog.Close(); + } + + private static string GetTypeText(MessageType type) + { + return type switch + { + MessageType.Announcement => "اطلاعیه", + MessageType.News => "خبر", + MessageType.Alert => "هشدار", + MessageType.Promotion => "تبلیغات", + _ => "نامشخص" + }; + } + + public class MessageTemplateModel + { + public Guid Id { get; set; } + public string Title { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; + public MessageType Type { get; set; } = MessageType.Announcement; + public int Priority { get; set; } = 1; + } +} + diff --git a/src/BackOffice/Pages/PublicMessages/PublicMessagesMainPage.razor b/src/BackOffice/Pages/PublicMessages/PublicMessagesMainPage.razor index f098a3e..e5f0083 100644 --- a/src/BackOffice/Pages/PublicMessages/PublicMessagesMainPage.razor +++ b/src/BackOffice/Pages/PublicMessages/PublicMessagesMainPage.razor @@ -60,6 +60,15 @@ پیام جدید + + + قالب‌ها + + ( + "قالب‌های پیام", + new DialogParameters(), + options); + } + protected override async Task OnInitializedAsync() { await LoadMessages(); diff --git a/src/BackOffice/Pages/Tag/Components/AssignTagsDialog.razor b/src/BackOffice/Pages/Tag/Components/AssignTagsDialog.razor new file mode 100644 index 0000000..3b8dff2 --- /dev/null +++ b/src/BackOffice/Pages/Tag/Components/AssignTagsDialog.razor @@ -0,0 +1,56 @@ +@using BackOffice.Services.Tag + + + + + تگ‌های محصول: @ProductTitle + + @if (_currentTags.Any()) + { + + @foreach (var tag in _currentTags) + { + + @($"{tag.TagTitle} (#{tag.TagId})") + + } + + } + else + { + + هیچ تگی برای این محصول ثبت نشده است. + + } + + + @if (_tags != null) + { + @foreach (var tag in _tags) + { + @tag.Title (@tag.Name) + } + } + + + + افزودن تگ به محصول + + + + + + بستن + + + diff --git a/src/BackOffice/Pages/Tag/Components/AssignTagsDialog.razor.cs b/src/BackOffice/Pages/Tag/Components/AssignTagsDialog.razor.cs new file mode 100644 index 0000000..2b646e6 --- /dev/null +++ b/src/BackOffice/Pages/Tag/Components/AssignTagsDialog.razor.cs @@ -0,0 +1,57 @@ +using BackOffice.Services.Tag; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace BackOffice.Pages.Tag.Components; + +public partial class AssignTagsDialog +{ + [CascadingParameter] MudDialogInstance DialogInstance { get; set; } = default!; + [Inject] public ITagService TagService { get; set; } = default!; + + [Parameter] public long ProductId { get; set; } + [Parameter] public string ProductTitle { get; set; } = string.Empty; + + private List _tags = new(); + private List _currentTags = new(); + private long _selectedTagId; + + protected override async Task OnInitializedAsync() + { + var tagsResult = await TagService.GetTagsAsync(new TagFilterDto + { + PageNumber = 1, + PageSize = 100, + IsActive = true + }); + + _tags = tagsResult.Items; + + _currentTags = await TagService.GetProductTagsAsync(ProductId); + + await base.OnInitializedAsync(); + } + + private async Task AssignAsync() + { + if (_selectedTagId == 0) + return; + + await TagService.AssignToProductAsync(ProductId, _selectedTagId); + Snackbar.Add("تگ به محصول اضافه شد", Severity.Success); + + _currentTags = await TagService.GetProductTagsAsync(ProductId); + } + + private async Task RemoveAsync(ProductTagViewDto tag) + { + await TagService.RemoveProductTagAsync(tag.ProductTagId); + Snackbar.Add("تگ از محصول حذف شد", Severity.Success); + _currentTags = await TagService.GetProductTagsAsync(ProductId); + } + + private void Close() + { + DialogInstance.Close(); + } +} diff --git a/src/BackOffice/Pages/Tag/Components/TagEditDialog.razor b/src/BackOffice/Pages/Tag/Components/TagEditDialog.razor new file mode 100644 index 0000000..bcac015 --- /dev/null +++ b/src/BackOffice/Pages/Tag/Components/TagEditDialog.razor @@ -0,0 +1,44 @@ +@using BackOffice.Services.Tag + + + + + + + + + + + + + + فعال + + + + + + + + + @(_isEditMode ? "ذخیره تغییرات" : "ایجاد تگ") + + + انصراف + + + + diff --git a/src/BackOffice/Pages/Tag/Components/TagEditDialog.razor.cs b/src/BackOffice/Pages/Tag/Components/TagEditDialog.razor.cs new file mode 100644 index 0000000..eb9e1f4 --- /dev/null +++ b/src/BackOffice/Pages/Tag/Components/TagEditDialog.razor.cs @@ -0,0 +1,42 @@ +using BackOffice.Services.Tag; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace BackOffice.Pages.Tag.Components; + +public partial class TagEditDialog +{ + [CascadingParameter] MudDialogInstance DialogInstance { get; set; } = default!; + [Inject] public ITagService TagService { get; set; } = default!; + + [Parameter] public TagEditDto Model { get; set; } = new(); + [Parameter] public long? TagId { get; set; } + [Parameter] public bool IsEditMode { get; set; } + + private bool _isEditMode => IsEditMode && TagId.HasValue; + + private async Task HandleValidSubmit() + { + await SubmitAsync(); + } + + private async Task SubmitAsync() + { + if (_isEditMode && TagId.HasValue) + { + await TagService.UpdateAsync(TagId.Value, Model); + } + else + { + await TagService.CreateAsync(Model); + } + + DialogInstance.Close(DialogResult.Ok(true)); + } + + private void Cancel() + { + DialogInstance.Cancel(); + } +} + diff --git a/src/BackOffice/Pages/Tag/TagManagementPage.razor b/src/BackOffice/Pages/Tag/TagManagementPage.razor new file mode 100644 index 0000000..3d23c7e --- /dev/null +++ b/src/BackOffice/Pages/Tag/TagManagementPage.razor @@ -0,0 +1,95 @@ +@page "/tags" +@attribute [Authorize(Roles = "Administrator")] + +@using BackOffice.Services.Tag + +@inject ITagService TagService + + + + + مدیریت تگ‌ها + + افزودن تگ + + + + + + + + همه + فعال + غیرفعال + + + + اعمال فیلتر + + + + + + + + + + + + @(context.Item.IsActive ? "فعال" : "غیرفعال") + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/BackOffice/Pages/Tag/TagManagementPage.razor.cs b/src/BackOffice/Pages/Tag/TagManagementPage.razor.cs new file mode 100644 index 0000000..43f45f3 --- /dev/null +++ b/src/BackOffice/Pages/Tag/TagManagementPage.razor.cs @@ -0,0 +1,114 @@ +using BackOffice.Services.Tag; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace BackOffice.Pages.Tag; + +public partial class TagManagementPage +{ + [Inject] public ITagService TagService { get; set; } = default!; + + private MudDataGrid _grid = default!; + private string? _search; + private bool? _isActive; + + private async Task> LoadServerData(GridState state) + { + var filter = new TagFilterDto + { + PageNumber = state.Page + 1, + PageSize = state.PageSize, + SearchTerm = _search, + IsActive = _isActive + }; + + var result = await TagService.GetTagsAsync(filter); + + return new GridData + { + Items = result.Items, + TotalItems = result.TotalCount + }; + } + + private async Task ReloadAsync() + { + if (_grid != null) + { + await _grid.ReloadServerData(); + } + } + + private void OnSearchKeyDown(KeyboardEventArgs args) + { + if (args.Key == "Enter") + { + _ = ReloadAsync(); + } + } + + private async Task CreateTag() + { + var parameters = new DialogParameters + { + { x => x.Model, new TagEditDto() }, + { x => x.IsEditMode, false } + }; + + var dialog = await DialogService.ShowAsync("ایجاد تگ جدید", parameters, + new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Small, FullWidth = true }); + + var result = await dialog.Result; + if (!result.Canceled) + { + Snackbar.Add("تگ با موفقیت ایجاد شد", Severity.Success); + await ReloadAsync(); + } + } + + private async Task EditTag(TagListItemDto item) + { + var dto = new TagEditDto + { + Name = item.Name, + Title = item.Title, + Description = item.Description, + IsActive = item.IsActive, + SortOrder = item.SortOrder + }; + + var parameters = new DialogParameters + { + { x => x.Model, dto }, + { x => x.TagId, item.Id }, + { x => x.IsEditMode, true } + }; + + var dialog = await DialogService.ShowAsync("ویرایش تگ", parameters, + new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Small, FullWidth = true }); + + var result = await dialog.Result; + if (!result.Canceled) + { + Snackbar.Add("تگ با موفقیت به‌روزرسانی شد", Severity.Success); + await ReloadAsync(); + } + } + + private async Task DeleteTag(TagListItemDto item) + { + var confirmed = await DialogService.ShowMessageBox( + "حذف تگ", + $"آیا از حذف تگ «{item.Title}» مطمئن هستید؟", + yesText: "حذف", + cancelText: "لغو"); + + if (confirmed == true) + { + await TagService.DeleteAsync(item.Id); + Snackbar.Add("تگ حذف شد", Severity.Success); + await ReloadAsync(); + } + } +} + diff --git a/src/BackOffice/Pages/UserOrder/Components/ApplyDiscountDialog.razor b/src/BackOffice/Pages/UserOrder/Components/ApplyDiscountDialog.razor new file mode 100644 index 0000000..79e060a --- /dev/null +++ b/src/BackOffice/Pages/UserOrder/Components/ApplyDiscountDialog.razor @@ -0,0 +1,36 @@ +@using BackOffice.BFF.UserOrder.Protobuf.Protos.UserOrder + + + + + + اعمال تخفیف روی سفارش شماره @OrderId + + + + مبلغ تخفیف به‌صورت ریالی وارد شود. + + + + + + + + + + انصراف + + + اعمال تخفیف + + + + diff --git a/src/BackOffice/Pages/UserOrder/Components/ApplyDiscountDialog.razor.cs b/src/BackOffice/Pages/UserOrder/Components/ApplyDiscountDialog.razor.cs new file mode 100644 index 0000000..8ead1ba --- /dev/null +++ b/src/BackOffice/Pages/UserOrder/Components/ApplyDiscountDialog.razor.cs @@ -0,0 +1,70 @@ +using BackOffice.BFF.UserOrder.Protobuf.Protos.UserOrder; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace BackOffice.Pages.UserOrder.Components; + +public partial class ApplyDiscountDialog +{ + [CascadingParameter] public IMudDialogInstance MudDialog { get; set; } = default!; + [Inject] public UserOrderContract.UserOrderContractClient UserOrderContract { get; set; } = default!; + [Inject] public ISnackbar Snackbar { get; set; } = default!; + + [Parameter] public long OrderId { get; set; } + + private long? _discountAmount; + private string? _reason; + private bool _isSaving; + + private async Task SubmitAsync() + { + if (!_discountAmount.HasValue || _discountAmount.Value <= 0) + { + Snackbar.Add("مبلغ تخفیف باید بزرگتر از صفر باشد.", Severity.Warning); + return; + } + + _isSaving = true; + StateHasChanged(); + + try + { + var request = new ApplyDiscountToOrderRequest + { + OrderId = OrderId, + DiscountAmount = _discountAmount.Value, + Reason = _reason ?? string.Empty + }; + + var response = await UserOrderContract.ApplyDiscountToOrderAsync(request); + + if (response.Success) + { + Snackbar.Add($"تخفیف با موفقیت اعمال شد. مبلغ نهایی: {response.FinalAmount.ToString("N0")} ریال", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + else + { + var message = string.IsNullOrWhiteSpace(response.Message) + ? "اعمال تخفیف با خطا مواجه شد." + : response.Message; + Snackbar.Add(message, Severity.Warning); + } + } + catch (Exception ex) + { + Snackbar.Add($"خطا در اعمال تخفیف: {ex.Message}", Severity.Error); + } + finally + { + _isSaving = false; + StateHasChanged(); + } + } + + private void Cancel() + { + MudDialog.Cancel(); + } +} + diff --git a/src/BackOffice/Pages/UserOrder/Components/CancelOrderDialog.razor b/src/BackOffice/Pages/UserOrder/Components/CancelOrderDialog.razor new file mode 100644 index 0000000..f24489d --- /dev/null +++ b/src/BackOffice/Pages/UserOrder/Components/CancelOrderDialog.razor @@ -0,0 +1,38 @@ +@using BackOffice.BFF.UserOrder.Protobuf.Protos.UserOrder + + + + + + لغو سفارش شماره @OrderId + + + + + با لغو سفارش، وضعیت ارسال سفارش به «لغو شده» تغییر می‌کند. + + + + + + + + بازگشت وجه به کاربر (در صورت پرداخت موفق) + + + + + + انصراف + + + لغو سفارش + + + + diff --git a/src/BackOffice/Pages/UserOrder/Components/CancelOrderDialog.razor.cs b/src/BackOffice/Pages/UserOrder/Components/CancelOrderDialog.razor.cs new file mode 100644 index 0000000..3a71a49 --- /dev/null +++ b/src/BackOffice/Pages/UserOrder/Components/CancelOrderDialog.razor.cs @@ -0,0 +1,64 @@ +using BackOffice.BFF.UserOrder.Protobuf.Protos.UserOrder; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace BackOffice.Pages.UserOrder.Components; + +public partial class CancelOrderDialog +{ + [CascadingParameter] public IMudDialogInstance MudDialog { get; set; } = default!; + [Inject] public UserOrderContract.UserOrderContractClient UserOrderContract { get; set; } = default!; + [Inject] public ISnackbar Snackbar { get; set; } = default!; + + [Parameter] public long OrderId { get; set; } + + private string? _cancelReason; + private bool _refundPayment; + private bool _isSaving; + + private async Task SubmitAsync() + { + if (string.IsNullOrWhiteSpace(_cancelReason)) + { + Snackbar.Add("لطفاً دلیل لغو سفارش را وارد کنید.", Severity.Warning); + return; + } + + _isSaving = true; + StateHasChanged(); + + try + { + var request = new CancelOrderRequest + { + OrderId = OrderId, + CancelReason = _cancelReason ?? string.Empty, + RefundPayment = _refundPayment + }; + + var response = await UserOrderContract.CancelOrderAsync(request); + + var message = string.IsNullOrWhiteSpace(response.Message) + ? "سفارش با موفقیت لغو شد." + : response.Message; + + Snackbar.Add(message, Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + catch (Exception ex) + { + Snackbar.Add($"خطا در لغو سفارش: {ex.Message}", Severity.Error); + } + finally + { + _isSaving = false; + StateHasChanged(); + } + } + + private void Cancel() + { + MudDialog.Cancel(); + } +} + diff --git a/src/BackOffice/Pages/UserOrder/Components/ChangeOrderStatusDialog.razor b/src/BackOffice/Pages/UserOrder/Components/ChangeOrderStatusDialog.razor new file mode 100644 index 0000000..e3c0eb6 --- /dev/null +++ b/src/BackOffice/Pages/UserOrder/Components/ChangeOrderStatusDialog.razor @@ -0,0 +1,35 @@ +@using BackOffice.BFF.UserOrder.Protobuf.Protos.UserOrder + + + + + + تغییر وضعیت ارسال سفارش شماره @OrderId + + + + وضعیت فعلی: @GetDeliveryStatusText(CurrentStatus) + + + + بدون ارسال / نامشخص + در انتظار ارسال + تحویل پست + تحویل به مشتری + مرجوع شده + + + + + + انصراف + + + ثبت وضعیت + + + + diff --git a/src/BackOffice/Pages/UserOrder/Components/ChangeOrderStatusDialog.razor.cs b/src/BackOffice/Pages/UserOrder/Components/ChangeOrderStatusDialog.razor.cs new file mode 100644 index 0000000..4181565 --- /dev/null +++ b/src/BackOffice/Pages/UserOrder/Components/ChangeOrderStatusDialog.razor.cs @@ -0,0 +1,78 @@ +using BackOffice.BFF.UserOrder.Protobuf.Protos.UserOrder; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace BackOffice.Pages.UserOrder.Components; + +public partial class ChangeOrderStatusDialog +{ + [CascadingParameter] public IMudDialogInstance MudDialog { get; set; } = default!; + [Inject] public UserOrderContract.UserOrderContractClient UserOrderContract { get; set; } = default!; + [Inject] public ISnackbar Snackbar { get; set; } = default!; + + [Parameter] public long OrderId { get; set; } + [Parameter] public int CurrentStatus { get; set; } + + private int _newStatus; + private bool _isSaving; + + protected override void OnInitialized() + { + _newStatus = CurrentStatus; + } + + private string GetDeliveryStatusText(int status) + { + return status switch + { + 1 => "در انتظار ارسال", + 2 => "تحویل پست", + 3 => "تحویل به مشتری", + 4 => "مرجوع شده", + _ => "بدون ارسال / نامشخص" + }; + } + + private async Task SubmitAsync() + { + _isSaving = true; + StateHasChanged(); + + try + { + var response = await UserOrderContract.UpdateOrderStatusAsync(new UpdateOrderStatusRequest + { + OrderId = OrderId, + NewStatus = _newStatus + }); + + if (response.Success) + { + Snackbar.Add("وضعیت ارسال سفارش با موفقیت به‌روزرسانی شد.", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + else + { + var message = string.IsNullOrWhiteSpace(response.Message) + ? "به‌روزرسانی وضعیت سفارش با خطا مواجه شد." + : response.Message; + Snackbar.Add(message, Severity.Warning); + } + } + catch (Exception ex) + { + Snackbar.Add($"خطا در تغییر وضعیت سفارش: {ex.Message}", Severity.Error); + } + finally + { + _isSaving = false; + StateHasChanged(); + } + } + + private void Cancel() + { + MudDialog.Cancel(); + } +} + diff --git a/src/BackOffice/Pages/UserOrder/Components/UserOrderDetailsDialog.razor b/src/BackOffice/Pages/UserOrder/Components/UserOrderDetailsDialog.razor index 7f67298..1135cf0 100644 --- a/src/BackOffice/Pages/UserOrder/Components/UserOrderDetailsDialog.razor +++ b/src/BackOffice/Pages/UserOrder/Components/UserOrderDetailsDialog.razor @@ -23,7 +23,7 @@ - + خلاصه سفارش مبلغ: @_model.Amount.ToString("N0") تومان @@ -50,6 +50,23 @@ پرداخت نشده } + + + Timeline وضعیت + + @foreach (var step in _timelineSteps) + { + + + @step.Icon + + @step.Title + + } + + + وضعیت ارسال: _timelineSteps = new(); protected override async Task OnInitializedAsync() { @@ -32,6 +33,8 @@ public partial class UserOrderDetailsDialog _deliveryStatusValue = _model.DeliveryStatus.GetHashCode(); _trackingCode = _model.TrackingCode; _deliveryDescription = _model.DeliveryDescription; + + BuildTimeline(); } } catch @@ -77,6 +80,86 @@ public partial class UserOrderDetailsDialog }; } + private void BuildTimeline() + { + if (_model is null) + { + _timelineSteps = new List(); + return; + } + + var steps = new List + { + new() + { + Title = "ثبت سفارش", + State = _model.PaymentStatus == PaymentStatus.None && _model.DeliveryStatus == DeliveryStatus.None ? StepState.Active : StepState.Completed, + Icon = "1" + }, + new() + { + Title = "پرداخت", + State = _model.PaymentStatus switch + { + PaymentStatus.Success => StepState.Completed, + PaymentStatus.Pending => StepState.Active, + _ => StepState.Inactive + }, + Icon = "2" + }, + new() + { + Title = "ارسال", + State = _model.DeliveryStatus switch + { + DeliveryStatus.Pending => StepState.Active, + DeliveryStatus.InTransit => StepState.Active, + DeliveryStatus.Delivered => StepState.Completed, + DeliveryStatus.Returned => StepState.Completed, + _ => StepState.Inactive + }, + Icon = "3" + }, + new() + { + Title = "تحویل / مرجوعی", + State = _model.DeliveryStatus switch + { + DeliveryStatus.Delivered => StepState.Completed, + DeliveryStatus.Returned => StepState.Completed, + _ => StepState.Inactive + }, + Icon = "4" + } + }; + + _timelineSteps = steps; + } + + private Color GetStepColor(StepState state) + { + return state switch + { + StepState.Completed => Color.Success, + StepState.Active => Color.Info, + _ => Color.Default + }; + } + + private enum StepState + { + Inactive = 0, + Active = 1, + Completed = 2 + } + + private class TimelineStep + { + public string Title { get; set; } = string.Empty; + public StepState State { get; set; } + public string Icon { get; set; } = string.Empty; + } + private async Task SaveAsync() { if (_model is null) diff --git a/src/BackOffice/Pages/UserOrder/UserOrderMainPage.razor b/src/BackOffice/Pages/UserOrder/UserOrderMainPage.razor index f173ccd..f92ccb6 100644 --- a/src/BackOffice/Pages/UserOrder/UserOrderMainPage.razor +++ b/src/BackOffice/Pages/UserOrder/UserOrderMainPage.razor @@ -9,6 +9,30 @@ + + + + + + + + + + + جمع سفارش‌ها در بازه فعلی + @_stats.TotalOrders.ToString("N0") + + مجموع مبلغ: @_stats.TotalAmount.ToString("N0") ریال + + + + + + + + نمودار تعداد سفارش‌ها بر اساس وضعیت ارسال + @if (_statusChartLabels.Length == 0) + { + + برای این فیلتر، سفارش فعالی یافت نشد. + + } + else + { + + } + + + + + + Hover="true" @ref="_gridData" Height="60vh"> @@ -143,6 +201,33 @@ Style="cursor:pointer;"/> + + + + + + + + + + + + _gridData; BasePageComponent _basePage; + private long? _orderIdFilter; + private long? _userIdFilter; + private long? _transactionIdFilter; private int? _paymentStatusFilter; private int? _deliveryStatusFilter; private int? _paymentMethodFilter; @@ -23,6 +26,10 @@ public partial class UserOrderMainPage private GetAllUserOrderByFilterRequest _request = new() { Filter = new() }; + private OrderStatsViewModel _stats = new(); + private string[] _statusChartLabels = Array.Empty(); + private List _statusChartSeries = new(); + private async Task> ServerReload(GridState state) { @@ -32,13 +39,24 @@ public partial class UserOrderMainPage _request.PaginationState.PageNumber = state.Page + 1; _request.PaginationState.PageSize = state.PageSize; + if (_orderIdFilter.HasValue && _orderIdFilter.Value > 0) + { + _request.Filter.Id = _orderIdFilter.Value; + } + else + { + _request.Filter.Id = null; + } + if (UserId.HasValue && UserId.Value > 0) { _request.Filter.UserId = UserId.Value; } else { - _request.Filter.UserId = null; + _request.Filter.UserId = _userIdFilter.HasValue && _userIdFilter.Value > 0 + ? _userIdFilter.Value + : null; } if (_paymentDateFrom.HasValue) @@ -78,13 +96,29 @@ public partial class UserOrderMainPage _request.Filter.PaymentMethod = null; } + if (_transactionIdFilter.HasValue && _transactionIdFilter.Value > 0) + { + _request.Filter.TransactionId = _transactionIdFilter.Value; + } + else + { + _request.Filter.TransactionId = null; + } + var result = await UserOrderContract.GetAllUserOrderByFilterAsync(_request); if (result != null && result.Models != null && result.Models.Any()) { - return new GridData() - { Items = result.Models.ToList(), TotalItems = (int)result.MetaData.TotalCount }; + var items = result.Models.ToList(); + UpdateStats(items); + + return new GridData + { + Items = items, + TotalItems = (int)result.MetaData.TotalCount + }; } + UpdateStats(new List()); return new GridData(); } @@ -145,10 +179,16 @@ public partial class UserOrderMainPage _basePage.IsFiltered = false; StateHasChanged(); _request = new() { Filter = new() { } }; + _orderIdFilter = null; + _userIdFilter = null; + _transactionIdFilter = null; _paymentStatusFilter = null; _deliveryStatusFilter = null; _paymentDateFrom = null; _paymentMethodFilter = null; + _stats = new OrderStatsViewModel(); + _statusChartLabels = Array.Empty(); + _statusChartSeries = new List(); ReLoadData(); } @@ -184,4 +224,100 @@ public partial class UserOrderMainPage _ => "درگاه پرداخت" }; } + + private void UpdateStats(List items) + { + if (items == null || items.Count == 0) + { + _stats = new OrderStatsViewModel(); + _statusChartLabels = Array.Empty(); + _statusChartSeries = new List(); + return; + } + + _stats = new OrderStatsViewModel + { + TotalOrders = items.Count, + TotalAmount = items.Sum(x => x.Amount) + }; + + var groups = items + .GroupBy(x => x.DeliveryStatus.GetHashCode()) + .OrderBy(g => g.Key) + .ToList(); + + _statusChartLabels = groups + .Select(g => GetDeliveryStatusText(g.Key)) + .ToArray(); + + _statusChartSeries = new List + { + new() + { + Name = "تعداد سفارش‌ها", + Data = groups.Select(g => (double)g.Count()).ToArray() + } + }; + } + + private class OrderStatsViewModel + { + public int TotalOrders { get; set; } + public long TotalAmount { get; set; } + } + + private async Task OpenChangeStatus(DataModel model) + { + var parameters = new DialogParameters + { + { nameof(BackOffice.Pages.UserOrder.Components.ChangeOrderStatusDialog.OrderId), model.Id }, + { nameof(BackOffice.Pages.UserOrder.Components.ChangeOrderStatusDialog.CurrentStatus), model.DeliveryStatus.GetHashCode() } + }; + + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true }; + var dialog = await DialogService.ShowAsync( + "تغییر وضعیت ارسال سفارش", parameters, options); + var result = await dialog.Result; + + if (!result.Canceled) + { + ReLoadData(); + } + } + + private async Task OpenApplyDiscount(DataModel model) + { + var parameters = new DialogParameters + { + { nameof(BackOffice.Pages.UserOrder.Components.ApplyDiscountDialog.OrderId), model.Id } + }; + + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true }; + var dialog = await DialogService.ShowAsync( + "اعمال تخفیف روی سفارش", parameters, options); + var result = await dialog.Result; + + if (!result.Canceled) + { + ReLoadData(); + } + } + + private async Task OpenCancelOrder(DataModel model) + { + var parameters = new DialogParameters + { + { nameof(BackOffice.Pages.UserOrder.Components.CancelOrderDialog.OrderId), model.Id } + }; + + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true }; + var dialog = await DialogService.ShowAsync( + "لغو سفارش", parameters, options); + var result = await dialog.Result; + + if (!result.Canceled) + { + ReLoadData(); + } + } } diff --git a/src/BackOffice/Services/Tag/ITagService.cs b/src/BackOffice/Services/Tag/ITagService.cs new file mode 100644 index 0000000..e75bc02 --- /dev/null +++ b/src/BackOffice/Services/Tag/ITagService.cs @@ -0,0 +1,58 @@ +namespace BackOffice.Services.Tag; + +public interface ITagService +{ + Task GetTagsAsync(TagFilterDto filter); + Task GetByIdAsync(long id); + Task CreateAsync(TagEditDto dto); + Task UpdateAsync(long id, TagEditDto dto); + Task DeleteAsync(long id); + Task AssignToProductAsync(long productId, long tagId); + Task> GetProductTagsAsync(long productId); + Task RemoveProductTagAsync(long productTagId); +} + +public class TagFilterDto +{ + public int PageNumber { get; set; } = 1; + public int PageSize { get; set; } = 20; + public string? SearchTerm { get; set; } + public bool? IsActive { get; set; } +} + +public class TagListResultDto +{ + public List Items { get; set; } = new(); + public int TotalCount { get; set; } +} + +public class TagListItemDto +{ + public long Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; + public string? Description { get; set; } + public bool IsActive { get; set; } + public int SortOrder { get; set; } +} + +public class TagDetailsDto : TagListItemDto +{ +} + +public class TagEditDto +{ + public string Name { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; + public string? Description { get; set; } + public bool IsActive { get; set; } = true; + public int SortOrder { get; set; } +} + +public class ProductTagViewDto +{ + public long ProductTagId { get; set; } + public long TagId { get; set; } + public string TagTitle { get; set; } = string.Empty; +} + diff --git a/src/BackOffice/Services/Tag/TagService.cs b/src/BackOffice/Services/Tag/TagService.cs new file mode 100644 index 0000000..401d740 --- /dev/null +++ b/src/BackOffice/Services/Tag/TagService.cs @@ -0,0 +1,176 @@ +namespace BackOffice.Services.Tag; + +public class TagService : ITagService +{ + private readonly BackOffice.BFF.Tag.Protobuf.Protos.Tag.TagContract.TagContractClient _tagClient; + private readonly BackOffice.BFF.ProductTag.Protobuf.Protos.ProductTag.ProductTagContract.ProductTagContractClient _productTagClient; + private readonly BackOffice.BFF.Products.Protobuf.Protos.Products.ProductsContract.ProductsContractClient _productsClient; + public TagService( + BackOffice.BFF.Tag.Protobuf.Protos.Tag.TagContract.TagContractClient tagClient, + BackOffice.BFF.ProductTag.Protobuf.Protos.ProductTag.ProductTagContract.ProductTagContractClient productTagClient, + BackOffice.BFF.Products.Protobuf.Protos.Products.ProductsContract.ProductsContractClient productsClient) + { + _tagClient = tagClient; + _productTagClient = productTagClient; + _productsClient = productsClient; + } + + public async Task GetTagsAsync(TagFilterDto filter) + { + var request = new BackOffice.BFF.Tag.Protobuf.Protos.Tag.GetAllTagByFilterRequest + { + PaginationState = new CMSMicroservice.Protobuf.Protos.PaginationState + { + PageNumber = filter.PageNumber, + PageSize = filter.PageSize + }, + Filter = new BackOffice.BFF.Tag.Protobuf.Protos.Tag.GetAllTagByFilterFilter() + }; + + if (!string.IsNullOrWhiteSpace(filter.SearchTerm)) + { + request.Filter.Name = filter.SearchTerm; + request.Filter.Title = filter.SearchTerm; + } + + if (filter.IsActive.HasValue) + { + request.Filter.IsActive = filter.IsActive.Value; + } + + var response = await _tagClient.GetAllTagByFilterAsync(request); + + var result = new TagListResultDto + { + TotalCount = response.MetaData != null ? (int)response.MetaData.TotalCount : response.Models.Count + }; + + foreach (var model in response.Models) + { + result.Items.Add(new TagListItemDto + { + Id = model.Id, + Name = model.Name, + Title = model.Title, + Description = model.Description, + IsActive = model.IsActive, + SortOrder = model.SortOrder + }); + } + + return result; + } + + public async Task GetByIdAsync(long id) + { + var response = await _tagClient.GetTagAsync(new BackOffice.BFF.Tag.Protobuf.Protos.Tag.GetTagRequest { Id = id }); + + if (response == null || response.Id <= 0) + { + return null; + } + + return new TagDetailsDto + { + Id = response.Id, + Name = response.Name, + Title = response.Title, + Description = response.Description, + IsActive = response.IsActive, + SortOrder = response.SortOrder + }; + } + + public async Task CreateAsync(TagEditDto dto) + { + var request = new BackOffice.BFF.Tag.Protobuf.Protos.Tag.CreateNewTagRequest + { + Name = dto.Name, + Title = dto.Title, + Description = dto.Description ?? string.Empty, + IsActive = dto.IsActive, + SortOrder = dto.SortOrder + }; + + var response = await _tagClient.CreateNewTagAsync(request); + return response.Id; + } + + public async Task UpdateAsync(long id, TagEditDto dto) + { + var request = new BackOffice.BFF.Tag.Protobuf.Protos.Tag.UpdateTagRequest + { + Id = id, + Name = dto.Name, + Title = dto.Title, + Description = dto.Description ?? string.Empty, + IsActive = dto.IsActive, + SortOrder = dto.SortOrder + }; + + await _tagClient.UpdateTagAsync(request); + } + + public async Task DeleteAsync(long id) + { + await _tagClient.DeleteTagAsync(new BackOffice.BFF.Tag.Protobuf.Protos.Tag.DeleteTagRequest { Id = id }); + } + + public async Task AssignToProductAsync(long productId, long tagId) + { + var request = new BackOffice.BFF.ProductTag.Protobuf.Protos.ProductTag.CreateNewProductTagRequest + { + ProductId = productId, + TagId = tagId + }; + + await _productTagClient.CreateNewProductTagAsync(request); + } + + public async Task> GetProductTagsAsync(long productId) + { + var request = new BackOffice.BFF.ProductTag.Protobuf.Protos.ProductTag.GetAllProductTagByFilterRequest + { + PaginationState = new CMSMicroservice.Protobuf.Protos.PaginationState + { + PageNumber = 1, + PageSize = 100 + }, + Filter = new BackOffice.BFF.ProductTag.Protobuf.Protos.ProductTag.GetAllProductTagByFilterFilter + { + ProductId = productId + } + }; + + var response = await _productTagClient.GetAllProductTagByFilterAsync(request); + + var tagIds = response.Models.Select(m => m.TagId).Distinct().ToList(); + + var tagsResult = new List(); + + if (tagIds.Any()) + { + // از ProductsContract برای گرفتن اطلاعات تگ‌ها نیست، بنابراین فقط TagId را نگه می‌داریم + foreach (var model in response.Models) + { + tagsResult.Add(new ProductTagViewDto + { + ProductTagId = model.Id, + TagId = model.TagId, + TagTitle = model.TagId.ToString() + }); + } + } + + return tagsResult; + } + + public async Task RemoveProductTagAsync(long productTagId) + { + await _productTagClient.DeleteProductTagAsync( + new BackOffice.BFF.ProductTag.Protobuf.Protos.ProductTag.DeleteProductTagRequest + { + Id = productTagId + }); + } +} diff --git a/src/BackOffice/Shared/NavMenu.razor b/src/BackOffice/Shared/NavMenu.razor index f0ebbf9..aa9c5f8 100644 --- a/src/BackOffice/Shared/NavMenu.razor +++ b/src/BackOffice/Shared/NavMenu.razor @@ -107,6 +107,12 @@ مدیریت دسته‌بندی + + مدیریت تگ‌ها + + @@ -144,6 +150,12 @@ Icon="@Icons.Material.Filled.ShoppingCart"> سفارشات فروشگاه + + + گزارش فروش +