feat: full BackOffice updates - discount shop, blog, content, UI improvements
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 2m20s

- Blog management pages (posts, categories)
- Content management pages (site pages, sections)
- Contract management pages
- Discount shop improvements (order details, product form, categories)
- Wallet management pages
- Commission withdrawal details/reject dialogs
- Orders hub, sales reports
- System hub, global search, breadcrumb
- Club features page, club hub
- UI/UX improvements across all pages
- AppBreadcrumb, GlobalSearch components
- NavMenu updates and styling
This commit is contained in:
masoodafar-web
2026-02-16 00:56:39 +03:30
parent d84dfe3bc5
commit 49017027d0
159 changed files with 11044 additions and 2893 deletions
File diff suppressed because it is too large Load Diff
+722
View File
@@ -0,0 +1,722 @@
# 📝 BackOffice — Changelog
**آخرین بروزرسانی:** بهمن ۱۴۰۴ (February 2026)
---
## 🏷 فاز ۱۳ — بهبودهای UI بک‌آفیس (بهمن ۱۴۰۴)
> **هدف:** رفع مشکلات بصری، بهبود UX فرم‌ها و ناوبری
### ۱۳.۱ — فیکس کامپوننت Image (پیشوند `/uploads/`) ✅
- **مشکل:** تصاویر آپلود‌شده (بلاگ، صفحات سایت، سکشن‌ها) در بک‌آفیس نمایش داده نمی‌شدند — مسیر `GwUrl + Src` با مسیر `UploadsController` هماهنگ نبود
- **رفع:** اضافه شدن `/uploads/` بین `FileBaseUrl` و مسیر نسبی تصویر
- **فایل:** `BackOffice/Common/BaseComponents/Image.razor.cs`
### ۱۳.۲ — فیکس فیلتر هفته در گزارش‌های هفتگی ✅
- **مشکل:** فیلتر هفته از `MudTextField` (ورودی متنی) استفاده می‌کرد — کاربر باید عدد حدس می‌زد
- **رفع:** جایگزینی با `WeekNumberPicker` — انتخاب هفته از لیست با بایندینگ `WeekDefinitionId`
- **فایل‌ها:**
- `CMS/src/CMSMicroservice.Protobuf/Protos/commission.proto` — تغییر فیلدها از `StringValue` به `Int64Value`
- `CMS/.../CommissionCQ/Queries/GetAllWeeklyPools/GetAllWeeklyPoolsQuery.cs` + `Handler.cs`
- `BackOffice/Pages/Commission/WeeklyReports.razor`
### ۱۳.۳ — فیکس دانلود PDF قراردادها ✅
- **مشکل:** فیلد `SignedPdfFile` محتوای HTML کامل دارد (نه URL فایل PDF) — دکمه دانلود کار نمی‌کرد
- **رفع:** تابع JS جدید `jsHtmlToPdf()` برای باز کردن HTML در پنجره جدید و چاپ/ذخیره به‌عنوان PDF
- **فایل‌ها:**
- `BackOffice/wwwroot/js/main.js` — تابع `jsHtmlToPdf`
- `BackOffice/Pages/Contract/UserContractPage.razor`
- `BackOffice/Pages/Contract/ContractDetailsDialog.razor`
### ۱۳.۴ — یکسان‌سازی ارتفاع ویرایشگر HTML ✅
- **مشکل:** ارتفاع `MudHtmlEditor` در صفحات مختلف متفاوت بود — برخی خیلی کوتاه
- **رفع:** اضافه شدن `MinHeight="300px"` به تمام ۷ ویرایشگر HTML
- **فایل‌ها:** `BlogPostEditDialog`, `SitePageSectionEditDialog`, `DiscountShop/ProductFormDialog`, `Package/CreateDialog`, `Package/UpdateDialog`, `Products/CreateDialog`, `Products/UpdateDialog`
### ۱۳.۵ — تولید خودکار اسلاگ بلاگ ✅
- **مشکل:** کاربر باید اسلاگ URL را دستی وارد می‌کرد — غیرضروری و مستعد خطا
- **رفع:** حذف فیلد اسلاگ از فرم، تولید خودکار `post-{GUID}` در بک‌اند (حداکثر ۲۰ کاراکتر)
- **فایل‌ها:**
- `CMS/.../BlogPostCQ/Commands/CreateBlogPost/CreateBlogPostCommandHandler.cs`
- `CMS/.../BlogPostCQ/Commands/UpdateBlogPost/UpdateBlogPostCommandHandler.cs`
- `BackOffice/Pages/Blog/Components/BlogPostEditDialog.razor`
### ۱۳.۶ — انتقال تگ‌ها به منوی جداگانه ✅
- **مشکل:** تگ‌ها فقط از تب سوم بلاگ قابل دسترسی بودند — ولی تگ‌ها بین محصولات و بلاگ مشترکند
- **رفع:** اضافه شدن مسیر `/tags` به `TagManagementPage`، لینک جدید «مدیریت تگ‌ها» در منوی ناوبری، حذف تب تگ‌ها از `BlogHub`
- **فایل‌ها:**
- `BackOffice/Pages/Tag/TagManagementPage.razor`
- `BackOffice/Shared/NavMenu.razor`
- `BackOffice/Pages/Blog/BlogHub.razor`
### ۱۳.۷ — تولید خودکار کلید سکشن ✅
- **مشکل:** کاربر باید کلید سکشن صفحه سایت را دستی وارد می‌کرد (مثلاً `about-us-section`)
- **رفع:** فیلد اختیاری شد، تولید خودکار `section-{GUID}` (حداکثر ۲۰ کاراکتر) در هندلر CMS
- **فایل‌ها:**
- `CMS/.../SitePageCQ/Commands/CreateSitePageSection/CreateSitePageSectionCommandHandler.cs`
- `CMS/.../SitePageCQ/Commands/CreateSitePageSection/CreateSitePageSectionCommandValidator.cs`
- `BackOffice/Pages/Content/Components/SitePageSectionEditDialog.razor`
---
## 🏷 فاز ۱۲ — فیکس فلوی ثبت‌نام / ورود FrontOffice (بهمن ۱۴۰۴)
> **هدف:** رفع ۳ باگ بحرانی در فلوی ثبت‌نام و ورود FrontOffice
> **مستند کامل:** [`totalDoc/cms/REGISTRATION-FLOW-FIXES.md`](../../totalDoc/cms/REGISTRATION-FLOW-FIXES.md)
### ۱۲.۱ — رفع ایجاد کاربر جدید در VerifyOtpToken 🔴 ✅
- **مشکل:** `UserCQ/VerifyOtpTokenCommandHandler` کاربران جدید ایجاد نمی‌کرد — فقط خطای «کاربر یافت نشد»
- **رفع:** اضافه شدن لاجیک کامل: اعتبارسنجی کد معرف، بررسی ظرفیت درخت باینری (حداکثر ۲ فرزند)، تعیین شاخه (چپ/راست)، ایجاد User + UserRole + UserWallet، رویدادهای دامنه
- **فایل:** `CMS/src/CMSMicroservice.Application/UserCQ/Commands/VerifyOtpToken/VerifyOtpTokenCommandHandler.cs`
### ۱۲.۲ — رفع جستجوی موبایل در AcceptContract 🔴 ✅
- **مشکل:** `_currentUserService.Username` = `"{FirstName} {LastName}"` — نه شماره موبایل! در نتیجه OTP و کاربر هیچوقت پیدا نمی‌شد
- **رفع:** ابتدا کاربر از `UserId` پیدا شود، سپس `user.Mobile` برای جستجوی OTP استفاده شود
- **فایل:** `CMS/src/CMSMicroservice.Application/UserCQ/Commands/AcceptContract/AcceptContractCommandHandler.cs`
### ۱۲.۳ — رفع `IsCompleteRegister()` در FrontOffice 🟡 ✅
- **مشکل:** `InitUserAuthInfo().GetAwaiter()` بدون `.GetResult()` — عملیات async اجرا نمی‌شود، منوی کناری وضعیت نادرست
- **رفع:** تغییر به `InitUserAuthInfo().GetAwaiter().GetResult()`
- **فایل:** `FrontOffice/src/FrontOffice.Main/Utilities/AuthService.cs`
---
## 🏷 فاز ۱۱ — رفع خطای صفحات + سرویس عمومی تصاویر (بهمن ۱۴۰۴)
> **هدف:** رفع خطای ۳ صفحه BackOffice + ایجاد اندپوینت HTTP عمومی برای سرو تصاویر
### ۱۱.۱ — سرویس عمومی سرو تصاویر (CMS) ✅
- **`UploadsController`** — اندپوینت `GET /uploads/{path}` برای سرو مستقیم فایل‌ها
- `[AllowAnonymous]` — بدون نیاز به احراز هویت
- کش مرورگر ۲۴ ساعته (`ResponseCache`)
- پشتیبانی از Range Requests
- **FMS Fallback:** اگر فایل محلی نباشد، از `dl.afrino.co` دانلود و کش می‌شود
- `IHttpClientFactory` با named client `"FMS"` (timeout: 30s)
- **فایل‌ها:** `CMS/WebApi/Controllers/UploadsController.cs`, `CMS/WebApi/Program.cs`
### ۱۱.۲ — رفع صفحه «صفحات سایت» ✅
- اضافه شدن **دکمه «افزودن صفحه جدید»** به بالای صفحه
- اضافه شدن **دکمه «حذف»** (آیکون قرمز) برای هر ردیف
- ایجاد **`SitePageCreateDialog`** — دیالوگ ایجاد صفحه جدید با فیلدها:
- کلید صفحه (PageKey)، عنوان، توضیحات متا، عنوان هیرو، زیرعنوان هیرو، تصویر هیرو، وضعیت
- Proto: اضافه شدن `CreateSitePage` و `DeleteSitePage` RPC
- CQRS: `CreateSitePageCommand/Handler` و `DeleteSitePageCommand/Handler`
- **فایل‌ها:**
- `sitepage.proto` — 2 RPC جدید + پیام‌های Request/Response
- `SitePageCQ/Commands/CreateSitePage/` — Command + Handler
- `SitePageCQ/Commands/DeleteSitePage/` — Command + Handler
- `CMS/WebApi/Services/SitePageService.cs` — 2 override جدید
- `BackOffice/Services/Content/ISitePageService.cs``CreateAsync` + `DeleteAsync` + `SitePageCreateDto`
- `BackOffice/Services/Content/SitePageService.cs` — پیاده‌سازی Create/Delete
- `BackOffice/Pages/Content/SitePageManagementPage.razor` — دکمه‌های Add/Delete
- `BackOffice/Pages/Content/SitePageManagementPage.razor.cs` — متدهای CreatePage/DeletePage
- `BackOffice/Pages/Content/Components/SitePageCreateDialog.razor` + `.razor.cs`
### ۱۱.۳ — رفع صفحه «قراردادها» (Contracts) ✅
- **مشکل:** صفحه `/contracts` اصلاً بارگذاری نمی‌شد — crash در DI
- **علت:** `UserContractContract.UserContractContractClient` در DI ثبت نشده بود
- **رفع:** اضافه شدن `using CMSMicroservice.Protobuf.Protos.UserContract` و ثبت gRPC client
- **فایل:** `BackOffice/Common/Configure/ConfigureService.cs`
### ۱۱.۴ — رفع صفحه «کیف پول کاربران» (Wallets) ✅
- **مشکل:** صفحه `/wallets` اصلاً بارگذاری نمی‌شد — crash در DI
- **علت:** دو سرویس ثبت نشده بود:
- `UserWalletContract.UserWalletContractClient`
- `UserWalletChangeLogContract.UserWalletChangeLogContractClient`
- **رفع:** اضافه شدن using‌ها و ثبت هر دو gRPC client
- **فایل:** `BackOffice/Common/Configure/ConfigureService.cs`
---
## 🏷 فاز ۱۰ — بهبود مدیریت محتوا: بلاگ + صفحات سایت (بهمن ۱۴۰۴)
> **هدف:** فعال‌سازی آپلود تصویر باینری و ادیتور HTML در صفحات بلاگ و صفحات سایت
### ۱۰.۱ — بهبود ادیتور پست بلاگ ✅
- **`MudTextField Lines="10"`** → **`MudHtmlEditor`** برای ویرایش محتوای HTML
- **فیلد متنی مسیر تصویر** → **`MudFileUpload`** با پیش‌نمایش تصویر (`Image` component)
- حذف فیلد `FeaturedImageThumbnailPath` (تولید خودکار توسط بک‌اند)
- اضافه شدن `MudForm` با اعتبارسنجی + حالت بارگذاری (`_loading`)
- مدیریت خطا: try-catch + Snackbar
- **فایل‌ها:** `Pages/Blog/Components/BlogPostEditDialog.razor` + `.razor.cs`
### ۱۰.۲ — بهبود ادیتور صفحه سایت ✅
- **فیلد متنی Hero Image** → **`MudFileUpload`** با پیش‌نمایش تصویر
- اضافه شدن `MudForm` + حالت آپلود + مدیریت خطا
- **فایل‌ها:** `Pages/Content/Components/SitePageEditDialog.razor` + `.razor.cs`
### ۱۰.۳ — بهبود ادیتور بخش صفحه سایت (Section) ✅
- **`MudTextField Lines="8"`** → **`MudHtmlEditor`** برای ویرایش محتوای HTML بخش‌ها
- **فیلدهای متنی تصویر/thumbnail** → **`MudFileUpload`** با پیش‌نمایش
- اضافه شدن `MudForm` + حالت آپلود + مدیریت خطا
- **فایل‌ها:** `Pages/Content/Components/SitePageSectionEditDialog.razor` + `.razor.cs`
### ۱۰.۴ — تغییرات Proto ✅
| Proto | تغییر |
|-------|-------|
| `blogpost.proto` | اضافه شدن `BlogImageFileModel` + فیلد `image_file` در Create/Update |
| `sitepage.proto` | اضافه شدن `SitePageImageFileModel` + فیلد `image_file` در Update/CreateSection/UpdateSection |
### ۱۰.۵ — تغییرات CQRS (بک‌اند CMS) ✅
- **Commands:** اضافه شدن `ImageFileBytes`, `ImageMime`, `ImageFileName` به:
- `CreateBlogPostCommand`, `UpdateBlogPostCommand`
- `UpdateSitePageCommand`, `CreateSitePageSectionCommand`, `UpdateSitePageSectionCommand`
- **Handlers:** تزریق `IFileManager` + فراخوانی `UploadImageAsync`:
- BlogPost → `Images/BlogPosts/`
- SitePage → `Images/SitePages/`
- SitePageSection → `Images/SitePageSections/`
### ۱۰.۶ — تغییرات سرویس‌های BackOffice ✅
- `BlogPostService.cs` — ارسال `BlogImageFileModel` با `ByteString.CopyFrom(dto.ImageFile)`
- `SitePageService.cs` — ارسال `SitePageImageFileModel` در Update/CreateSection/UpdateSection
- **DTOs:** اضافه شدن `ImageFile`, `ImageMime`, `ImageFileName` به `BlogPostEditDto`, `SitePageEditDto`, `SitePageSectionEditDto`
### خلاصه آمار فاز ۱۰
| معیار | تعداد |
|---|---|
| فایل‌های proto تغییریافته | 2 |
| Command‌های بروزشده | 5 |
| Handler‌های بروزشده | 5 |
| سرویس‌های gRPC تغییریافته | 2 (BlogPostService + SitePageService) |
| دیالوگ‌های UI بازنویسی‌شده | 3 (BlogPost, SitePage, SitePageSection) |
| سرویس‌های BackOffice بروزشده | 2 |
| DTO‌های بروزشده | 3 |
| Build | 0 Error ✅ (هر ۳ پروژه) |
---
## 🏷 فاز ۹ — معماری مدیریت فایل + رفع سفارشات + مهاجرت DB (بهمن ۱۴۰۴)
> **مستند کامل معماری فایل:** [`totalDoc/cms/FILE-MANAGEMENT-ARCHITECTURE.md`](../../totalDoc/cms/FILE-MANAGEMENT-ARCHITECTURE.md)
### ۹.۱ — حذف کد مرده FMS ✅
- حذف ۳ فایل غیرقابل استفاده مرتبط با FMS خارجی (`https://dl.afrino.co`):
| فایل حذف‌شده | شرح |
|-------------|------|
| `Infrastructure/Services/FmsFileManager.cs` | پیاده‌سازی HTTP upload به FMS |
| `Application/Common/FileManager/FileManagementService.cs` | سرویس قدیمی مدیریت فایل |
| `Application/Common/FileManager/IFileManagementService.cs` | اینترفیس قدیمی |
### ۹.۲ — رفع صفحه سفارشات (Orders) ✅
**مشکل ۱ — صفحه خالی (بدون پیغام خطا)**
- **علت:** `ServerReload` بدون try-catch — exception‌های gRPC ساکت می‌شدند
- **رفع:** اضافه شدن try-catch + نمایش خطا با Snackbar
- **فایل:** `Pages/UserOrder/UserOrderMainPage.razor.cs`
**مشکل ۲ — Shadow FK تکراری در OrderVAT**
- **علت:** `.WithOne()` بدون inverse navigation → EF Core یک shadow FK اضافی می‌ساخت
- **رفع:** `.WithOne()``.WithOne(x => x.OrderVAT)`
- **فایل:** `Persistence/Configurations/OrderVATConfiguration.cs`
**مشکل ۳ — فیلترهای oneof همیشه فعال**
- **علت:** فیلدهای proto `oneof` مقدار پیش‌فرض enum دارند (هیچ‌وقت null نیستند)، پس `PaymentStatus != null` همیشه true بود
- **رفع:** `PaymentStatus != null``HasPaymentStatus == true` (و مشابه برای DeliveryStatus و PaymentMethod)
- **فایل‌ها:** `UserOrderService.cs` — هر دو متد `GetAllUserOrderByFilter` و `GetCustomerOrders`
### ۹.۳ — مهاجرت DB: حذف محدودیت طول ستون‌های تصویر ✅
- **علت:** data URI‌های base64 بزرگ‌تر از `nvarchar(500)` بودند → truncation خاموش
- **رفع:** حذف `HasMaxLength(500)` از تمام ستون‌های image path
- **Migration:** `RemoveImagePathMaxLength`
| Entity | ستون‌ها |
|--------|---------|
| Product | `ImagePath`, `ImageThumbnailPath` |
| DiscountProduct | `ImagePath`, `ImageThumbnailPath` |
| Category | `ImagePath` |
| DiscountCategory | `ImagePath` |
| BlogPost | `FeaturedImagePath`, `FeaturedImageThumbnailPath` |
| SitePage | `HeroImagePath` |
| SitePageSection | `ImagePath`, `ImageThumbnailPath` |
### ۹.۴ — بازنویسی کامل مدیریت فایل: ذخیره دیسکی ✅
**معماری قبلی:** ذخیره data URI مستقیم در DB (ناکارآمد + حجم بالا)
**معماری جدید:**
1. فایل‌ها در دیسک ذخیره می‌شوند: `Uploads/Images/{folder}/{guid}.jpg`
2. مسیر نسبی در DB ذخیره می‌شود
3. `ImagePathResolverInterceptor` هنگام serve تبدیل به base64 می‌کند
**فایل‌های اصلی:**
| فایل | شرح |
|------|------|
| `Application/Common/FileManager/IFileManager.cs` | اینترفیس: Upload, UploadImage, Delete, ResolveImageUrl |
| `Infrastructure/Services/LocalFileManager.cs` | پیاده‌سازی: ImageSharp, main 1200×1200 + thumb 300×300, JPEG Q75 |
| `WebApi/Interceptors/ImagePathResolverInterceptor.cs` | gRPC Interceptor: walk بازگشتی response → تبدیل path به data URI |
**تنظیمات:**
- `FileStorage:UploadPath` → پوشه ریشه آپلود (پیش‌فرض: `AppContext.BaseDirectory/Uploads`)
- gRPC MaxReceiveMessageSize: 50MB
### ۹.۵ — پاکسازی LoggingBehaviour ✅
- **قبل:** فیلدهای باینری (byte[] تصاویر) کامل در لاگ چاپ می‌شدند → لاگ‌های چند مگابایتی
- **بعد:**
- فرمت: `JsonFormatter.Default.Format()` به جای `{@Request}`
- Regex: حذف فیلدهای باینری (`File`, `ImageFile`, `image_file`, `file`)
- محدودیت: حداکثر 2000 کاراکتر
- **فایل:** `WebApi/Common/Behaviours/LoggingBehaviour.cs`
### خلاصه آمار فاز ۹
| معیار | تعداد |
|---|---|
| فایل‌های حذف‌شده (کد مرده) | 3 |
| فایل‌های جدید | 2 (LocalFileManager بازنویسی + ImagePathResolverInterceptor) |
| فایل‌های ویرایش‌شده | ~12 |
| باگ‌های رفع‌شده | 4 (Orders page ×3 + DB truncation) |
| Migration‌های جدید | 1 (RemoveImagePathMaxLength) |
| ستون‌های DB تغییریافته | 9 (nvarchar(500) → nvarchar(max)) |
| Build | 0 Error ✅ (هر ۳ پروژه) |
---
## 🏷 فاز ۸ — یکسان‌سازی فروشگاه عادی و تخفیفی (بهمن ۱۴۰۴)
> **مستند کامل:** [`totalDoc/ui-modernization/BACKOFFICE-STORE-UNIFICATION.md`](../../totalDoc/ui-modernization/BACKOFFICE-STORE-UNIFICATION.md)
### ۸.۱ — بازسازی NavMenu ✅
- ایجاد دو NavGroup مجزا: «فروشگاه عادی» و «فروشگاه تخفیفی»
- حذف «ویرایش دسته‌جمعی» از منو
- انبارداری → گروه مستقل | پکیج‌ها → آیتم مستقل
- **فایل:** `Shared/NavMenu.razor`
### ۸.۲ — حذف گزارش‌های کوچک از سفارشات ✅
- حذف کارت‌های آماری (TotalOrders + TotalAmount) و نمودار Bar ارسال
- حذف فیلدها: `_stats`, `_statusChartLabels`, `_statusChartSeries`
- حذف متد `UpdateStats()` و کلاس `OrderStatsViewModel`
- عنوان: «سفارش‌های کاربر» → «لیست سفارشات»
- **فایل‌ها:** `Pages/UserOrder/UserOrderMainPage.razor` + `.razor.cs`
### ۸.۳ — رفع باگ لیست خالی سفارشات ✅
- **علت:** `PaymentDate.ToDateTime()` بدون null check → exception ساکت در WASM
- **رفع:** `@if (context.Item.PaymentDate != null)` + fallback "-"
- **فایل:** `Pages/UserOrder/UserOrderMainPage.razor`
### ۸.۴ — بازنویسی صفحه محصولات تخفیفی ✅
- تبدیل به `BasePageComponent` (مطابق فروشگاه عادی)
- فیلترهای جدید: وضعیت + موجودی
- ستون‌های جدید: تصویر inline + عنوان truncate + چیپ رنگی موجودی + چیپ وضعیت
- **فایل‌ها:** `Pages/DiscountShop/DiscountProductsMainPage.razor` + `.razor.cs` (بازنویسی کامل)
### ۸.۵ — بازنویسی صفحه دسته‌بندی‌های تخفیفی ✅
- تبدیل به `BasePageComponent` (مطابق فروشگاه عادی)
- ستون‌های جدید: نام لاتین، دسته‌بندی والد (resolve شده)، تعداد محصولات، ترتیب
- code-behind جدید (`@code` درون‌خطی → `.razor.cs`)
- **فایل‌ها:** `Pages/DiscountShop/DiscountCategoriesMainPage.razor` + `.razor.cs` (بازنویسی کامل)
### خلاصه آمار فاز ۸
| معیار | تعداد |
|---|---|
| فایل‌های بازنویسی‌شده | 4 |
| فایل‌های ویرایش‌شده | 3 |
| باگ‌های رفع‌شده | 2 (PaymentDate null + لیست خالی) |
| Build | 0 Error ✅ |
---
## 🏷 فاز ۷ — پس از اجرای Audit v7.0 (تیر ۱۴۰۴)
### خلاصه تغییرات
- **۵۷ مورد** از ۵۸ یافته audit اجرا شد (≈98%)
- **تمام ۱۶ مورد بحرانی 🔴** حل شد
- **تمام ۱۸ مورد جزئی 🟢** حل شد
- **فازهای ۱ تا ۶ تکمیل شدند** (به‌جز DayaLoan و Audit Trail که نیاز به بک‌اند دارند)
- **فاز ۷** — Global Search, Notification Bell, NavMenu Badge, Export Excel 4 صفحه جدید
- **۱۳+ فایل جدید** ساخته شد
- **۹ صفحه** ادغام شدند (۱۴ صفحه → ۵ صفحه)
- **Build**: 0 error ✅
---
## فاز ۷ — Global Search + Notifications + Export All + NavMenu Badge (v7.0)
### 6.5 — Badge Pending در NavMenu — ✅
- **`NavMenu.razor`**: اضافه شدن `MudBadge` روی لینک «درخواست‌های برداشت»
- نمایش تعداد درخواست‌های Pending بصورت real-time از gRPC (`CommissionContract.GetWithdrawalRequestsAsync` با `Status=0`)
- fire-and-forget در `OnInitializedAsync` با try/catch silent
### 6.2 — Global Search در AppBar — ✅
- **فایل جدید**: `Shared/GlobalSearch.razor`
- `MudAutocomplete<SearchResultItem>` با Debounce 400ms
- دو نوع جستجو: ۱) نام صفحات (۱۵ صفحه اصلی) ۲) کاربران از gRPC (`UserContract.GetAllUserByFilterAsync`)
- Template سفارشی با آیکون + عنوان + زیرنویس
- ناوبری خودکار با `Navigation.NavigateTo`
- اضافه شده در `MainLayout.razor` داخل `<AuthorizeView>`
- CSS در `app.css` — rounded border-radius 24px, compact padding
### 6.1 — Notification Bell (placeholder) — ✅
- **`MainLayout.razor`**: اضافه شدن `MudMenu` + `MudBadge` (Dot) + آیکون Notifications
- Dropdown با هدر «اعلان‌ها» + placeholder «اعلان جدیدی وجود ندارد»
- آماده اتصال به بک‌اند — فقط `Visible=true` و populate items
### 6.3 — Export Excel صفحات جدید — ✅ (۱۰ صفحه کامل)
- **۴ صفحه جدید** اضافه شدند به ۶ صفحه قبلی:
1. **UserMainPage** — خروجی CSV: شناسه, موبایل, نام, نام خانوادگی, کدملی
2. **DiscountOrdersMainPage** — خروجی CSV: شماره سفارش, تاریخ, مبلغ کل, تخفیف, قابل پرداخت, تعداد, وضعیت, پرداخت
3. **ManualPayments** — خروجی CSV: شناسه, شناسه کاربر, نام کاربر, مبلغ, نوع, وضعیت, تاریخ
4. **InventoryMainPage** — خروجی CSV: شناسه, محصول, انبار, موجودی, رزرو, قابل فروش, حد هشدار, وضعیت
- هر ۴ صفحه: دکمه «خروجی Excel» + `ExportToExcel()` + `EscapeCsv()` + UTF-8 BOM + `jsSaveAsFile`
- فایل‌های code-behind جدید: `ManualPayments.razor.cs`, `DiscountOrdersMainPage.razor.cs`
---
## فاز ۶ — تاریخ شمسی + تاریخچه‌ها + Export Excel (v6.0)
### تاریخ شمسی یکپارچه (3.6) — ✅ کامل
- **۱۹+ فایل** آپدیت شدند: تمام تاریخ‌ها از فرمت میلادی (`yyyy/MM/dd`) به شمسی تبدیل شدند
- از extension method‌های `DateTimeConverterCL` استفاده شد:
- `.MiladiToJalali()` — فرمت تاریخ فقط (مثال: ۱۴۰۴/۰۴/۱۲)
- `.MiladiToJalaliWithTime()` — فرمت تاریخ و ساعت
- **صفحات اصلاح‌شده شامل**: Club (Members, Statistics, Features, MemberDetailsDialog)، Commission (Dashboard, WeeklyReports, WithdrawalRequests, Payouts, WithdrawalReports)، Network (UserNetworkInfo, BalancesReport, Statistics, NetworkTreeViewer)، UserOrder, Products, Package, Inventory, DiscountShop, Blog, Content, و غیره
- `PropertyColumn Format="yyyy/MM/dd"``TemplateColumn` با `.MiladiToJalali()`
- `MudDatePicker DateFormat` تغییر نکرد (قابلیت تقویم شمسی نیاز به کتابخانه جداگانه دارد)
### تاریخچه عضویت باشگاه (5.7) — ✅
- **`MemberDetailsDialog.razor`**: تب تاریخچه با فیلدهای صحیح proto:
- ستون‌ها: `Action`, `Created`, `PackageName`, `Reason`, `PerformedBy`
- Request: `PageIndex = 0` (نه PageNumber)
### تاریخچه تغییرات شبکه (5.8) — ✅
- **`UserNetworkInfo.razor`**: تب تاریخچه با فیلدهای تخصصی:
- ستون‌ها: `Action`, `Created`, `OldParentId → NewParentId`, `OldNetworkLeg → NewNetworkLeg` (چپ/راست)
- ستون `Reason` و `PerformedBy` اضافه شد
- Request: `PageIndex = 0`
### لاگ اجرای Worker‌ها (5.6) — ✅
- در تب WorkerControl از SystemHub قبلاً پیاده‌سازی شده بود
### Export Excel / CSV (6.3) — ✅ ۶ صفحه
قابلیت خروجی CSV (سازگار با Excel) به ۶ صفحه اصلی اضافه شد:
| صفحه | فایل(ها) | ستون‌های خروجی |
|---|---|---|
| **Products** (قبلاً موجود) | ProductsMainPage.razor.cs | — |
| **Club Members** | ClubMembers.razor + .razor.cs | شناسه,کاربر,نام,پکیج,کد فعال‌سازی,تاریخ فعال‌سازی,تاریخ انقضا,فعال,منقضی |
| **Withdrawal Requests** | WithdrawalRequests.razor + .razor.cs | شناسه,کاربر,نام,مبلغ,وضعیت,روش,شبا,تاریخ درخواست,تاریخ پردازش,مرجع بانکی |
| **Weekly Reports** | WeeklyReports.razor + .razor.cs | هفته,مبلغ استخر,موجودی‌ها,ارزش,وضعیت,تاریخ محاسبه |
| **User Orders** | UserOrderMainPage.razor + .razor.cs | شناسه,کاربر,نام,مبلغ,وضعیت پرداخت,وضعیت ارسال,روش پرداخت,تاریخ |
| **Stock Movements** | MovementsPage.razor + .razor.cs | شناسه,محصول,نوع,تعداد,قبل,بعد,مرجع,یادداشت,تاریخ |
**الگوی پیاده‌سازی:**
- دکمه `<MudButton>` با آیکون `FileDownload` در ToolBarContent
- `StringBuilder` → CSV با هدرهای فارسی
- UTF-8 BOM (`Encoding.UTF8.GetPreamble()`) برای نمایش صحیح فارسی در Excel
- `jsSaveAsFile(filename, base64)` از `wwwroot/js/main.js`
- متد `EscapeCsv()` برای مقادیر حاوی کاما/کوتیشن
- تاریخ‌ها در خروجی نیز شمسی هستند
### فایل‌های جدید (این نسخه)
| فایل | عملکرد |
|---|---|
| `Pages/Commission/WeeklyReports.razor.cs` | Code-behind برای export (به‌دلیل محدودیت Razor parser با escaped quotes) |
---
## نسخه قبلی — Audit v5.0 (خرداد ۱۴۰۴)
### تم رنگی یکپارچه
- **`CustomMudTheme.cs`**: تم Primary از `#0380C0` به `#6366f1` (Indigo) تغییر کرد — هماهنگ با FrontOffice
- **`PaletteDark`** اضافه شد: Surface `#1a1a2e`, Background `#16213e`, AppbarBackground `#0f3460`
- **`app.css`**: رنگ loading از `#1b6ec2` به `var(--mud-palette-primary)` تغییر کرد
- **`NavMenu.razor.css`**: رنگ active از `#0380C0` هاردکد به CSS variable تغییر کرد
- **`MainLayout.razor`**: حذف inline style `border: 1px solid #5e4df9`
### Dark Mode Toggle
- آیکون ماه/آفتاب در AppBar اضافه شد
- `_isDarkMode` state با `MudThemeProvider` اتصال دارد
- تعویض بین `PaletteLight` و `PaletteDark` در CustomMudTheme
### عنوان اپلیکیشن
- "پنل مدیریت **فرصت**" → "پنل مدیریت **کارا بازار سلامت**"
---
## فاز ۲ — بازطراحی لایوت صفحات
### BasePageComponent بازنویسی کامل
- **قبل**: فیلتر ثابت سمت راست (20%) + جدول (80%)، ارتفاع 85vh هاردکد
- **بعد**: فیلتر collapsible بالای جدول، فیلدها در `MudGrid` با `MudItem`، responsive
- **۱۳ صفحه** آپدیت شدند: User, Products, Package, Category, UserOrder, UserAddress, UserRole, Role, BlogPost, BlogCategory, Tag, Transactions, Content
### DataGrid Height یکسان‌سازی
- همه DataGrid‌ها: `Height="calc(100vh - 240px)"`
- **صفحات اصلاح‌شده**: تمام ~33 صفحه دارای DataGrid
### Pager یکسان‌سازی
- **PageSize**: همه `20, 50, 100`
- **متن فارسی**: `RowsPerPageString="تعداد در صفحه"` و `InfoFormat="سطر {first_item} تا {last_item} از {all_items}"`
### MaxWidth یکسان‌سازی
- `MainLayout.razor` MudContainer wrapper اضافه شد → MaxWidth یکسان برای همه صفحات
- **۳۱ صفحه**: حذف `MudContainer` تکراری (double nesting)
### Breadcrumb
- **`Shared/AppBreadcrumb.razor`** ساخته شد
- جداکننده چپ‌به‌راست (`ChevronLeft`)
- نقشه فارسی URL segments (60+ مسیر)
- آخرین آیتم disabled (صفحه فعلی)
### Login + VerifyCode بازطراحی
- عرض ثابت 35% → responsive `MaxWidth.Small`
- لوگو + برندینگ "کارا بازار سلامت" اضافه شد
- طراحی gradient background
---
## فاز ۲.۵ — پاکسازی ساختاری
### NavMenu بازسازی کامل
- **لینک مرده `categories-dragdrop`**: حذف شد
- **Transactions**: حذف از NavMenu (صفحه stub خالی)
- **AlertsMonitoring**: حذف از NavMenu (100% mock)
- **WorkerControl**: به SystemHub منتقل شد (قبلاً لینک نداشت)
- **«پیام‌های عمومی»**: از بخش فروشگاه تخفیفی به «مدیریت محتوا» منتقل شد
- **ساختار گروه‌بندی** اصلاح شد:
- کمیسیون و شبکه: NavGroup واضح
- باشگاه مشتریان: اعضا و آمار + فیچرها
- فروشگاه: پکیج‌ها، محصولات (group)، سفارش‌ها، فروشگاه تخفیفی (group)
- مدیریت: کاربران، نقش‌ها، پرداخت دستی، **کیف‌پول (جدید)**، **قراردادها (جدید)**
- محتوا: بلاگ، صفحات سایت، پیام‌های عمومی
- سیستم: مدیریت سیستم، نسخه اپ‌ها
---
## فاز ۳ — UX بهبودها
### NoRecordsContent (Empty State)
- **۳۳ DataGrid** آپدیت شدند
- پیام: `<MudAlert Severity="Info">موردی یافت نشد.</MudAlert>`
### Delete Confirmation با نام آیتم
- **۱۱ صفحه** اصلاح شدند:
- Role → `«{model.Title}»`
- User → `«{model.FirstName} {model.LastName}»`
- Package → `«{model.Title}»`
- UserAddress → `«{model.Title}»`
- UserRole → `شناسه «{model.Id}»`
- UserOrder → `شماره «{model.Id}»`
- Category → `«{model.Title}»`
- Products → `«{model.Title}»`
- DiscountCategories → `«{category?.Title}»` (lookup اضافه شد)
- DiscountProducts → `«{product?.Title}»` (lookup اضافه شد)
- PublicMessages → `«{message?.Title}»` (lookup اضافه شد)
### User Menu در AppBar
- `MudMenu` جایگزین متن ساده شماره موبایل شد
- آیتم‌ها: نام کاربر (غیرفعال) + خروج
- آیکون `AccountCircle`
- خروج (`signout`) از NavMenu به AppBar منتقل شد
---
## فاز ۳.۵ — ادغام صفحات
### Dashboard Merge ← `Pages/Index.razor`
- **`Index.razor` + `SystemOverview.razor`** ادغام شدند
- Routes: `/` + `/dashboard/overview`
- بخش‌ها:
- 6 کارت آمار CMS (کاربران، محصولات، پکیج‌ها، دسته‌بندی‌ها، سفارش‌ها، مبلغ پرداخت‌شده)
- آمار کمیسیون (هفته جاری، استخر، موجودی‌ها، وضعیت محاسبه)
- آمار باشگاه (کل/فعال/غیرفعال)
- جدول آخرین ۵ سفارش پرداخت‌شده
- ۶ دکمه Quick Action
- بارگذاری موازی با `Task.WhenAll` + `.ResponseAsync`
- `SystemOverview.razor` **حذف شد**
### DiscountShopHub ← `Pages/DiscountShop/DiscountShopHub.razor`
- Routes: `/discount-shop`, `/discount-orders`, `/discount-sales-reports`
- ۲ تب: DiscountOrdersMainPage + SalesReports
- انتخاب تب از URL
### ClubHub ← `Pages/Club/ClubHub.razor`
- Routes: `/club`, `/club/members`, `/club/statistics`
- ۲ تب: ClubMembers + Statistics
### BlogHub ← `Pages/Blog/BlogHub.razor`
- Routes: `/blog`, `/blog/posts`, `/blog/categories`, `/tags`
- ۳ تب: BlogPostManagementPage + BlogCategoryManagementPage + TagManagementPage
### SystemHub ← `Pages/SystemManagement/SystemHub.razor`
- Routes: `/system`, `/system/configuration`, `/system/worker-control`, `/system/health`
- ۳ تب: Configuration + WorkerControl + HealthDashboard
### ۱۰ صفحه — `@page` directive حذف شد
صفحاتی که به hub منتقل شدند دیگر route مستقل ندارند:
DiscountOrdersMainPage, SalesReports, ClubMembers, Statistics, BlogPostManagementPage, BlogCategoryManagementPage, TagManagementPage, Configuration, WorkerControl, HealthDashboard
---
## فاز ۴ — فیکس‌های بیزینسی
### Dashboard آمار اصلاح شد
- **قبل**: آمار از ۱۰ رکورد اول محاسبه می‌شد (نادرست)
- **بعد**: از `metadata.TotalCount` و API‌های جداگانه استفاده می‌شود
### ManualPayments — تأیید/رد
- دکمه‌های Approve/Reject وقتی `Status==0` (در انتظار) نمایش داده می‌شوند
- Confirmation dialog قبل از اجرا
- از `ManualPaymentId` (نه `Id`) استفاده می‌شود
### UserOrder — فیلتر Cancelled
- `<MudSelectItem Value="@(5)">لغو شده</MudSelectItem>` اضافه شد
### WithdrawalRequests — بهبود کامل
- **Pager**: 10/25/50/100 → 20/50/100
- **`WithdrawalDetailsDialog.razor`** (جدید): نمایش کامل اطلاعات کاربر، درخواست، و بانکی
- **`RejectReasonDialog.razor`** (جدید): ورود دلیل رد با اعتبارسنجی
- ViewDetails: از Snackbar TODO به دیالوگ واقعی تغییر کرد
- RejectRequest: از دلیل هاردکد به دیالوگ custom تغییر کرد
---
## فاز ۵ — صفحات مفقود (CMS-Derived)
### WalletManagementPage ← `Pages/Wallet/WalletManagementPage.razor`
- Route: `/wallets`
- **تب ۱ — کیف‌پول‌ها**: DataGrid با ServerData، فیلتر UserAutoComplete
- ستون‌ها: شناسه، کاربر، موجودی اصلی، موجودی شبکه، مشاهده تغییرات
- نوع: `GetAllUserWalletByFilterResponseModel`
- **تب ۲ — تاریخچه تغییرات**: DataGrid با ServerData، فیلتر کاربر + نوع (افزایش/کاهش)
- ستون‌ها: شناسه، کیف‌پول، نوع (چیپ رنگی)، تغییر موجودی، موجودی فعلی، تغییر شبکه، موجودی شبکه فعلی، شناسه مرجع، تاریخ
- نوع: `GetAllUserWalletChangeLogByFilterResponseModel`
- فیلدهای proto با typo: `ChangeNerworkValue`, `RefrenceId`
### UserContractPage ← `Pages/Contract/UserContractPage.razor`
- Route: `/contracts`
- DataGrid با ServerData، فیلتر UserAutoComplete
- ستون‌ها: شناسه، کاربر، قرارداد، کد امضا، لینک PDF، جزئیات
- نوع: `GetAllUserContractByFilterResponseModel`
### ContractDetailsDialog ← `Pages/Contract/ContractDetailsDialog.razor`
- جدول ساده: شناسه، کاربر، قالب قرارداد، کد امضا، لینک دانلود PDF
### ClubFeaturesPage ← `Pages/Club/ClubFeaturesPage.razor`
- Route: `/club/features`
- **تب ۱ — فیچرهای تعریف‌شده**: کارت‌های MudPaper با عنوان، توضیحات، وضعیت (فعال/غیرفعال)، ترتیب
- از `ConfigClient.GetClubFeaturesAsync`
- **تب ۲ — فیچرهای کاربران**: DataGrid با فیلتر کاربر، سوئیچ فعال/غیرفعال
- از `ClubClient.GetUserClubFeaturesAsync` + `ToggleUserClubFeatureAsync`
---
## ⚠️ نکات فنی مهم
### الگوهای Proto
- **فیلتر**: `new GetAllXxxByFilterFilter()` (سطح بالا، بدون `.Types.`)
- **پاسخ لیست**: `GetAllXxxByFilterResponseModel` (نه `GetXxxResponse`)
- **Typo‌ها در proto**: `ChangeNerworkValue` و `RefrenceId` — باید دقیقاً همین‌ها استفاده شوند
### _Imports.razor — سرویس‌های Global
سرویس‌های زیر از `_Imports.razor` تزریق می‌شوند و نباید در code-behind با `[Inject]` دوباره تعریف شوند:
- `NavigationManager Navigation`
- `ILocalStorageService LocalStorageService`
- `ISnackbar Snackbar`
- `IJSRuntime jsRuntime`
- `IDialogService DialogService`
- `AuthenticationStateProvider`
### MudBlazor محدودیت‌ها
- `Variant.Flat` وجود ندارد → از `Variant.Text` استفاده شود
- `GutterSize` روی `MudGrid` deprecated است
---
## فاز ۴ (ادامه) — تکمیل فیکس‌های بیزینسی
### UserSettings — حذف تب‌های جعلی (2.5.5)
- **`UserSettings.razor`**: تب Notifications (۷ تاگل جعلی بدون بک‌اند) → placeholder «به‌زودی فعال می‌شود» با آیکون NotificationsActive
- **`UserSettings.razor`**: تب Security (فرم تغییر رمز + 2FA بدون بک‌اند) → placeholder «به‌زودی فعال می‌شود» با آیکون Lock
- **حذف کد مرده**: ~۱۵ متغیر notification/security + متدهای `SaveNotificationSettings()`, `SaveSecuritySettings()`, `ChangePassword()`
### HealthDashboard — حذف متریک‌های Mock (2.5.6)
- **`HealthDashboard.razor`**: بخش System Resources (۴ کارت CPU=45.2%, Memory=62.8%, Disk=73.5%, Network) → یک کارت با پیام «مانیتورینگ منابع سیستم در نسخه‌های آینده با اتصال به Prometheus/Grafana فعال خواهد شد»
- **حذف کد مرده**: ۹ متغیر mock (`_cpuUsage`, `_memoryUsage`, etc.) + متد `GetResourceColor()`
### حذف cursor:pointer زائد (4.10)
- `Style="cursor:pointer;"` از **۱۷ فایل** حذف شد (MudButton/MudIconButton خودشان cursor:pointer دارند)
- شامل: ProductsMainPage, GalleryDialog, Image.razor (base component), RoleMainPage, UserOrderMainPage, PackageMainPage, etc.
### حذف Console.WriteLine (4.11)
- از **۱۱ فایل** حذف شد: DiscountProductService, DiscountCategoryService, NetworkTreeViewer, UserSettings, CategoryMainPage, UserOrderMainPage, Commission/Dashboard, DiscountProductsMainPage, DiscountCategoryMultiSelectCombo, UserRole/CreateDialog
- **`Commission/Dashboard.razor.cs`**: `using System.Text.Json` هم حذف شد (دیگر استفاده نمی‌شد)
### DiscountProducts — فیلتر دسته‌بندی (4.7)
- **`DiscountProductsMainPage.razor`**: `@inject IDiscountCategoryService` اضافه شد
- دسته‌بندی‌های فعال از سرویس بارگذاری و در MudSelect نمایش داده می‌شوند
- جایگزینی `@* TODO: Load categories *@` با `@foreach` واقعی
### DiscountProducts — Items → ServerData (4.8)
- **`DiscountProductsMainPage.razor`**: تبدیل از `Items="@_products"` به `ServerData="LoadServerData"`
- متد `LoadServerData(GridState<T>)` اضافه شد با `PageNumber` و `PageSize` از state
- `Height="calc(100vh - 300px)"` + `FixedHeader="true"` اضافه شد
- Snackbar اطلاع‌رسانی اضافی در هر بارگذاری حذف شد (redundant)
### UserOrder — فعال‌سازی آمار (4.6)
- **`UserOrderMainPage.razor`**: بخش آمار (MudCard + MudChart) از حالت comment خارج شد
- نمایش تعداد سفارش‌ها + مجموع مبلغ + نمودار Bar وضعیت ارسال
### UserSettings — persist با localStorage (4.9)
- تب General از قبل با localStorage کار می‌کرد ✅ (فقط تب‌های جعلی حذف شدند)
---
## فاز ۴.۵ — Cross-Navigation
### لینک نام کاربر → پروفایل (4.5.1, 4.5.2)
- **`UserOrderMainPage.razor`**: ستون `UserFullName` از PropertyColumn به TemplateColumn تبدیل شد — MudLink به `/network/user-info/{UserId}`
- **`UserPayouts.razor`**: نام/شناسه کاربر به MudLink تبدیل شد → `/network/user-info/{UserId}`
- **`WithdrawalRequests.razor`**: نام کاربر به MudLink تبدیل شد → `/network/user-info/{UserId}`
### لینک محصول ↔ انبار (4.5.3, 4.5.4) — Skip
- صفحه جزئیات محصول وجود ندارد (فقط لیست + دیالوگ ویرایش)
- لینک cross-navigation بدون صفحه مقصد معنادار نیست
### Extract shared status helpers (3.5.6) — Skip
- بررسی نشان داد هر context مقادیر متفاوتی دارد:
- UserPayouts: status 1 = Success/پرداخت شده
- WithdrawalRequests: status 1 = Info/واریز شده
- PayoutDetailsDialog: فقط ۳ وضعیت (نه ۶)
- استخراج به helper مشترک باعث از دست رفتن معنای domain-specific می‌شود
---
## 📈 آمار تغییرات
| معیار | تعداد |
|---|---|
| فایل‌های جدید | 11 |
| فایل‌های حذف‌شده | 1 (SystemOverview.razor) |
| فایل‌های ویرایش‌شده | ~65+ |
| DataGrid‌های آپدیت‌شده | 33 |
| Delete confirmation اصلاح‌شده | 11 |
| صفحات ادغام‌شده | 14 → 5 |
| خطاهای build حل‌شده | 9+ |
| Console.WriteLine حذف‌شده | 22+ (از ۱۱ فایل) |
| cursor:pointer حذف‌شده | 17 فایل |
| Cross-link اضافه‌شده | 3 صفحه (UserOrder, UserPayouts, WithdrawalRequests) |
@@ -1,34 +1,54 @@
<MudStack Row="true" AlignItems="AlignItems.Center" Class="pa-2 pr-4 rounded" Style="height:85vh;">
<MudItem xs="2">
<MudPaper Height="85vh" Style="overflow:hidden; position:relative;" Outlined="true" Class="rounded-lg">
<MudStack Class="pa-5" Style="height:100%;">
<MudStack Row="true" AlignItems="AlignItems.Center">
<MudText Typo="Typo.subtitle2">فیلتر ها</MudText>
<MudSpacer />
@if (IsFiltered)
{
<MudButton Color="Color.Error" Variant="Variant.Text" Size="Size.Small" OnClick="@(async () => await OnClearFilterClick.InvokeAsync())">حذف فیلتر</MudButton>
}
</MudStack>
<MudItem Style="height:100%;">
<MudStack StretchItems="StretchItems.None" Class="filter-item" Style="height:100%;">
<MudStack>
@Filters
</MudStack>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Size="Size.Small" FullWidth="true" Class="mt-auto" OnClick="@(async () => await OnSubmitClick.InvokeAsync())">جستجو</MudButton>
</MudStack>
</MudItem>
@* ── Responsive base page: filter above grid, collapsible ── *@
<MudStack Spacing="0" Class="base-page">
@* ── Header row: page title + filter toggle ── *@
@if (Filters != null)
{
<MudPaper Outlined="true" Class="rounded-lg mb-3 base-page__filter-panel">
<MudStack Row="true" AlignItems="AlignItems.Center" Class="pa-3 cursor-pointer"
@onclick="ToggleFilterPanel">
<MudIcon Icon="@Icons.Material.Filled.FilterList" Size="Size.Small"
Color="@(IsFiltered ? Color.Primary : Color.Default)" />
<MudText Typo="Typo.subtitle2" Class="mx-1">فیلترها</MudText>
@if (IsFiltered)
{
<MudChip T="string" Color="Color.Primary" Size="Size.Small" Variant="Variant.Text">فعال</MudChip>
}
<MudSpacer />
<MudIcon Icon="@(_filterOpen ? Icons.Material.Filled.ExpandLess : Icons.Material.Filled.ExpandMore)"
Size="Size.Small" />
</MudStack>
@if (_filterOpen)
{
<MudDivider />
<div class="pa-3">
<MudGrid Spacing="2">
@Filters
</MudGrid>
<MudStack Row="true" Justify="Justify.FlexEnd" Class="mt-3" Spacing="2">
@if (IsFiltered)
{
<MudButton Color="Color.Error" Variant="Variant.Text" Size="Size.Small"
OnClick="@(async () => await OnClearFilterClick.InvokeAsync())">
پاک کردن فیلتر
</MudButton>
}
<MudButton Variant="Variant.Filled" Color="Color.Primary" Size="Size.Small"
StartIcon="@Icons.Material.Filled.Search"
OnClick="@(async () => await OnSubmitClick.InvokeAsync())">
جستجو
</MudButton>
</MudStack>
</div>
}
</MudPaper>
}
</MudItem>
<MudItem xs="10">
<MudPaper Height="85vh" Style="overflow:hidden; position:relative;" Outlined="true" Class="rounded-lg">
@* ── Content area (DataGrid or custom layout) ── *@
<MudPaper Outlined="true" Class="rounded-lg base-page__content" Style="overflow:hidden; position:relative;">
@Content
</MudPaper>
@Content
</MudPaper>
</MudItem>
</MudStack>
@@ -1,11 +1,17 @@
using Microsoft.AspNetCore.Components;
namespace BackOffice.Common.BaseComponents;
public partial class BasePageComponent
{
[Parameter] public RenderFragment Filters { get; set; } = default!;
[Parameter] public RenderFragment? Filters { get; set; }
[Parameter] public RenderFragment Content { get; set; } = default!;
[Parameter] public bool IsFiltered { get; set; }
[Parameter] public EventCallback OnSubmitClick { get; set; }
[Parameter] public EventCallback OnClearFilterClick { get; set; }
/// <summary>Whether the filter panel is expanded.</summary>
private bool _filterOpen;
private void ToggleFilterPanel() => _filterOpen = !_filterOpen;
}
@@ -0,0 +1,14 @@
/* ── BasePageComponent scoped styles ── */
.base-page {
min-height: 0;
}
.base-page__filter-panel {
transition: all 0.2s ease;
}
.base-page__content {
flex: 1 1 auto;
min-height: 0;
}
@@ -7,6 +7,6 @@
}
else
{
<MudImage Src="@src" Alt="@Alt" Elevation="@Elevation" Class="@Class" Fluid="@Fluid" Height="@Height" ObjectFit="@ObjectFit" ObjectPosition="@ObjectPosition" Style="@(Style + ";cursor:pointer;")" Tag="@Tag" Width="@Width" @onclick="async()=>await OnClick.InvokeAsync()" />
<MudImage Src="@src" Alt="@Alt" Elevation="@Elevation" Class="@Class" Fluid="@Fluid" Height="@Height" ObjectFit="@ObjectFit" ObjectPosition="@ObjectPosition" Style="@Style" Tag="@Tag" Width="@Width" @onclick="async()=>await OnClick.InvokeAsync()" />
}
@@ -4,6 +4,7 @@ using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.Extensions.Configuration;
using MudBlazor;
namespace BackOffice.Common.BaseComponents
@@ -13,13 +14,24 @@ namespace BackOffice.Common.BaseComponents
{
private string src = string.Empty;
[Parameter] public EventCallback OnClick { get; set; }
[Inject] private IConfiguration Configuration { get; set; } = default!;
private string FileBaseUrl => Configuration["GwUrl"]?.TrimEnd('/') ?? "";
protected override async Task OnParametersSetAsync()
{
await base.OnParametersSetAsync();
if (!string.IsNullOrWhiteSpace(Src))
{
src = Src.Contains("data:") ? Src : "https://dl.afrino.co" + Src;
if (Src.Contains("data:") || Src.StartsWith("http"))
src = Src;
else
src = FileBaseUrl + "/uploads/" + Src.TrimStart('/');
}
else
{
src = string.Empty;
}
}
@@ -23,12 +23,21 @@ using CMSMicroservice.Protobuf.Protos.ProductTag;
using CMSMicroservice.Protobuf.Protos.ManualPayment;
using CMSMicroservice.Protobuf.Protos;
using CMSMicroservice.Protobuf.Protos.Inventory;
using CMSMicroservice.Protobuf.Protos.BlogPost;
using CMSMicroservice.Protobuf.Protos.BlogCategory;
using CMSMicroservice.Protobuf.Protos.BlogPostImage;
using CMSMicroservice.Protobuf.Protos.SitePage;
using CMSMicroservice.Protobuf.Protos.UserContract;
using CMSMicroservice.Protobuf.Protos.UserWallet;
using CMSMicroservice.Protobuf.Protos.UserWalletChangeLog;
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 BackOffice.Services.Blog;
using BackOffice.Services.Content;
using Blazored.LocalStorage;
using Grpc.Core;
using Grpc.Core.Interceptors;
@@ -77,6 +86,12 @@ public static class ConfigureServices
services.AddScoped<IPublicMessageService, PublicMessageService>();
services.AddScoped<ITagService, TagService>();
// Blog & Content Management Services
services.AddScoped<IBlogPostService, BlogPostService>();
services.AddScoped<IBlogCategoryService, BlogCategoryService>();
services.AddScoped<IBlogPostImageService, BlogPostImageService>();
services.AddScoped<ISitePageService, SitePageService>();
return services;
}
@@ -138,6 +153,19 @@ public static class ConfigureServices
// Inventory Management Service
services.AddTransient(sp => new InventoryContract.InventoryContractClient(sp.GetRequiredService<CallInvoker>()));
// Blog & Content Management Services
services.AddTransient(sp => new BlogPostContract.BlogPostContractClient(sp.GetRequiredService<CallInvoker>()));
services.AddTransient(sp => new BlogCategoryContract.BlogCategoryContractClient(sp.GetRequiredService<CallInvoker>()));
services.AddTransient(sp => new BlogPostImageContract.BlogPostImageContractClient(sp.GetRequiredService<CallInvoker>()));
services.AddTransient(sp => new SitePageContract.SitePageContractClient(sp.GetRequiredService<CallInvoker>()));
// User Contracts Service
services.AddTransient(sp => new UserContractContract.UserContractContractClient(sp.GetRequiredService<CallInvoker>()));
// User Wallet Services
services.AddTransient(sp => new UserWalletContract.UserWalletContractClient(sp.GetRequiredService<CallInvoker>()));
services.AddTransient(sp => new UserWalletChangeLogContract.UserWalletChangeLogContractClient(sp.GetRequiredService<CallInvoker>()));
return services;
}
@@ -10,6 +10,7 @@ public static class RouteConstance
public const string UserPage = "/UserPage/";
public const string UserOrder = "/UserOrderPage/";
public const string Orders = "/OrdersPage/";
public const string OrdersSalesReports = "/OrdersSalesReportsPage/";
public const string UserRole = "/UserRolePage/";
public const string UserAddress = "/UserAddressPage/";
public const string Products = "/ProductsPage/";
@@ -4,10 +4,12 @@
@ref="_selectRef"
Label="@Label"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Disabled="@Disabled"
MultiSelection="true"
MultiSelectionTextFunc="@GetSelectedText"
SelectedValues="_internalSelectedIds"
SelectedValuesChanged="@(_ => OnSelectedValuesChangedAsync())">
SelectedValuesChanged="OnSelectedValuesChangedAsync">
@foreach (var item in _items)
{
<MudSelectItem T="long" Value="@item.Id">
@@ -62,11 +62,9 @@ public partial class CategoryMultiSelectCombo
: new HashSet<long>();
}
private async Task OnSelectedValuesChangedAsync()
private async Task OnSelectedValuesChangedAsync(IEnumerable<long> values)
{
var current = _selectRef?.SelectedValues ?? System.Array.Empty<long>();
_internalSelectedIds = current.Where(id => id > 0).Distinct().ToHashSet();
_internalSelectedIds = values.Where(id => id > 0).Distinct().ToHashSet();
SelectedIds = _internalSelectedIds.ToList();
if (SelectedIdsChanged.HasDelegate)
@@ -74,5 +72,27 @@ public partial class CategoryMultiSelectCombo
await SelectedIdsChanged.InvokeAsync(SelectedIds);
}
}
private string GetSelectedText(List<string> selectedValues)
{
if (selectedValues == null || selectedValues.Count == 0)
return string.Empty;
var titles = new List<string>();
foreach (var val in selectedValues)
{
if (long.TryParse(val, out var id))
{
var item = _items.FirstOrDefault(x => x.Id == id);
titles.Add(item != null ? item.Title : val);
}
else
{
titles.Add(val);
}
}
return string.Join("، ", titles);
}
}
@@ -3,6 +3,7 @@
<MudSelect @ref="_selectRef"
T="long"
MultiSelection="true"
MultiSelectionTextFunc="@GetSelectedText"
SelectedValues="_internalSelectedIds"
SelectedValuesChanged="OnSelectedValuesChangedAsync"
Label="@Label"
@@ -11,6 +12,6 @@
Disabled="@Disabled">
@foreach (var item in _items)
{
<MudSelectItem Value="@item.Id">@item.Title</MudSelectItem>
<MudSelectItem T="long" Value="@item.Id">@item.Title</MudSelectItem>
}
</MudSelect>
@@ -34,30 +34,22 @@ public partial class DiscountCategoryMultiSelectCombo
{
try
{
Console.WriteLine("Starting to load DiscountCategories...");
var tree = await CategoryService.GetCategoriesAsync(isActive: true);
Console.WriteLine($"Received {tree?.Count ?? 0} root categories");
_items = FlattenCategories(tree ?? new List<DiscountCategoryDto>());
Console.WriteLine($"DiscountCategory loaded: {_items.Count} items");
// Log first few items
if (_items.Count > 0)
{
Console.WriteLine("Sample categories:");
foreach (var item in _items.Take(5))
{
Console.WriteLine($" - {item.Id}: {item.Title} (Active: {item.IsActive})");
}
}
}
catch (Exception ex)
{
Console.WriteLine($"Error loading DiscountCategories: {ex.GetType().Name} - {ex.Message}");
if (ex.InnerException != null)
{
Console.WriteLine($"Inner Exception: {ex.InnerException.Message}");
}
Console.WriteLine($"Stack Trace: {ex.StackTrace}");
_items = new List<DiscountCategoryDto>();
}
}
@@ -102,4 +94,26 @@ public partial class DiscountCategoryMultiSelectCombo
await SelectedIdsChanged.InvokeAsync(SelectedIds);
}
}
private string GetSelectedText(List<string> selectedValues)
{
if (selectedValues == null || selectedValues.Count == 0)
return string.Empty;
var titles = new List<string>();
foreach (var val in selectedValues)
{
if (long.TryParse(val, out var id))
{
var item = _items.FirstOrDefault(x => x.Id == id);
titles.Add(item != null ? item.Title : val);
}
else
{
titles.Add(val);
}
}
return string.Join("، ", titles);
}
}
@@ -0,0 +1,71 @@
@* Merged into /blog *@
@attribute [Authorize(Roles = "Administrator")]
@using BackOffice.Services.Blog
@inject IBlogCategoryService BlogCategoryService
<div>
<MudStack Spacing="2">
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.h5">مدیریت دسته‌بندی‌های بلاگ</MudText>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="CreateCategory">
افزودن دسته‌بندی
</MudButton>
</MudStack>
<MudDataGrid T="BlogCategoryItemDto"
@ref="_grid"
ServerData="LoadServerData"
Hover="true"
Elevation="1"
Dense="true"
Height="calc(100vh - 240px)">
<Columns>
<PropertyColumn Property="x => x.Id" Title="شناسه" />
<PropertyColumn Property="x => x.Title" Title="عنوان" />
<PropertyColumn Property="x => x.Slug" Title="اسلاگ" />
<PropertyColumn Property="x => x.IconName" Title="آیکون" />
<PropertyColumn Property="x => x.PostCount" Title="تعداد پست" />
<PropertyColumn Property="x => x.IsActive" Title="وضعیت">
<CellTemplate>
<MudChip Color="@(context.Item.IsActive ? Color.Success : Color.Error)"
Size="Size.Small">
@(context.Item.IsActive ? "فعال" : "غیرفعال")
</MudChip>
</CellTemplate>
</PropertyColumn>
<PropertyColumn Property="x => x.SortOrder" Title="ترتیب" />
<TemplateColumn Title="عملیات" Sortable="false">
<CellTemplate>
<MudStack Row="true" Spacing="1">
<MudTooltip Text="ویرایش">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Size="Size.Small"
Color="Color.Primary"
OnClick="@(() => EditCategory(context.Item))" />
</MudTooltip>
<MudTooltip Text="حذف">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Size="Size.Small"
Color="Color.Error"
OnClick="@(() => DeleteCategory(context.Item))" />
</MudTooltip>
</MudStack>
</CellTemplate>
</TemplateColumn>
</Columns>
<NoRecordsContent>
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Text" Class="my-4">موردی یافت نشد.</MudAlert>
</NoRecordsContent>
<PagerContent>
<MudDataGridPager T="BlogCategoryItemDto" PageSizeOptions="@(new int[] { 20, 50, 100 })"
InfoFormat="سطر {first_item} تا {last_item} از {all_items}"
RowsPerPageString="تعداد در صفحه" />
</PagerContent>
</MudDataGrid>
</MudStack>
</div>
@@ -0,0 +1,102 @@
using BackOffice.Services.Blog;
using BackOffice.Pages.Blog.Components;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using MudBlazor;
namespace BackOffice.Pages.Blog;
public partial class BlogCategoryManagementPage
{
private MudDataGrid<BlogCategoryItemDto> _grid = default!;
private async Task<GridData<BlogCategoryItemDto>> LoadServerData(GridState<BlogCategoryItemDto> state)
{
var filter = new BlogCategoryFilterDto
{
PageNumber = state.Page + 1,
PageSize = state.PageSize
};
var result = await BlogCategoryService.GetAllAsync(filter);
return new GridData<BlogCategoryItemDto>
{
Items = result.Items,
TotalItems = result.TotalCount
};
}
private async Task ReloadAsync()
{
if (_grid != null)
{
await _grid.ReloadServerData();
}
}
private async Task CreateCategory()
{
var parameters = new DialogParameters<BlogCategoryEditDialog>
{
{ nameof(BlogCategoryEditDialog.Model), new BlogCategoryEditDto() },
{ nameof(BlogCategoryEditDialog.IsEditMode), false }
};
var dialog = await DialogService.ShowAsync<BlogCategoryEditDialog>("ایجاد دسته‌بندی جدید", 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 EditCategory(BlogCategoryItemDto item)
{
var dto = new BlogCategoryEditDto
{
Title = item.Title,
Slug = item.Slug,
Description = item.Description,
IconName = item.IconName,
SortOrder = item.SortOrder,
IsActive = item.IsActive
};
var parameters = new DialogParameters<BlogCategoryEditDialog>
{
{ nameof(BlogCategoryEditDialog.Model), dto },
{ nameof(BlogCategoryEditDialog.CategoryId), item.Id },
{ nameof(BlogCategoryEditDialog.IsEditMode), true }
};
var dialog = await DialogService.ShowAsync<BlogCategoryEditDialog>("ویرایش دسته‌بندی", 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 DeleteCategory(BlogCategoryItemDto item)
{
var confirmed = await DialogService.ShowMessageBox(
"حذف دسته‌بندی",
$"آیا از حذف دسته‌بندی «{item.Title}» مطمئن هستید؟",
yesText: "حذف",
cancelText: "لغو");
if (confirmed == true)
{
await BlogCategoryService.DeleteAsync(item.Id);
Snackbar.Add("دسته‌بندی حذف شد", Severity.Success);
await ReloadAsync();
}
}
}
+32
View File
@@ -0,0 +1,32 @@
@page "/blog"
@page "/blog/posts"
@page "/blog/categories"
@attribute [Authorize(Roles = "Administrator")]
<div>
<MudText Typo="Typo.h5" Class="mb-3">مدیریت بلاگ</MudText>
<MudTabs Elevation="0" Rounded="true" Border="true" ApplyEffectsToContainer="true"
PanelClass="pt-4" @bind-ActivePanelIndex="_activeTab">
<MudTabPanel Text="پست‌ها" Icon="@Icons.Material.Filled.PostAdd">
<BlogPostManagementPage />
</MudTabPanel>
<MudTabPanel Text="دسته‌بندی‌ها" Icon="@Icons.Material.Filled.Category">
<BlogCategoryManagementPage />
</MudTabPanel>
</MudTabs>
</div>
@code {
private int _activeTab;
protected override void OnInitialized()
{
var uri = Navigation.ToBaseRelativePath(Navigation.Uri).ToLowerInvariant().TrimEnd('/');
_activeTab = uri switch
{
"blog/categories" => 1,
_ => 0
};
}
}
@@ -0,0 +1,152 @@
@* Merged into /blog *@
@attribute [Authorize(Roles = "Administrator")]
@using BackOffice.Services.Blog
@inject IBlogPostService BlogPostService
@inject IBlogCategoryService BlogCategoryService
<div>
<MudStack Spacing="2">
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.h5">مدیریت پست‌های بلاگ</MudText>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="CreatePost">
افزودن پست
</MudButton>
</MudStack>
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center">
<MudTextField @bind-Value="_search"
Label="جستجو بر اساس عنوان"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search"
Immediate="true"
OnBlur="ReloadAsync"
OnKeyDown="OnSearchKeyDown" />
<MudSelect @bind-Value="_statusFilter"
Label="وضعیت"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Style="width: 160px;">
<MudSelectItem T="int?" Value="@( (int?)null )">همه</MudSelectItem>
<MudSelectItem T="int?" Value="0">پیش‌نویس</MudSelectItem>
<MudSelectItem T="int?" Value="1">منتشر شده</MudSelectItem>
<MudSelectItem T="int?" Value="2">آرشیو شده</MudSelectItem>
</MudSelect>
<MudSelect @bind-Value="_categoryFilter"
Label="دسته‌بندی"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Style="width: 200px;">
<MudSelectItem T="long?" Value="@( (long?)null )">همه</MudSelectItem>
@foreach (var cat in _categories)
{
<MudSelectItem T="long?" Value="@( (long?)cat.Id )">@cat.Title</MudSelectItem>
}
</MudSelect>
<MudButton Variant="Variant.Outlined"
Color="Color.Primary"
OnClick="ReloadAsync">
اعمال فیلتر
</MudButton>
</MudStack>
<MudDataGrid T="BlogPostListItemDto"
@ref="_grid"
ServerData="LoadServerData"
Hover="true"
Elevation="1"
Dense="true"
Height="calc(100vh - 240px)">
<Columns>
<PropertyColumn Property="x => x.Id" Title="شناسه" />
<PropertyColumn Property="x => x.Title" Title="عنوان" />
<PropertyColumn Property="x => x.Slug" Title="اسلاگ" />
<PropertyColumn Property="x => x.StatusName" Title="وضعیت">
<CellTemplate>
<MudChip Color="@GetStatusColor(context.Item.Status)"
Size="Size.Small">
@context.Item.StatusName
</MudChip>
</CellTemplate>
</PropertyColumn>
<PropertyColumn Property="x => x.IsFeatured" Title="ویژه">
<CellTemplate>
@if (context.Item.IsFeatured)
{
<MudIcon Icon="@Icons.Material.Filled.Star" Color="Color.Warning" Size="Size.Small" />
}
</CellTemplate>
</PropertyColumn>
<PropertyColumn Property="x => x.ViewCount" Title="بازدید" />
<TemplateColumn Title="دسته‌بندی‌ها" Sortable="false">
<CellTemplate>
<MudStack Row="true" Spacing="1">
@foreach (var cat in context.Item.Categories)
{
<MudChip Size="Size.Small" Color="Color.Info">@cat.Title</MudChip>
}
</MudStack>
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="تاریخ ایجاد">
<CellTemplate>
@context.Item.Created.MiladiToJalali()
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="عملیات" Sortable="false">
<CellTemplate>
<MudStack Row="true" Spacing="1">
@if (context.Item.Status == 0)
{
<MudTooltip Text="انتشار">
<MudIconButton Icon="@Icons.Material.Filled.Publish"
Size="Size.Small"
Color="Color.Success"
OnClick="@(() => PublishPost(context.Item))" />
</MudTooltip>
}
@if (context.Item.Status == 1)
{
<MudTooltip Text="آرشیو">
<MudIconButton Icon="@Icons.Material.Filled.Archive"
Size="Size.Small"
Color="Color.Warning"
OnClick="@(() => ArchivePost(context.Item))" />
</MudTooltip>
}
<MudTooltip Text="ویرایش">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Size="Size.Small"
Color="Color.Primary"
OnClick="@(() => EditPost(context.Item))" />
</MudTooltip>
<MudTooltip Text="حذف">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Size="Size.Small"
Color="Color.Error"
OnClick="@(() => DeletePost(context.Item))" />
</MudTooltip>
</MudStack>
</CellTemplate>
</TemplateColumn>
</Columns>
<NoRecordsContent>
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Text" Class="my-4">موردی یافت نشد.</MudAlert>
</NoRecordsContent>
<PagerContent>
<MudDataGridPager T="BlogPostListItemDto" PageSizeOptions="@(new int[] { 20, 50, 100 })"
InfoFormat="سطر {first_item} تا {last_item} از {all_items}"
RowsPerPageString="تعداد در صفحه" />
</PagerContent>
</MudDataGrid>
</MudStack>
</div>
@@ -0,0 +1,189 @@
using BackOffice.Services.Blog;
using BackOffice.Pages.Blog.Components;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using MudBlazor;
namespace BackOffice.Pages.Blog;
public partial class BlogPostManagementPage
{
private MudDataGrid<BlogPostListItemDto> _grid = default!;
private string? _search;
private int? _statusFilter;
private long? _categoryFilter;
private List<BlogCategoryItemDto> _categories = new();
protected override async Task OnInitializedAsync()
{
_categories = await BlogCategoryService.GetActiveAsync();
}
private async Task<GridData<BlogPostListItemDto>> LoadServerData(GridState<BlogPostListItemDto> state)
{
var filter = new BlogPostFilterDto
{
PageNumber = state.Page + 1,
PageSize = state.PageSize,
SearchTerm = _search,
Status = _statusFilter,
CategoryId = _categoryFilter
};
var result = await BlogPostService.GetAllAsync(filter);
return new GridData<BlogPostListItemDto>
{
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 static Color GetStatusColor(int status) => status switch
{
0 => Color.Default, // Draft
1 => Color.Success, // Published
2 => Color.Warning, // Archived
_ => Color.Default
};
private async Task CreatePost()
{
var parameters = new DialogParameters<BlogPostEditDialog>
{
{ nameof(BlogPostEditDialog.Model), new BlogPostEditDto() },
{ nameof(BlogPostEditDialog.IsEditMode), false },
{ nameof(BlogPostEditDialog.Categories), _categories }
};
var dialog = await DialogService.ShowAsync<BlogPostEditDialog>("ایجاد پست جدید", parameters,
new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Large, FullWidth = true });
var result = await dialog.Result;
if (!result.Canceled)
{
Snackbar.Add("پست با موفقیت ایجاد شد", Severity.Success);
await ReloadAsync();
}
}
private async Task EditPost(BlogPostListItemDto item)
{
var details = await BlogPostService.GetByIdAsync(item.Id);
if (details == null)
{
Snackbar.Add("خطا در دریافت اطلاعات پست", Severity.Error);
return;
}
var dto = new BlogPostEditDto
{
Title = details.Title,
Slug = details.Slug,
Summary = details.Summary,
HtmlContent = details.HtmlContent,
FeaturedImagePath = details.FeaturedImagePath,
FeaturedImageThumbnailPath = details.FeaturedImageThumbnailPath,
CategoryIds = details.Categories.Select(c => c.Id).ToList(),
TagIds = details.Tags.Select(t => t.Id).ToList(),
IsFeatured = details.IsFeatured,
SortOrder = details.SortOrder
};
var parameters = new DialogParameters<BlogPostEditDialog>
{
{ nameof(BlogPostEditDialog.Model), dto },
{ nameof(BlogPostEditDialog.PostId), item.Id },
{ nameof(BlogPostEditDialog.IsEditMode), true },
{ nameof(BlogPostEditDialog.Categories), _categories }
};
var dialog = await DialogService.ShowAsync<BlogPostEditDialog>("ویرایش پست", parameters,
new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Large, FullWidth = true });
var result = await dialog.Result;
if (!result.Canceled)
{
Snackbar.Add("پست با موفقیت به‌روزرسانی شد", Severity.Success);
await ReloadAsync();
}
}
private async Task PublishPost(BlogPostListItemDto item)
{
var confirmed = await DialogService.ShowMessageBox(
"انتشار پست",
$"آیا از انتشار پست «{item.Title}» مطمئن هستید؟",
yesText: "انتشار",
cancelText: "لغو");
if (confirmed == true)
{
var result = await BlogPostService.PublishAsync(item.Id);
if (result.Success)
{
Snackbar.Add("پست با موفقیت منتشر شد", Severity.Success);
}
else
{
Snackbar.Add(result.Message, Severity.Error);
}
await ReloadAsync();
}
}
private async Task ArchivePost(BlogPostListItemDto item)
{
var confirmed = await DialogService.ShowMessageBox(
"آرشیو پست",
$"آیا از آرشیو کردن پست «{item.Title}» مطمئن هستید؟",
yesText: "آرشیو",
cancelText: "لغو");
if (confirmed == true)
{
var result = await BlogPostService.ArchiveAsync(item.Id);
if (result.Success)
{
Snackbar.Add("پست آرشیو شد", Severity.Success);
}
else
{
Snackbar.Add(result.Message, Severity.Error);
}
await ReloadAsync();
}
}
private async Task DeletePost(BlogPostListItemDto item)
{
var confirmed = await DialogService.ShowMessageBox(
"حذف پست",
$"آیا از حذف پست «{item.Title}» مطمئن هستید؟",
yesText: "حذف",
cancelText: "لغو");
if (confirmed == true)
{
await BlogPostService.DeleteAsync(item.Id);
Snackbar.Add("پست حذف شد", Severity.Success);
await ReloadAsync();
}
}
}
@@ -0,0 +1,48 @@
@using BackOffice.Services.Blog
<MudDialog>
<DialogContent>
<EditForm Model="@Model" OnValidSubmit="HandleValidSubmit">
<MudStack Spacing="2">
<MudTextField @bind-Value="Model.Title"
Label="عنوان"
Required="true"
Variant="Variant.Outlined" />
<MudTextField @bind-Value="Model.Slug"
Label="اسلاگ (URL)"
Required="true"
Variant="Variant.Outlined"
HelperText="مثال: tech-news" />
<MudTextField @bind-Value="Model.Description"
Label="توضیحات"
Variant="Variant.Outlined"
Lines="3" />
<MudTextField @bind-Value="Model.IconName"
Label="نام آیکون"
Variant="Variant.Outlined"
HelperText="مثال: category" />
<MudNumericField T="int"
@bind-Value="Model.SortOrder"
Label="ترتیب نمایش"
Variant="Variant.Outlined" />
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<MudSwitch T="bool" @bind-Value="Model.IsActive" Color="Color.Primary" />
<MudText Typo="Typo.body2">فعال</MudText>
</MudStack>
</MudStack>
</EditForm>
</DialogContent>
<DialogActions>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SubmitAsync">
@(_isEditMode ? "ذخیره تغییرات" : "ایجاد دسته‌بندی")
</MudButton>
<MudButton Color="Color.Default" Variant="Variant.Text" OnClick="Cancel">
انصراف
</MudButton>
</DialogActions>
</MudDialog>
@@ -0,0 +1,41 @@
using BackOffice.Services.Blog;
using Microsoft.AspNetCore.Components;
using MudBlazor;
namespace BackOffice.Pages.Blog.Components;
public partial class BlogCategoryEditDialog
{
[CascadingParameter] IMudDialogInstance DialogInstance { get; set; } = default!;
[Inject] public IBlogCategoryService BlogCategoryService { get; set; } = default!;
[Parameter] public BlogCategoryEditDto Model { get; set; } = new();
[Parameter] public long? CategoryId { get; set; }
[Parameter] public bool IsEditMode { get; set; }
private bool _isEditMode => IsEditMode && CategoryId.HasValue;
private async Task HandleValidSubmit()
{
await SubmitAsync();
}
private async Task SubmitAsync()
{
if (_isEditMode && CategoryId.HasValue)
{
await BlogCategoryService.UpdateAsync(CategoryId.Value, Model);
}
else
{
await BlogCategoryService.CreateAsync(Model);
}
DialogInstance.Close(DialogResult.Ok(true));
}
private void Cancel()
{
DialogInstance.Cancel();
}
}
@@ -0,0 +1,93 @@
@using BackOffice.Services.Blog
@using Microsoft.AspNetCore.Components.Forms
@using Tizzani.MudBlazor.HtmlEditor
@using BackOffice.Common.BaseComponents
<MudDialog>
<DialogContent>
<MudForm @ref="_form" @bind-IsValid="@_isValid">
<MudStack Spacing="2">
<MudTextField @bind-Value="Model.Title"
Label="عنوان پست"
Required="true"
Variant="Variant.Outlined" />
<MudTextField @bind-Value="Model.Summary"
Label="خلاصه"
Variant="Variant.Outlined"
Lines="3" />
<MudText Typo="Typo.subtitle2">محتوای HTML</MudText>
<MudHtmlEditor @bind-Html="Model.HtmlContent" MinHeight="300px">
<MudHtmlToolbarOptions InsertImage="false" />
</MudHtmlEditor>
<MudText Typo="Typo.subtitle2">تصویر شاخص</MudText>
<MudStack Justify="Justify.Center" AlignItems="AlignItems.Center">
@if (!string.IsNullOrWhiteSpace(_imagePreview))
{
<Image Src="@_imagePreview" Width="280" Height="180" ObjectPosition="ObjectPosition.Center" ObjectFit="ObjectFit.Cover" />
}
else if (!string.IsNullOrEmpty(Model.FeaturedImagePath))
{
<Image Src="@Model.FeaturedImagePath" Width="280" Height="180" ObjectPosition="ObjectPosition.Center" ObjectFit="ObjectFit.Cover" />
}
else
{
<MudPaper Class="d-flex align-center justify-center" Style="width:280px;height:180px;">
<MudText Typo="Typo.caption">تصویری انتخاب نشده است</MudText>
</MudPaper>
}
<MudFileUpload T="IBrowserFile" Accept="image/*" FilesChanged="OnImageSelected">
<ActivatorContent>
<MudButton HtmlTag="label"
Variant="Variant.Filled"
Color="Color.Primary"
ButtonType="ButtonType.Button"
StartIcon="@Icons.Material.Filled.Image">
انتخاب تصویر شاخص
</MudButton>
</ActivatorContent>
<SelectedTemplate>
@if (context != null)
{
<MudText Class="mt-1" Typo="Typo.caption">@context.Name</MudText>
}
</SelectedTemplate>
</MudFileUpload>
</MudStack>
<MudSelect T="long"
Label="دسته‌بندی‌ها"
MultiSelection="true"
@bind-SelectedValues="_selectedCategoryIds"
Variant="Variant.Outlined"
MultiSelectionTextFunc="@(new Func<List<string>, string>(GetCategorySelectionText))">
@foreach (var cat in Categories)
{
<MudSelectItem T="long" Value="@cat.Id">@cat.Title</MudSelectItem>
}
</MudSelect>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudSwitch T="bool" @bind-Value="Model.IsFeatured" Color="Color.Warning" />
<MudText Typo="Typo.body2">پست ویژه</MudText>
</MudStack>
<MudNumericField T="int"
@bind-Value="Model.SortOrder"
Label="ترتیب نمایش"
Variant="Variant.Outlined" />
</MudStack>
</MudForm>
</DialogContent>
<DialogActions>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SubmitAsync" Disabled="@(!_isValid || _loading)">
@(_isEditMode ? "ذخیره تغییرات" : "ایجاد پست")
</MudButton>
<MudButton Color="Color.Default" Variant="Variant.Text" OnClick="Cancel">
انصراف
</MudButton>
</DialogActions>
</MudDialog>
@@ -0,0 +1,115 @@
using BackOffice.Services.Blog;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Forms;
using MudBlazor;
namespace BackOffice.Pages.Blog.Components;
public partial class BlogPostEditDialog
{
[CascadingParameter] IMudDialogInstance DialogInstance { get; set; } = default!;
[Inject] public IBlogPostService BlogPostService { get; set; } = default!;
[Parameter] public BlogPostEditDto Model { get; set; } = new();
[Parameter] public long? PostId { get; set; }
[Parameter] public bool IsEditMode { get; set; }
[Parameter] public List<BlogCategoryItemDto> Categories { get; set; } = new();
private bool _isEditMode => IsEditMode && PostId.HasValue;
private MudForm? _form;
private bool _isValid = true;
private bool _loading;
private IBrowserFile? _imageFile;
private byte[]? _imageBuffer;
private string? _imagePreview;
private readonly long _maxAllowedSize = (1024 * 1024) * 5;
private IEnumerable<long> _selectedCategoryIds
{
get => Model.CategoryIds;
set => Model.CategoryIds = value.ToList();
}
private string GetCategorySelectionText(List<string> selectedValues)
{
if (selectedValues == null || !selectedValues.Any())
return "انتخاب دسته‌بندی";
var names = new List<string>();
foreach (var val in selectedValues)
{
if (long.TryParse(val, out var id))
{
var cat = Categories.FirstOrDefault(c => c.Id == id);
if (cat != null) names.Add(cat.Title);
}
}
return names.Any() ? string.Join("، ", names) : "انتخاب دسته‌بندی";
}
private async Task OnImageSelected(IBrowserFile? file)
{
_imageFile = file;
if (file != null)
{
using var ms = new MemoryStream();
await file.OpenReadStream(_maxAllowedSize).CopyToAsync(ms);
_imageBuffer = ms.ToArray();
_imagePreview = $"data:{file.ContentType};base64," + Convert.ToBase64String(_imageBuffer);
StateHasChanged();
}
}
private async Task HandleValidSubmit()
{
await SubmitAsync();
}
private async Task SubmitAsync()
{
if (_form != null)
{
await _form.Validate();
if (!_isValid) return;
}
_loading = true;
// Handle image upload
if (_imageFile != null && _imageBuffer != null)
{
Model.ImageFile = _imageBuffer;
Model.ImageMime = _imageFile.ContentType;
Model.ImageFileName = Path.GetFileNameWithoutExtension(_imageFile.Name);
}
try
{
if (_isEditMode && PostId.HasValue)
{
await BlogPostService.UpdateAsync(PostId.Value, Model);
}
else
{
await BlogPostService.CreateAsync(Model);
}
DialogInstance.Close(DialogResult.Ok(true));
}
catch (Exception ex)
{
Snackbar.Add($"خطا: {ex.Message}", Severity.Error);
}
finally
{
_loading = false;
}
}
private void Cancel()
{
DialogInstance.Cancel();
}
}
@@ -7,21 +7,25 @@
<BasePageComponent @ref="_basePage" OnClearFilterClick="OnFilterCleared" OnSubmitClick="OnFilterSubmit">
<Filters>
<MudTextField T="string" Clearable="true" Label="عنوان" @bind-Value="@_request.Filter.Title" Variant="Variant.Outlined" Margin="Margin.Dense" />
<CategoryAutoComplete Value="_parentFilterId"
ValueChanged="OnParentFilterChanged"
Label="فیلتر بر اساس دسته‌بندی والد" />
<MudItem xs="12" sm="6" md="3">
<MudTextField T="string" Clearable="true" Label="عنوان" @bind-Value="@_request.Filter.Title" Variant="Variant.Outlined" Margin="Margin.Dense" />
</MudItem>
<MudItem xs="12" sm="6" md="3">
<CategoryAutoComplete Value="_parentFilterId"
ValueChanged="OnParentFilterChanged"
Label="فیلتر بر اساس دسته‌بندی والد" />
</MudItem>
</Filters>
<Content>
<MudGrid>
<MudItem xs="12" md="3">
<MudPaper Class="pa-2" Style="height:72vh; overflow:auto;">
<MudPaper Class="pa-2" Style="height:calc(100vh - 240px); overflow:auto;">
<MudText Typo="Typo.subtitle2" Class="mb-2">درخت دسته‌بندی‌ها</MudText>
<MudList T="string" Dense="true">
<MudListItem T="string"
OnClick="@(() => OnTreeNodeSelected(null))"
Selected="@(_parentFilterId == null)"
Style="cursor:pointer;">
>
<MudText Typo="Typo.body2">همه دسته‌بندی‌ها</MudText>
</MudListItem>
@foreach (var node in _treeNodes)
@@ -29,7 +33,7 @@
<MudListItem T="string"
OnClick="@(() => OnTreeNodeSelected(node.Id))"
Selected="@(_parentFilterId == node.Id)"
Style="cursor:pointer;">
>
<MudStack Row="true" AlignItems="AlignItems.Center">
<div style="width:@(node.Level * 16)px;"></div>
<MudText Typo="Typo.body2">@node.Title</MudText>
@@ -44,13 +48,14 @@
ServerData="@(new Func<GridState<DataModel>, Task<GridData<DataModel>>>(ServerReload))"
Hover="true"
@ref="_gridData"
Height="72vh">
Height="calc(100vh - 240px)">
<ColGroup>
<col />
<col />
<col />
<col />
<col />
<col />
<col style="width: 80px;" />
</ColGroup>
<ToolBarContent>
@@ -62,7 +67,7 @@
Size="Size.Large"
ButtonType="ButtonType.Button"
OnClick="CreateNew"
Style="cursor:pointer;">
>
افزودن
</MudButton>
</MudStack>
@@ -85,6 +90,8 @@
<MudText Typo="Typo.body2">@parentTitle</MudText>
</CellTemplate>
</TemplateColumn>
<PropertyColumn Property="x => x.SortOrder" Title="ترتیب" />
<PropertyColumn Property="x => x.ProductCount" Title="تعداد محصولات" />
<TemplateColumn Title="فعال؟">
<CellTemplate>
<MudChip Color="@(context.Item.IsActive ? Color.Success : Color.Error)"
@@ -102,15 +109,16 @@
Size="Size.Small"
ButtonType="ButtonType.Button"
OnClick="@(() => Update(context.Item))"
Style="cursor:pointer;" />
/>
</MudTooltip>
<MudTooltip Text="حذف">
<MudIconButton Icon="@Icons.Material.Filled.DeleteOutline"
Size="Size.Small"
ButtonType="ButtonType.Button"
Disabled="@HasChildren(context.Item.Id)"
OnClick="@(() => OnDelete(context.Item))"
Style="cursor:pointer;" />
/>
</MudTooltip>
<MudTooltip Text="مدیریت محصولات این دسته (درگ و دراپ)">
@@ -118,17 +126,29 @@
Size="Size.Small"
ButtonType="ButtonType.Button"
OnClick="@(() => OpenCategoryProducts(context.Item))"
Style="cursor:pointer;" />
/>
</MudTooltip>
<MudTooltip Text="افزودن زیردسته">
<MudIconButton Icon="@Icons.Material.Filled.CreateNewFolder"
Size="Size.Small"
Color="Color.Primary"
ButtonType="ButtonType.Button"
OnClick="@(() => CreateSubcategory(context.Item))"
/>
</MudTooltip>
</MudStack>
</CellTemplate>
</TemplateColumn>
</Columns>
<NoRecordsContent>
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Text" Class="my-4">موردی یافت نشد.</MudAlert>
</NoRecordsContent>
<PagerContent>
<MudDataGridPager T="DataModel"
PageSizeOptions="@(new int[] { 30, 60, 90 })"
PageSizeOptions="@(new int[] { 20, 50, 100 })"
InfoFormat="سطر {first_item} تا {last_item} از {all_items}"
RowsPerPageString="تعداد سطرهای صفحه" />
RowsPerPageString="تعداد در صفحه" />
</PagerContent>
</MudDataGrid>
</MudItem>
@@ -152,12 +152,30 @@ public partial class CategoryMainPage
}
}
public async Task CreateSubcategory(DataModel parent)
{
var dialog = await DialogService.ShowAsync<CreateOrUpdateCategoryDialog>(
$"افزودن زیردسته برای «{parent.Title}»",
new DialogParameters<CreateOrUpdateCategoryDialog>
{
{ x => x.Model, new CreateNewCategoryRequest { IsActive = true, ParentId = parent.Id } }
},
new DialogOptions { CloseButton = true, FullWidth = true, MaxWidth = MaxWidth.Small });
var result = await dialog.Result;
if (!result.Canceled)
{
await ReloadData();
await LoadTreeAsync();
Snackbar.Add("زیردسته با موفقیت ایجاد شد.", Severity.Success);
}
}
public async Task Update(DataModel model)
{
var getResponse = await CategoryContract.GetCategoryAsync(new GetCategoryRequest { Id = model.Id });
var editModel = getResponse.Adapt<UpdateCategoryRequest>();
// Console.WriteLine(JsonSerializer.Serialize(editModel));
var dialog = await DialogService.ShowAsync<CreateOrUpdateCategoryDialog>(
"ویرایش دسته‌بندی",
new DialogParameters<CreateOrUpdateCategoryDialog>
@@ -180,7 +198,7 @@ public partial class CategoryMainPage
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small };
bool? result = await DialogService.ShowMessageBox(
"اخطار",
"آیا از حذف این دسته‌بندی مطمئن هستید؟",
$"آیا از حذف دسته‌بندی «{model.Title}» مطمئن هستید؟",
yesText: "حذف",
cancelText: "لغو",
options: options);
@@ -238,4 +256,9 @@ public partial class CategoryMainPage
{
Navigation.NavigateTo($"{RouteConstance.CategoryProducts}{model.Id}");
}
private bool HasChildren(long categoryId)
{
return _treeNodes.Any(n => n.ParentId == categoryId);
}
}
@@ -3,44 +3,73 @@
@using BackOffice.Pages.AutoComplete
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">
<MudIcon Icon="@(IsEdit ? Icons.Material.Filled.Edit : Icons.Material.Filled.Add)" Class="ml-2" />
@(IsEdit ? "ویرایش دسته‌بندی" : "ایجاد دسته‌بندی جدید")
</MudText>
</TitleContent>
<DialogContent>
<MudStack Spacing="2">
<MudTextField T="string"
@bind-Value="Name"
Label="نام لاتین"
Variant="Variant.Outlined"
Margin="Margin.Dense" />
<MudForm @ref="_form" @bind-IsValid="@_isValid">
<MudStack Spacing="2">
<MudTextField T="string"
@bind-Value="Name"
Label="نام لاتین"
Required="true"
RequiredError="نام لاتین الزامی است"
Variant="Variant.Outlined"
Margin="Margin.Dense" />
<MudTextField T="string"
@bind-Value="Title"
Label="عنوان"
Variant="Variant.Outlined"
Margin="Margin.Dense" />
<MudTextField T="string"
@bind-Value="Title"
Label="عنوان"
Required="true"
RequiredError="عنوان الزامی است"
Variant="Variant.Outlined"
Margin="Margin.Dense" />
<MudTextField T="string"
@bind-Value="Description"
Label="توضیحات"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Lines="3"
TextArea="true" />
<MudTextField T="string"
@bind-Value="Description"
Label="توضیحات"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Lines="3"
TextArea="true" />
<CategoryAutoComplete Value="ParentId"
ValueChanged="OnParentChanged"
Label="دسته‌بندی والد (اختیاری)" />
<MudTextField T="string"
@bind-Value="ImagePath"
Label="آدرس تصویر"
Variant="Variant.Outlined"
Margin="Margin.Dense" />
<MudNumericField T="int"
@bind-Value="SortOrder"
HideSpinButtons="true"
Label="ترتیب نمایش"
Variant="Variant.Outlined"
Margin="Margin.Dense" />
<CategoryAutoComplete Value="ParentId"
ValueChanged="OnParentChanged"
Label="دسته‌بندی والد (اختیاری)" />
<MudText Typo="Typo.caption" Color="Color.Secondary">برای دسته اصلی خالی بگذارید</MudText>
<MudCheckBox T="bool" @bind-Value="IsActive" Label="فعال" />
</MudStack>
<MudNumericField T="int"
@bind-Value="SortOrder"
HideSpinButtons="true"
Label="ترتیب نمایش"
Variant="Variant.Outlined"
Margin="Margin.Dense"
HelperText="عدد کمتر = اولویت بالاتر" />
<MudSwitch T="bool" @bind-Value="IsActive" Label="فعال" Color="Color.Success" />
</MudStack>
</MudForm>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">لغو</MudButton>
<MudButton Color="Color.Primary" OnClick="Save">ثبت</MudButton>
<MudButton OnClick="Cancel" Variant="Variant.Text">لغو</MudButton>
<MudButton Color="Color.Primary"
Variant="Variant.Filled"
OnClick="Save"
Disabled="@(!_isValid || _isSaving)">
@if (_isSaving)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="ml-2" />
}
@(IsEdit ? "ذخیره تغییرات" : "ایجاد دسته‌بندی")
</MudButton>
</DialogActions>
</MudDialog>
@@ -14,9 +14,14 @@ public partial class CreateOrUpdateCategoryDialog
private bool IsEdit => EditModel != null;
private MudForm? _form;
private bool _isValid;
private bool _isSaving;
private string Name { get; set; } = string.Empty;
private string Title { get; set; } = string.Empty;
private string? Description { get; set; }
private string? ImagePath { get; set; }
private long? ParentId { get; set; }
private bool IsActive { get; set; } = true;
private int SortOrder { get; set; }
@@ -28,9 +33,9 @@ public partial class CreateOrUpdateCategoryDialog
Name = EditModel.Name;
Title = EditModel.Title;
Description = EditModel.Description;
ImagePath = EditModel.ImagePath;
ParentId = EditModel.ParentId;
IsActive = EditModel.IsActive;
Console.WriteLine(EditModel.IsActive);
SortOrder = EditModel.SortOrder;
}
else if (Model != null)
@@ -38,6 +43,7 @@ public partial class CreateOrUpdateCategoryDialog
Name = Model.Name;
Title = Model.Title;
Description = Model.Description;
ImagePath = Model.ImagePath;
ParentId = Model.ParentId;
IsActive = Model.IsActive;
SortOrder = Model.SortOrder;
@@ -46,37 +52,60 @@ public partial class CreateOrUpdateCategoryDialog
private async Task Save()
{
if (IsEdit && EditModel != null)
if (_form != null)
{
var request = new UpdateCategoryRequest
{
Id = EditModel.Id,
Name = Name,
Title = Title,
Description = Description,
ParentId = ParentId,
IsActive = IsActive,
SortOrder = SortOrder
};
await CategoryContract.UpdateCategoryAsync(request);
}
else
{
var request = new CreateNewCategoryRequest
{
Name = Name,
Title = Title,
Description = Description,
ParentId = ParentId,
IsActive = IsActive,
SortOrder = SortOrder
};
await CategoryContract.CreateNewCategoryAsync(request);
await _form.Validate();
if (!_isValid) return;
}
MudDialog.Close(DialogResult.Ok(true));
_isSaving = true;
StateHasChanged();
try
{
if (IsEdit && EditModel != null)
{
var request = new UpdateCategoryRequest
{
Id = EditModel.Id,
Name = Name,
Title = Title,
Description = Description,
ImagePath = ImagePath,
ParentId = ParentId,
IsActive = IsActive,
SortOrder = SortOrder
};
await CategoryContract.UpdateCategoryAsync(request);
}
else
{
var request = new CreateNewCategoryRequest
{
Name = Name,
Title = Title,
Description = Description,
ImagePath = ImagePath,
ParentId = ParentId,
IsActive = IsActive,
SortOrder = SortOrder
};
await CategoryContract.CreateNewCategoryAsync(request);
}
MudDialog.Close(DialogResult.Ok(true));
}
catch (Exception ex)
{
Snackbar.Add($"خطا: {ex.Message}", Severity.Error);
}
finally
{
_isSaving = false;
StateHasChanged();
}
}
private Task OnParentChanged(long? parentId)
@@ -0,0 +1,197 @@
@page "/club/features"
@attribute [Authorize(Roles = "Administrator")]
@using CMSMicroservice.Protobuf.Protos.Configuration
@using CMSMicroservice.Protobuf.Protos.ClubMembership
@using BackOffice.Pages.AutoComplete
@inject ConfigurationContract.ConfigurationContractClient ConfigClient
@inject ClubMembershipContract.ClubMembershipContractClient ClubClient
<div>
<MudText Typo="Typo.h5" Class="mb-3">فیچرهای باشگاه</MudText>
<MudTabs Elevation="0" Rounded="true" Border="true" ApplyEffectsToContainer="true"
PanelClass="pt-4" @bind-ActivePanelIndex="_activeTab">
@* ── تب اول: لیست فیچرها ── *@
<MudTabPanel Text="فیچرهای تعریف‌شده" Icon="@Icons.Material.Filled.Star">
@if (_loadingFeatures)
{
<MudProgressLinear Color="Color.Primary" Indeterminate="true" />
}
else if (_features.Count == 0)
{
<MudAlert Severity="Severity.Info" Variant="Variant.Text">فیچری تعریف نشده است.</MudAlert>
}
else
{
<MudGrid Spacing="2">
@foreach (var feature in _features)
{
<MudItem xs="12" sm="6" md="4">
<MudPaper Class="pa-3 rounded-lg" Outlined="true">
<MudStack Spacing="1">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.subtitle1"><strong>@feature.Title</strong></MudText>
<MudChip T="string" Size="Size.Small"
Color="@(feature.IsEnabled ? Color.Success : Color.Error)">
@(feature.IsEnabled ? "فعال" : "غیرفعال")
</MudChip>
</MudStack>
@if (!string.IsNullOrWhiteSpace(feature.Description))
{
<MudText Typo="Typo.body2" Color="Color.Secondary">@feature.Description</MudText>
}
<MudText Typo="Typo.caption">ترتیب: @feature.DisplayOrder</MudText>
</MudStack>
</MudPaper>
</MudItem>
}
</MudGrid>
}
</MudTabPanel>
@* ── تب دوم: فیچرهای کاربران ── *@
<MudTabPanel Text="فیچرهای کاربران" Icon="@Icons.Material.Filled.PeopleAlt">
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center" Class="mb-3">
<UserAutoComplete Label="جستجوی کاربر" @bind-SelectedUserId="_featureFilterUserId" />
<MudButton Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Search"
OnClick="LoadUserFeatures">جستجو</MudButton>
</MudStack>
@if (_loadingUserFeatures)
{
<MudProgressLinear Color="Color.Primary" Indeterminate="true" />
}
else if (_featureFilterUserId == null || _featureFilterUserId == 0)
{
<MudAlert Severity="Severity.Info" Variant="Variant.Text">لطفاً یک کاربر انتخاب کنید.</MudAlert>
}
else if (_userFeatures.Count == 0)
{
<MudAlert Severity="Severity.Info" Variant="Variant.Text">فیچری برای این کاربر یافت نشد.</MudAlert>
}
else
{
<MudDataGrid T="UserClubFeatureModel" Items="_userFeatures"
Hover="true" Dense="true"
Height="calc(100vh - 340px)">
<Columns>
<PropertyColumn Property="x => x.Id" Title="شناسه" />
<PropertyColumn Property="x => x.FeatureTitle" Title="عنوان فیچر" />
<TemplateColumn Title="توضیحات">
<CellTemplate>
<MudText Typo="Typo.body2">@(string.IsNullOrWhiteSpace(context.Item.FeatureDescription) ? "—" : context.Item.FeatureDescription)</MudText>
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="وضعیت">
<CellTemplate>
<MudSwitch T="bool" Value="context.Item.IsActive"
Color="Color.Success"
ValueChanged="@(val => ToggleFeature(context.Item, val))" />
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="تاریخ اختصاص">
<CellTemplate>
@if (context.Item.GrantedAt != null)
{
@context.Item.GrantedAt.ToDateTime().ToLocalTime().MiladiToJalali()
}
</CellTemplate>
</TemplateColumn>
<PropertyColumn Property="x => x.Notes" Title="یادداشت" />
</Columns>
<NoRecordsContent>
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Text" Class="my-4">موردی یافت نشد.</MudAlert>
</NoRecordsContent>
<PagerContent>
<MudDataGridPager T="UserClubFeatureModel" PageSizeOptions="@(new int[] { 20, 50, 100 })"
InfoFormat="سطر {first_item} تا {last_item} از {all_items}"
RowsPerPageString="تعداد در صفحه" />
</PagerContent>
</MudDataGrid>
}
</MudTabPanel>
</MudTabs>
</div>
@code {
private int _activeTab;
private bool _loadingFeatures;
private bool _loadingUserFeatures;
// Features tab
private List<ClubFeatureModel> _features = new();
// User features tab
private long? _featureFilterUserId;
private List<UserClubFeatureModel> _userFeatures = new();
protected override async Task OnInitializedAsync()
{
await LoadFeatures();
}
private async Task LoadFeatures()
{
_loadingFeatures = true;
try
{
var result = await ConfigClient.GetClubFeaturesAsync(new Google.Protobuf.WellKnownTypes.Empty());
_features = result?.Features?.ToList() ?? new();
}
catch (Exception ex)
{
Snackbar.Add($"خطا در بارگذاری فیچرها: {ex.Message}", Severity.Error);
}
finally
{
_loadingFeatures = false;
}
}
private async Task LoadUserFeatures()
{
if (_featureFilterUserId == null || _featureFilterUserId == 0) return;
_loadingUserFeatures = true;
try
{
var result = await ClubClient.GetUserClubFeaturesAsync(new GetUserClubFeaturesRequest
{
UserId = _featureFilterUserId.Value
});
_userFeatures = result?.Features?.ToList() ?? new();
}
catch (Exception ex)
{
Snackbar.Add($"خطا: {ex.Message}", Severity.Error);
}
finally
{
_loadingUserFeatures = false;
}
}
private async Task ToggleFeature(UserClubFeatureModel feature, bool isActive)
{
try
{
await ClubClient.ToggleUserClubFeatureAsync(new ToggleUserClubFeatureRequest
{
UserId = feature.UserId,
ClubFeatureId = feature.ClubFeatureId,
IsActive = isActive
});
feature.IsActive = isActive;
Snackbar.Add($"فیچر «{feature.FeatureTitle}» {(isActive ? "فعال" : "غیرفعال")} شد", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"خطا: {ex.Message}", Severity.Error);
}
}
}
+32
View File
@@ -0,0 +1,32 @@
@page "/club"
@page "/club/members"
@page "/club/statistics"
@attribute [Authorize]
<div>
<MudText Typo="Typo.h5" Class="mb-3">باشگاه مشتریان</MudText>
<MudTabs Elevation="0" Rounded="true" Border="true" ApplyEffectsToContainer="true"
PanelClass="pt-4" @bind-ActivePanelIndex="_activeTab">
<MudTabPanel Text="اعضای باشگاه" Icon="@Icons.Material.Filled.Groups">
<ClubMembers />
</MudTabPanel>
<MudTabPanel Text="آمار باشگاه" Icon="@Icons.Material.Filled.PieChart">
<Statistics />
</MudTabPanel>
</MudTabs>
</div>
@code {
private int _activeTab;
protected override void OnInitialized()
{
var uri = Navigation.ToBaseRelativePath(Navigation.Uri).ToLowerInvariant().TrimEnd('/');
_activeTab = uri switch
{
"club/statistics" => 1,
_ => 0
};
}
}
+16 -7
View File
@@ -1,18 +1,18 @@
@page "/club/members"
@* Merged into /club *@
@attribute [Authorize]
@using Google.Protobuf.WellKnownTypes
@using BackOffice.Pages.Club.Components
@using CMSMicroservice.Protobuf.Protos.ClubMembership
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="mt-4">
<div>
<MudText Typo="Typo.h4" Class="mb-4">مدیریت اعضای باشگاه</MudText>
<MudDataGrid T="ClubMembershipModel"
ServerData="@(new Func<GridState<ClubMembershipModel>, Task<GridData<ClubMembershipModel>>>(ServerReload))"
Hover="true"
@ref="_gridData"
Height="75vh">
Height="calc(100vh - 240px)">
<ToolBarContent>
<MudText Typo="Typo.h6">لیست اعضا</MudText>
<MudSpacer />
@@ -32,6 +32,12 @@
StartIcon="@Icons.Material.Filled.Add">
فعال‌سازی عضو جدید
</MudButton>
<MudButton Variant="Variant.Outlined"
Color="Color.Info"
OnClick="ExportToExcel"
StartIcon="@Icons.Material.Filled.FileDownload">
خروجی Excel
</MudButton>
</MudStack>
</ToolBarContent>
<Columns>
@@ -58,14 +64,14 @@
<PropertyColumn Property="x => x.ActivatedAt" Title="تاریخ فعال‌سازی">
<CellTemplate>
@context.Item.ActivatedAt.ToDateTime().ToLocalTime().ToString("yyyy/MM/dd")
@(context.Item.ActivatedAt != null ? context.Item.ActivatedAt.ToDateTime().ToLocalTime().MiladiToJalali() : "—")
</CellTemplate>
</PropertyColumn>
<PropertyColumn Property="x => x.ExpiresAt" Title="تاریخ انقضا">
<CellTemplate>
<MudText Color="@(context.Item.IsExpired ? Color.Error : Color.Success)">
@context.Item.ExpiresAt.ToDateTime().ToLocalTime().ToString("yyyy/MM/dd")
@(context.Item.ExpiresAt != null ? context.Item.ExpiresAt.ToDateTime().ToLocalTime().MiladiToJalali() : "—")
</MudText>
</CellTemplate>
</PropertyColumn>
@@ -102,11 +108,14 @@
</CellTemplate>
</TemplateColumn>
</Columns>
<NoRecordsContent>
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Text" Class="my-4">موردی یافت نشد.</MudAlert>
</NoRecordsContent>
<PagerContent>
<MudDataGridPager T="ClubMembershipModel"
PageSizeOptions="@(new int[] { 20, 50, 100 })"
InfoFormat="سطر {first_item} تا {last_item} از {all_items}"
RowsPerPageString="تعداد سطرهای صفحه" />
RowsPerPageString="تعداد در صفحه" />
</PagerContent>
</MudDataGrid>
</MudContainer>
</div>
@@ -3,12 +3,16 @@ using MudBlazor;
using Google.Protobuf.WellKnownTypes;
using BackOffice.Pages.Club.Components;
using CMSMicroservice.Protobuf.Protos.ClubMembership;
using Microsoft.JSInterop;
using System.Text;
using DateTimeConverterCL;
namespace BackOffice.Pages.Club;
public partial class ClubMembers
{
[Inject] public ClubMembershipContract.ClubMembershipContractClient ClubContract { get; set; }
[Inject] public IJSRuntime JsRuntime { get; set; }
private MudDataGrid<ClubMembershipModel> _gridData;
private bool _filterIsActive = true;
@@ -108,4 +112,61 @@ public partial class ClubMembers
await ApplyFilter();
}
}
private async Task ExportToExcel()
{
try
{
var request = new GetAllClubMembershipsRequest
{
PageIndex = 1,
PageSize = 1000,
IsActive = _filterIsActive
};
var result = await ClubContract.GetAllClubMembershipsAsync(request);
if (result?.Models == null || !result.Models.Any())
{
Snackbar.Add("داده‌ای برای خروجی وجود ندارد.", Severity.Info);
return;
}
var sb = new StringBuilder();
sb.AppendLine("شناسه,کاربر,نام کاربر,پکیج,کد فعال‌سازی,تاریخ فعال‌سازی,تاریخ انقضا,فعال,منقضی");
foreach (var m in result.Models)
{
sb.AppendLine(string.Join(",",
m.Id,
m.UserId,
EscapeCsv(m.UserName),
EscapeCsv(m.PackageName),
EscapeCsv(m.ActivationCode),
m.ActivatedAt.ToDateTime().ToLocalTime().MiladiToJalaliWithTime(),
m.ExpiresAt.ToDateTime().ToLocalTime().MiladiToJalaliWithTime(),
m.IsActive ? "فعال" : "غیرفعال",
m.IsExpired ? "بله" : "خیر"));
}
var bytes = Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes(sb.ToString())).ToArray();
var base64 = Convert.ToBase64String(bytes);
var filename = $"club-members-{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);
}
}
private static string EscapeCsv(string value)
{
if (string.IsNullOrEmpty(value)) return "";
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
return $"\"{value.Replace("\"", "\"\"")}\"";
return value;
}
}
@@ -41,13 +41,13 @@
</tr>
<tr>
<td><strong>تاریخ فعال‌سازی:</strong></td>
<td>@Member.ActivatedAt.ToDateTime().ToLocalTime().ToString("yyyy/MM/dd HH:mm")</td>
<td>@Member.ActivatedAt.ToDateTime().ToLocalTime().MiladiToJalaliWithTime()</td>
</tr>
<tr>
<td><strong>تاریخ انقضا:</strong></td>
<td>
<MudText Color="@(Member.IsExpired ? Color.Error : Color.Success)">
@Member.ExpiresAt.ToDateTime().ToLocalTime().ToString("yyyy/MM/dd HH:mm")
@Member.ExpiresAt.ToDateTime().ToLocalTime().MiladiToJalaliWithTime()
</MudText>
</td>
</tr>
@@ -82,10 +82,38 @@
<tbody>
<tr>
<td><strong>تاریخ ایجاد:</strong></td>
<td>@Member.Created.ToDateTime().ToLocalTime().ToString("yyyy/MM/dd HH:mm:ss")</td>
<td>@Member.Created.ToDateTime().ToLocalTime().MiladiToJalaliWithTime()</td>
</tr>
</tbody>
</MudSimpleTable>
@if (_historyLoading)
{
<MudProgressLinear Indeterminate="true" Color="Color.Primary" Class="my-2" />
}
else if (_history.Any())
{
<MudTable T="ClubMembershipHistoryModel" Items="_history" Dense="true" Hover="true" Class="mt-2">
<HeaderContent>
<MudTh>عملیات</MudTh>
<MudTh>تاریخ</MudTh>
<MudTh>دلیل</MudTh>
<MudTh>توسط</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="عملیات">
<MudChip T="string" Size="Size.Small" Color="Color.Info">@context.Action</MudChip>
</MudTd>
<MudTd DataLabel="تاریخ">@context.Created.ToDateTime().ToLocalTime().MiladiToJalaliWithTime()</MudTd>
<MudTd DataLabel="دلیل">@(string.IsNullOrEmpty(context.Reason) ? "-" : context.Reason)</MudTd>
<MudTd DataLabel="توسط">@(string.IsNullOrEmpty(context.PerformedBy) ? "-" : context.PerformedBy)</MudTd>
</RowTemplate>
</MudTable>
}
else
{
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mt-2">تاریخچه‌ای یافت نشد.</MudText>
}
</MudPaper>
</MudStack>
}
@@ -98,6 +126,39 @@
@code {
[CascadingParameter] IMudDialogInstance MudDialog { get; set; }
[Parameter] public ClubMembershipModel Member { get; set; }
[Inject] public ClubMembershipContract.ClubMembershipContractClient ClubClient { get; set; }
private List<ClubMembershipHistoryModel> _history = new();
private bool _historyLoading;
protected override async Task OnInitializedAsync()
{
await LoadHistory();
}
private async Task LoadHistory()
{
_historyLoading = true;
try
{
var request = new GetClubMembershipHistoryRequest
{
UserId = Member.UserId,
PageIndex = 0,
PageSize = 50
};
var response = await ClubClient.GetClubMembershipHistoryAsync(request);
_history = response.Models?.ToList() ?? new();
}
catch
{
_history = new();
}
finally
{
_historyLoading = false;
}
}
private void Close() => MudDialog.Close();
}
+4 -4
View File
@@ -1,9 +1,9 @@
@page "/club/statistics"
@* Merged into /club *@
@using MudBlazor
@using CMSMicroservice.Protobuf.Protos.ClubMembership
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="mt-4">
<div>
<MudText Typo="Typo.h4" GutterBottom="true">آمار باشگاه</MudText>
<MudText Typo="Typo.body1" Color="Color.Secondary" Class="mb-4">
نمای کلی از وضعیت عضویت‌های باشگاه
@@ -139,7 +139,7 @@
<MudTh>عملیات</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="تاریخ">@context.ActivatedAt.ToString("yyyy/MM/dd")</MudTd>
<MudTd DataLabel="تاریخ">@context.ActivatedAt.MiladiToJalali()</MudTd>
<MudTd DataLabel="شناسه کاربر">@context.UserId</MudTd>
<MudTd DataLabel="نام کاربر">@context.UserName</MudTd>
<MudTd DataLabel="پکیج">
@@ -166,7 +166,7 @@
</MudCardContent>
</MudCard>
}
</MudContainer>
</div>
@code {
[Inject] public ClubMembershipContract.ClubMembershipContractClient ClubClient { get; set; }
@@ -89,12 +89,12 @@
<tbody>
<tr>
<td><strong>تاریخ ایجاد:</strong></td>
<td>@Payout.Created.ToDateTime().ToLocalTime().ToString("yyyy/MM/dd HH:mm:ss")</td>
<td>@Payout.Created.ToDateTime().ToLocalTime().MiladiToJalaliWithTime()</td>
</tr>
<tr>
<td><strong>آخرین ویرایش:</strong></td>
@* CMS UserCommissionPayoutModel does not have LastModified - using Created as fallback *@
<td>@Payout.Created.ToDateTime().ToLocalTime().ToString("yyyy/MM/dd HH:mm:ss")</td>
<td>@Payout.Created.ToDateTime().ToLocalTime().MiladiToJalaliWithTime()</td>
</tr>
</tbody>
</MudSimpleTable>
@@ -0,0 +1,33 @@
<MudDialog>
<DialogContent>
<MudStack Spacing="3">
<MudAlert Severity="Severity.Warning" Dense="true">
درخواست برداشت @Amount.ToString("N0") ریال برای @UserName رد خواهد شد.
</MudAlert>
<MudTextField @bind-Value="_reason"
Label="دلیل رد درخواست"
Variant="Variant.Outlined"
Lines="3"
Required="true"
RequiredError="لطفاً دلیل رد درخواست را وارد کنید" />
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">انصراف</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Error" OnClick="Submit"
Disabled="@string.IsNullOrWhiteSpace(_reason)">
رد درخواست
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter] public string UserName { get; set; } = "";
[Parameter] public long Amount { get; set; }
private string _reason = "رد توسط مدیر";
private void Cancel() => MudDialog.Cancel();
private void Submit() => MudDialog.Close(DialogResult.Ok(_reason));
}
@@ -0,0 +1,129 @@
<MudDialog>
<DialogContent>
<MudStack Spacing="3">
<MudPaper Class="pa-3" Elevation="0" Outlined="true">
<MudText Typo="Typo.subtitle1" Color="Color.Primary" Class="mb-2">اطلاعات کاربر</MudText>
<MudSimpleTable Dense="true">
<tbody>
<tr>
<td><strong>نام کاربر:</strong></td>
<td>@Request.UserName</td>
</tr>
<tr>
<td><strong>شناسه کاربر:</strong></td>
<td>@Request.UserId</td>
</tr>
</tbody>
</MudSimpleTable>
</MudPaper>
<MudPaper Class="pa-3" Elevation="0" Outlined="true">
<MudText Typo="Typo.subtitle1" Color="Color.Primary" Class="mb-2">اطلاعات درخواست</MudText>
<MudSimpleTable Dense="true">
<tbody>
<tr>
<td><strong>شناسه درخواست:</strong></td>
<td>@Request.Id</td>
</tr>
<tr>
<td><strong>مبلغ:</strong></td>
<td><MudText Color="Color.Primary"><strong>@Request.Amount.ToString("N0") ریال</strong></MudText></td>
</tr>
<tr>
<td><strong>روش برداشت:</strong></td>
<td>
<MudChip T="string" Size="Size.Small" Color="Color.Info">@MethodText</MudChip>
</td>
</tr>
<tr>
<td><strong>وضعیت:</strong></td>
<td>
<MudChip T="string" Size="Size.Small" Color="@GetStatusColor()">@StatusText</MudChip>
</td>
</tr>
<tr>
<td><strong>تاریخ درخواست:</strong></td>
<td>@Request.RequestedAt.ToDateTime().ToLocalTime().MiladiToJalaliWithTime()</td>
</tr>
@if (Request.ProcessedDate.HasValue)
{
<tr>
<td><strong>تاریخ پردازش:</strong></td>
<td>@Request.ProcessedDate.Value.MiladiToJalaliWithTime()</td>
</tr>
}
</tbody>
</MudSimpleTable>
</MudPaper>
<MudPaper Class="pa-3" Elevation="0" Outlined="true">
<MudText Typo="Typo.subtitle1" Color="Color.Primary" Class="mb-2">اطلاعات بانکی</MudText>
<MudSimpleTable Dense="true">
<tbody>
@if (!string.IsNullOrWhiteSpace(Request.IbanNumber))
{
<tr>
<td><strong>شماره شبا:</strong></td>
<td>@Request.IbanNumber</td>
</tr>
}
@if (!string.IsNullOrWhiteSpace(Request.BankReferenceId))
{
<tr>
<td><strong>شماره مرجع بانک:</strong></td>
<td>@Request.BankReferenceId</td>
</tr>
}
@if (!string.IsNullOrWhiteSpace(Request.BankTrackingCode))
{
<tr>
<td><strong>کد پیگیری بانکی:</strong></td>
<td>@Request.BankTrackingCode</td>
</tr>
}
@if (!string.IsNullOrWhiteSpace(Request.PaymentFailureReason))
{
<tr>
<td><strong>دلیل خطای پرداخت:</strong></td>
<td><MudText Color="Color.Error">@Request.PaymentFailureReason</MudText></td>
</tr>
}
@if (!string.IsNullOrWhiteSpace(Request.AdminNote))
{
<tr>
<td><strong>یادداشت مدیر:</strong></td>
<td>@Request.AdminNote</td>
</tr>
}
</tbody>
</MudSimpleTable>
</MudPaper>
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">بستن</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter] public WithdrawalRequestModel Request { get; set; } = null!;
[Parameter] public string StatusText { get; set; } = "";
[Parameter] public string MethodText { get; set; } = "";
private Color GetStatusColor()
{
return Request.Status switch
{
0 => Color.Warning,
1 => Color.Info,
2 => Color.Warning,
3 => Color.Success,
4 => Color.Error,
5 => Color.Default,
_ => Color.Default
};
}
private void Cancel() => MudDialog.Cancel();
}
@@ -5,7 +5,7 @@
@using MudBlazor
@using BackOffice.Pages.AutoComplete
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="mt-4">
<div>
<MudText Typo="Typo.h4" Class="mb-4">داشبورد کمیسیون</MudText>
@if (_isLoading)
@@ -90,7 +90,7 @@
@if (_availableWeeks?.CurrentWeek != null)
{
<MudSelectItem Value="@_availableWeeks.CurrentWeek.WeekDefinitionId">
<MudSelectItem T="long" Value="@_availableWeeks.CurrentWeek.WeekDefinitionId">
<MudText>
<MudIcon Icon="@Icons.Material.Filled.Today" Size="Size.Small" Class="ml-2" />
@_availableWeeks.CurrentWeek.DisplayText
@@ -104,7 +104,7 @@
<MudListSubheader>هفته‌های محاسبه شده</MudListSubheader>
@foreach (var week in _availableWeeks.CalculatedWeeks)
{
<MudSelectItem Value="@week.WeekDefinitionId">
<MudSelectItem T="long" Value="@week.WeekDefinitionId">
<MudText>
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" Class="ml-2" />
@week.DisplayText
@@ -125,7 +125,7 @@
<MudListSubheader>هفته‌های محاسبه نشده</MudListSubheader>
@foreach (var week in _availableWeeks.PendingWeeks)
{
<MudSelectItem Value="@week.WeekDefinitionId">
<MudSelectItem T="long" Value="@week.WeekDefinitionId">
<MudText>
<MudIcon Icon="@Icons.Material.Filled.AccessTime" Color="Color.Warning" Size="Size.Small" Class="ml-2" />
@week.DisplayText
@@ -140,7 +140,7 @@
<MudListSubheader>هفته‌های آینده</MudListSubheader>
@foreach (var week in _availableWeeks.FutureWeeks)
{
<MudSelectItem Value="@week.WeekDefinitionId">
<MudSelectItem T="long" Value="@week.WeekDefinitionId">
<MudText>
<MudIcon Icon="@Icons.Material.Filled.CalendarMonth" Color="Color.Info" Size="Size.Small" Class="ml-2" />
@week.DisplayText
@@ -204,7 +204,7 @@
<td>
@if (_poolData.CalculatedAt != null)
{
@_poolData.CalculatedAt.ToDateTime().ToLocalTime().ToString("yyyy/MM/dd HH:mm")
@_poolData.CalculatedAt.ToDateTime().ToLocalTime().MiladiToJalaliWithTime()
}
else
{
@@ -214,7 +214,7 @@
</tr>
<tr>
<td><strong>تاریخ ایجاد:</strong></td>
<td>@_poolData.Created.ToDateTime().ToLocalTime().ToString("yyyy/MM/dd HH:mm")</td>
<td>@_poolData.Created.ToDateTime().ToLocalTime().MiladiToJalaliWithTime()</td>
</tr>
</tbody>
</MudSimpleTable>
@@ -247,4 +247,4 @@
</MudStack>
</MudPaper>
}
</MudContainer>
</div>
@@ -1,7 +1,6 @@
using Microsoft.AspNetCore.Components;
using MudBlazor;
using System.Globalization;
using System.Text.Json;
using CMSMicroservice.Protobuf.Protos.Commission;
using BackOffice.Services;
@@ -111,7 +110,6 @@ public partial class Dashboard
};
_poolData = await CommissionContract.GetWeeklyCommissionPoolAsync(request);
Console.WriteLine(JsonSerializer.Serialize(_poolData));
Snackbar.Add($"اطلاعات Pool هفته {_currentWeekDisplayName} بارگذاری شد", Severity.Success);
}
catch (Exception ex)
@@ -6,14 +6,14 @@
@using MudBlazor
@using BackOffice.Pages.AutoComplete
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="mt-4">
<div>
<MudText Typo="Typo.h4" Class="mb-4"> پرداخت های کاربران</MudText>
<MudDataGrid T="UserCommissionPayoutModel"
ServerData="@(new Func<GridState<UserCommissionPayoutModel>, Task<GridData<UserCommissionPayoutModel>>>(ServerReload))"
Hover="true"
@ref="_gridData"
Height="75vh">
Height="calc(100vh - 240px)">
<ToolBarContent>
<MudText Typo="Typo.h6">لیست پرداخت ها</MudText>
<MudSpacer />
@@ -28,13 +28,13 @@
Margin="Margin.Dense"
Clearable="true"
Style="max-width: 150px;">
<MudSelectItem Value="@((int?)null)">همه</MudSelectItem>
<MudSelectItem Value="@((int?)0)">در انتظار</MudSelectItem>
<MudSelectItem Value="@((int?)1)">پرداخت شده</MudSelectItem>
<MudSelectItem Value="@((int?)2)">درخواست برداشت</MudSelectItem>
<MudSelectItem Value="@((int?)3)">برداشت شده</MudSelectItem>
<MudSelectItem Value="@((int?)4)">شکست خورده</MudSelectItem>
<MudSelectItem Value="@((int?)5)">لغو شده</MudSelectItem>
<MudSelectItem T="int?" Value="@((int?)null)">همه</MudSelectItem>
<MudSelectItem T="int?" Value="@((int?)0)">در انتظار</MudSelectItem>
<MudSelectItem T="int?" Value="@((int?)1)">پرداخت شده</MudSelectItem>
<MudSelectItem T="int?" Value="@((int?)2)">درخواست برداشت</MudSelectItem>
<MudSelectItem T="int?" Value="@((int?)3)">برداشت شده</MudSelectItem>
<MudSelectItem T="int?" Value="@((int?)4)">شکست خورده</MudSelectItem>
<MudSelectItem T="int?" Value="@((int?)5)">لغو شده</MudSelectItem>
</MudSelect>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
@@ -50,7 +50,9 @@
<PropertyColumn Property="x => x.UserId" Title="کاربر">
<CellTemplate>
<MudStack Spacing="0">
<MudText Typo="Typo.body2"><strong>ID:</strong> @context.Item.UserId</MudText>
<MudLink Href="@($"/network/user-info/{context.Item.UserId}")" Underline="Underline.Hover" Color="Color.Primary">
<strong>ID:</strong> @context.Item.UserId
</MudLink>
<MudText Typo="Typo.caption" Color="Color.Secondary">@context.Item.UserName</MudText>
</MudStack>
</CellTemplate>
@@ -131,11 +133,14 @@
</CellTemplate>
</TemplateColumn>
</Columns>
<NoRecordsContent>
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Text" Class="my-4">موردی یافت نشد.</MudAlert>
</NoRecordsContent>
<PagerContent>
<MudDataGridPager T="UserCommissionPayoutModel"
PageSizeOptions="@(new int[] { 10, 25, 50, 100 })"
InfoFormat="سطر {first_item} تا {last_item} از {all_items}"
RowsPerPageString="تعداد سطرهای صفحه" />
RowsPerPageString="تعداد در صفحه" />
</PagerContent>
</MudDataGrid>
</MudContainer>
</div>
@@ -3,8 +3,9 @@
@using MudBlazor
@using CMSMicroservice.Protobuf.Protos.Commission
@using Google.Protobuf.WellKnownTypes
@using BackOffice.Pages.AutoComplete
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="mt-4">
<div>
<MudText Typo="Typo.h4" GutterBottom="true">گزارش‌های هفتگی کمیسیون</MudText>
<MudText Typo="Typo.body1" Color="Color.Secondary" Class="mb-4">
مشاهده تاریخچه محاسبات کمیسیون هفتگی
@@ -14,29 +15,32 @@
<MudCardContent>
<MudGrid>
<MudItem xs="12" md="4">
<MudTextField @bind-Value="_filterFromWeek"
Label="از هفته"
Placeholder="2025-W01"
Variant="Variant.Outlined"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.DateRange" />
<WeekNumberPicker Label="از هفته"
@bind-SelectedWeekDefinitionId="_filterFromWeekId"
PastWeeksCount="24" />
</MudItem>
<MudItem xs="12" md="4">
<MudTextField @bind-Value="_filterToWeek"
Label="تا هفته"
Placeholder="2025-W48"
Variant="Variant.Outlined"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.DateRange" />
<WeekNumberPicker Label="تا هفته"
@bind-SelectedWeekDefinitionId="_filterToWeekId"
PastWeeksCount="24" />
</MudItem>
<MudItem xs="12" md="4" Class="d-flex align-center">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
OnClick="ApplyFilter"
StartIcon="@Icons.Material.Filled.Search"
FullWidth="true">
جستجو
</MudButton>
<MudStack Row="true" Spacing="2" Style="width:100%">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
OnClick="ApplyFilter"
StartIcon="@Icons.Material.Filled.Search"
FullWidth="true">
جستجو
</MudButton>
<MudButton Variant="Variant.Outlined"
Color="Color.Info"
OnClick="ExportToExcel"
StartIcon="@Icons.Material.Filled.FileDownload"
FullWidth="true">
خروجی Excel
</MudButton>
</MudStack>
</MudItem>
</MudGrid>
</MudCardContent>
@@ -85,7 +89,7 @@
<CellTemplate>
@if (context.Item.CalculatedAt != null)
{
<MudText>@context.Item.CalculatedAt.ToDateTime().ToString("yyyy/MM/dd HH:mm")</MudText>
<MudText>@context.Item.CalculatedAt.ToDateTime().MiladiToJalaliWithTime()</MudText>
}
else
{
@@ -151,15 +155,15 @@
</MudGrid>
</MudCardContent>
</MudCard>
</MudContainer>
</div>
@code {
[Inject] public CommissionContract.CommissionContractClient CommissionClient { get; set; }
private List<WeeklyPoolReportModel> _reports = new();
private bool _loading = false;
private string _filterFromWeek = "";
private string _filterToWeek = "";
private long? _filterFromWeekId;
private long? _filterToWeekId;
// Statistics
private long _totalPoolSum = 0;
@@ -183,14 +187,14 @@
PageSize = 100
};
if (!string.IsNullOrWhiteSpace(_filterFromWeek))
if (_filterFromWeekId.HasValue && _filterFromWeekId.Value > 0)
{
request.FromWeek = _filterFromWeek;
request.FromWeekDefinitionId = _filterFromWeekId.Value;
}
if (!string.IsNullOrWhiteSpace(_filterToWeek))
if (_filterToWeekId.HasValue && _filterToWeekId.Value > 0)
{
request.ToWeek = _filterToWeek;
request.ToWeekDefinitionId = _filterToWeekId.Value;
}
var response = await CommissionClient.GetAllWeeklyPoolsAsync(request);
@@ -0,0 +1,56 @@
using System.Text;
using DateTimeConverterCL;
using Microsoft.JSInterop;
using MudBlazor;
namespace BackOffice.Pages.Commission;
public partial class WeeklyReports
{
private async Task ExportToExcel()
{
if (_reports == null || !_reports.Any())
{
Snackbar.Add("داده‌ای برای خروجی وجود ندارد.", Severity.Info);
return;
}
try
{
var sb = new StringBuilder();
sb.AppendLine("هفته,مبلغ کل استخر,مجموع موجودی‌ها,ارزش هر موجودی,وضعیت,تاریخ محاسبه");
foreach (var r in _reports)
{
sb.AppendLine(string.Join(",",
EscapeCsv(r.WeekDisplayName),
r.TotalPoolAmount,
r.TotalBalances,
r.ValuePerBalance,
r.IsCalculated ? "محاسبه شده" : "در انتظار",
r.CalculatedAt != null ? r.CalculatedAt.ToDateTime().MiladiToJalaliWithTime() : "-"));
}
var bytes = Encoding.UTF8.GetPreamble()
.Concat(Encoding.UTF8.GetBytes(sb.ToString()))
.ToArray();
var base64 = Convert.ToBase64String(bytes);
var filename = $"weekly-reports-{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);
}
}
private static string EscapeCsv(string value)
{
if (string.IsNullOrEmpty(value)) return "";
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
return $"\"{value.Replace("\"", "\"\"")}\"";
return value;
}
}
@@ -8,7 +8,7 @@
@using System.Text
@using BackOffice.Pages.AutoComplete
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="mt-4">
<div>
<MudText Typo="Typo.h4" Class="mb-1">گزارش برداشت‌ها</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-4">
گزارش تجمیعی برداشت‌های کمیسیون بر اساس بازه تاریخ و نوع دوره
@@ -53,13 +53,13 @@
Label="وضعیت (اختیاری)"
Variant="Variant.Outlined"
Margin="Margin.Dense">
<MudSelectItem Value="@(null as int?)">همه وضعیت‌ها</MudSelectItem>
<MudSelectItem Value="0">در انتظار واریز به کیف پول</MudSelectItem>
<MudSelectItem Value="1">واریز شده به کیف پول طلایی</MudSelectItem>
<MudSelectItem Value="2">درخواست برداشت ثبت شده</MudSelectItem>
<MudSelectItem Value="3">برداشت انجام شده</MudSelectItem>
<MudSelectItem Value="4">خطا در پرداخت بانکی</MudSelectItem>
<MudSelectItem Value="5">لغو شده</MudSelectItem>
<MudSelectItem T="int?" Value="@(null as int?)">همه وضعیت‌ها</MudSelectItem>
<MudSelectItem T="int?" Value="0">در انتظار واریز به کیف پول</MudSelectItem>
<MudSelectItem T="int?" Value="1">واریز شده به کیف پول طلایی</MudSelectItem>
<MudSelectItem T="int?" Value="2">درخواست برداشت ثبت شده</MudSelectItem>
<MudSelectItem T="int?" Value="3">برداشت انجام شده</MudSelectItem>
<MudSelectItem T="int?" Value="4">خطا در پرداخت بانکی</MudSelectItem>
<MudSelectItem T="int?" Value="5">لغو شده</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12" md="4" Class="d-flex align-end">
@@ -175,12 +175,12 @@
<PropertyColumn Property="x => x.PeriodLabel" Title="دوره" />
<PropertyColumn Property="x => x.StartDate" Title="از تاریخ">
<CellTemplate>
@context.Item.StartDate.ToString("yyyy/MM/dd")
@context.Item.StartDate.MiladiToJalali()
</CellTemplate>
</PropertyColumn>
<PropertyColumn Property="x => x.EndDate" Title="تا تاریخ">
<CellTemplate>
@context.Item.EndDate.ToString("yyyy/MM/dd")
@context.Item.EndDate.MiladiToJalali()
</CellTemplate>
</PropertyColumn>
<PropertyColumn Property="x => x.TotalRequests" Title="تعداد درخواست‌ها" />
@@ -203,11 +203,14 @@
</CellTemplate>
</PropertyColumn>
</Columns>
<NoRecordsContent>
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Text" Class="my-4">موردی یافت نشد.</MudAlert>
</NoRecordsContent>
</MudDataGrid>
</MudCardContent>
</MudCard>
}
</MudContainer>
</div>
@code {
[Inject] public CommissionContract.CommissionContractClient CommissionClient { get; set; }
@@ -394,8 +397,8 @@
{
sb.AppendLine(string.Join(",",
EscapeCsv(r.PeriodLabel),
r.StartDate.ToString("yyyy-MM-dd"),
r.EndDate.ToString("yyyy-MM-dd"),
r.StartDate.MiladiToJalali(),
r.EndDate.MiladiToJalali(),
r.TotalRequests,
r.CompletedCount,
r.FailedCount,
@@ -423,7 +426,7 @@
var sb = new StringBuilder();
sb.AppendLine("گزارش برداشت‌ها");
sb.AppendLine($"دوره: {_periodType} از تاریخ: {_startDate:yyyy/MM/dd} تا تاریخ: {_endDate:yyyy/MM/dd}");
sb.AppendLine($"دوره: {_periodType} از تاریخ: {_startDate?.MiladiToJalali() ?? "-"} تا تاریخ: {_endDate?.MiladiToJalali() ?? "-"}");
sb.AppendLine();
sb.AppendLine("خلاصه:");
@@ -441,7 +444,7 @@
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}");
$"{r.PeriodLabel} | {r.StartDate.MiladiToJalali()} | {r.EndDate.MiladiToJalali()} | {r.TotalRequests} | {r.CompletedCount} | {r.FailedCount} | {r.PendingCount} | {r.TotalAmount:N0} | {r.PaidAmount:N0} | {r.PendingAmount:N0}");
}
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
@@ -4,14 +4,14 @@
@using CMSMicroservice.Protobuf.Protos.Commission
@using BackOffice.Pages.AutoComplete
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="mt-4">
<div>
<MudText Typo="Typo.h4" Class="mb-4">درخواست‌های برداشت</MudText>
<MudDataGrid T="WithdrawalRequestModel"
ServerData="@(new Func<GridState<WithdrawalRequestModel>, Task<GridData<WithdrawalRequestModel>>>(ServerReload))"
Hover="true"
@ref="_gridData"
Height="75vh">
Height="calc(100vh - 240px)">
<ToolBarContent>
<MudText Typo="Typo.h6">درخواست‌های برداشت</MudText>
<MudSpacer />
@@ -30,13 +30,13 @@
Variant="Variant.Outlined"
Margin="Margin.Dense"
Style="max-width: 150px;">
<MudSelectItem Value="@((int?)null)">همه</MudSelectItem>
<MudSelectItem Value="@((int?)0)">در انتظار واریز</MudSelectItem>
<MudSelectItem Value="@((int?)1)">واریز شده به کیف پول</MudSelectItem>
<MudSelectItem Value="@((int?)2)">درخواست برداشت</MudSelectItem>
<MudSelectItem Value="@((int?)3)">برداشت شده</MudSelectItem>
<MudSelectItem Value="@((int?)4)">خطای پرداخت بانکی</MudSelectItem>
<MudSelectItem Value="@((int?)5)">لغو شده</MudSelectItem>
<MudSelectItem T="int?" Value="@((int?)null)">همه</MudSelectItem>
<MudSelectItem T="int?" Value="@((int?)0)">در انتظار واریز</MudSelectItem>
<MudSelectItem T="int?" Value="@((int?)1)">واریز شده به کیف پول</MudSelectItem>
<MudSelectItem T="int?" Value="@((int?)2)">درخواست برداشت</MudSelectItem>
<MudSelectItem T="int?" Value="@((int?)3)">برداشت شده</MudSelectItem>
<MudSelectItem T="int?" Value="@((int?)4)">خطای پرداخت بانکی</MudSelectItem>
<MudSelectItem T="int?" Value="@((int?)5)">لغو شده</MudSelectItem>
</MudSelect>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
@@ -44,6 +44,12 @@
StartIcon="@Icons.Material.Filled.Search">
جستجو
</MudButton>
<MudButton Variant="Variant.Outlined"
Color="Color.Info"
OnClick="ExportToExcel"
StartIcon="@Icons.Material.Filled.FileDownload">
خروجی Excel
</MudButton>
</MudStack>
</ToolBarContent>
<Columns>
@@ -52,7 +58,9 @@
<PropertyColumn Property="x => x.UserId" Title="کاربر">
<CellTemplate>
<MudStack Spacing="0">
<MudText Typo="Typo.body2"><strong>@context.Item.UserName</strong></MudText>
<MudLink Href="@($"/network/user-info/{context.Item.UserId}")" Underline="Underline.Hover" Color="Color.Primary">
<strong>@context.Item.UserName</strong>
</MudLink>
<MudText Typo="Typo.caption" Color="Color.Secondary">ID: @context.Item.UserId</MudText>
</MudStack>
</CellTemplate>
@@ -101,7 +109,7 @@
<PropertyColumn Property="x => x.RequestedAt" Title="تاریخ درخواست">
<CellTemplate>
@context.Item.RequestedAt.ToDateTime().ToLocalTime().ToString("yyyy/MM/dd HH:mm")
@context.Item.RequestedAt.ToDateTime().ToLocalTime().MiladiToJalaliWithTime()
</CellTemplate>
</PropertyColumn>
@@ -142,11 +150,14 @@
</CellTemplate>
</TemplateColumn>
</Columns>
<NoRecordsContent>
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Text" Class="my-4">موردی یافت نشد.</MudAlert>
</NoRecordsContent>
<PagerContent>
<MudDataGridPager T="WithdrawalRequestModel"
PageSizeOptions="@(new int[] { 10, 25, 50, 100 })"
PageSizeOptions="@(new int[] { 20, 50, 100 })"
InfoFormat="سطر {first_item} تا {last_item} از {all_items}"
RowsPerPageString="تعداد سطرهای صفحه" />
RowsPerPageString="تعداد در صفحه" />
</PagerContent>
</MudDataGrid>
</MudContainer>
</div>
@@ -2,6 +2,10 @@ using CMSMicroservice.Protobuf.Protos.Commission;
using Microsoft.AspNetCore.Components;
using MudBlazor;
using Google.Protobuf.WellKnownTypes;
using BackOffice.Pages.Commission.Components;
using Microsoft.JSInterop;
using System.Text;
using DateTimeConverterCL;
using GrpcWithdrawalRequestModel = CMSMicroservice.Protobuf.Protos.Commission.WithdrawalRequestModel;
using GrpcGetWithdrawalRequestsRequest = CMSMicroservice.Protobuf.Protos.Commission.GetWithdrawalRequestsRequest;
@@ -10,6 +14,7 @@ namespace BackOffice.Pages.Commission;
public partial class WithdrawalRequests
{
[Inject] public CommissionContract.CommissionContractClient CommissionContract { get; set; }
[Inject] public IJSRuntime JsRuntime { get; set; }
private MudDataGrid<WithdrawalRequestModel> _gridData;
private int? _filterStatus;
@@ -142,10 +147,16 @@ public partial class WithdrawalRequests
};
}
private void ViewDetails(WithdrawalRequestModel request)
private async Task ViewDetails(WithdrawalRequestModel request)
{
Snackbar.Add($"جزئیات درخواست {request.Id}", Severity.Info);
// TODO: Open details dialog
var parameters = new DialogParameters<WithdrawalDetailsDialog>
{
{ x => x.Request, request },
{ x => x.StatusText, GetStatusText(request.Status) },
{ x => x.MethodText, GetMethodText(request.Method) }
};
var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true };
await DialogService.ShowAsync<WithdrawalDetailsDialog>("جزئیات درخواست برداشت", parameters, options);
}
private async Task ApproveRequest(WithdrawalRequestModel request)
@@ -177,19 +188,23 @@ public partial class WithdrawalRequests
private async Task RejectRequest(WithdrawalRequestModel request)
{
var confirmed = await DialogService.ShowMessageBox(
"رد درخواست",
$"آیا از رد درخواست برداشت {request.Amount:N0} ریال برای {request.UserName} مطمئن هستید؟",
yesText: "رد", cancelText: "لغو");
var parameters = new DialogParameters<RejectReasonDialog>
{
{ x => x.UserName, request.UserName },
{ x => x.Amount, request.Amount }
};
var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true };
var dialog = await DialogService.ShowAsync<RejectReasonDialog>("رد درخواست برداشت", parameters, options);
var result = await dialog.Result;
if (confirmed == true)
if (result is { Canceled: false, Data: string reason })
{
try
{
var rejectRequest = new RejectWithdrawalRequest
{
PayoutId = request.Id,
Reason = "رد توسط ادمین پنل مدیریت"
Reason = reason
};
await CommissionContract.RejectWithdrawalAsync(rejectRequest);
@@ -230,4 +245,66 @@ public partial class WithdrawalRequests
}
}
}
private async Task ExportToExcel()
{
try
{
var request = new GrpcGetWithdrawalRequestsRequest
{
PageIndex = 1,
PageSize = 1000
};
if (_filterStatus.HasValue) 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);
if (result?.Models == null || !result.Models.Any())
{
Snackbar.Add("داده‌ای برای خروجی وجود ندارد.", Severity.Info);
return;
}
var sb = new StringBuilder();
sb.AppendLine("شناسه,کاربر,نام کاربر,مبلغ,وضعیت,روش,شماره شبا,تاریخ درخواست,تاریخ پردازش,شماره مرجع بانکی");
foreach (var r in result.Models)
{
var mapped = MapToViewModel(r);
sb.AppendLine(string.Join(",",
mapped.Id,
mapped.UserId,
EscapeCsv(mapped.UserName),
mapped.Amount,
EscapeCsv(GetStatusText(mapped.Status)),
EscapeCsv(GetMethodText(mapped.Method)),
EscapeCsv(mapped.IbanNumber ?? ""),
mapped.RequestedAt.ToDateTime().ToLocalTime().MiladiToJalaliWithTime(),
mapped.ProcessedDate?.MiladiToJalaliWithTime() ?? "-",
EscapeCsv(mapped.BankReferenceId ?? "")));
}
var bytes = Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes(sb.ToString())).ToArray();
var base64 = Convert.ToBase64String(bytes);
var filename = $"withdrawal-requests-{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);
}
}
private static string EscapeCsv(string value)
{
if (string.IsNullOrEmpty(value)) return "";
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
return $"\"{value.Replace("\"", "\"\"")}\"";
return value;
}
}
@@ -0,0 +1,80 @@
@using BackOffice.Services.Content
@using Microsoft.AspNetCore.Components.Forms
@using BackOffice.Common.BaseComponents
<MudDialog>
<DialogContent>
<MudForm @ref="_form" @bind-IsValid="@_isValid">
<MudStack Spacing="2">
<MudTextField @bind-Value="Model.PageKey"
Label="کلید صفحه (مثال: about, contact)"
Required="true"
Variant="Variant.Outlined"
HelperText="این مقدار باید یکتا باشد و فقط حروف انگلیسی و خط فاصله مجاز است" />
<MudTextField @bind-Value="Model.Title"
Label="عنوان صفحه"
Required="true"
Variant="Variant.Outlined" />
<MudTextField @bind-Value="Model.MetaDescription"
Label="توضیحات متا (SEO)"
Variant="Variant.Outlined"
Lines="2" />
<MudTextField @bind-Value="Model.HeroTitle"
Label="عنوان هیرو"
Variant="Variant.Outlined" />
<MudTextField @bind-Value="Model.HeroSubtitle"
Label="زیرعنوان هیرو"
Variant="Variant.Outlined" />
<MudText Typo="Typo.subtitle2">تصویر هیرو</MudText>
<MudStack Justify="Justify.Center" AlignItems="AlignItems.Center">
@if (!string.IsNullOrWhiteSpace(_imagePreview))
{
<Image Src="@_imagePreview" Width="280" Height="160" ObjectPosition="ObjectPosition.Center" ObjectFit="ObjectFit.Cover" />
}
else
{
<MudPaper Class="d-flex align-center justify-center" Style="width:280px;height:160px;">
<MudText Typo="Typo.caption">تصویری انتخاب نشده است</MudText>
</MudPaper>
}
<MudFileUpload T="IBrowserFile" Accept="image/*" FilesChanged="OnImageSelected">
<ActivatorContent>
<MudButton HtmlTag="label"
Variant="Variant.Filled"
Color="Color.Primary"
ButtonType="ButtonType.Button"
StartIcon="@Icons.Material.Filled.Image">
انتخاب تصویر هیرو
</MudButton>
</ActivatorContent>
<SelectedTemplate>
@if (context != null)
{
<MudText Class="mt-1" Typo="Typo.caption">@context.Name</MudText>
}
</SelectedTemplate>
</MudFileUpload>
</MudStack>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<MudSwitch T="bool" @bind-Value="Model.IsActive" Color="Color.Primary" />
<MudText Typo="Typo.body2">فعال</MudText>
</MudStack>
</MudStack>
</MudForm>
</DialogContent>
<DialogActions>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SubmitAsync" Disabled="@(!_isValid || _loading)">
ایجاد صفحه
</MudButton>
<MudButton Color="Color.Default" Variant="Variant.Text" OnClick="Cancel">
انصراف
</MudButton>
</DialogActions>
</MudDialog>
@@ -0,0 +1,74 @@
using BackOffice.Services.Content;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Forms;
using MudBlazor;
namespace BackOffice.Pages.Content.Components;
public partial class SitePageCreateDialog
{
[CascadingParameter] IMudDialogInstance DialogInstance { get; set; } = default!;
[Inject] public ISitePageService SitePageService { get; set; } = default!;
[Parameter] public SitePageCreateDto Model { get; set; } = new();
private MudForm? _form;
private bool _isValid = true;
private bool _loading;
private IBrowserFile? _imageFile;
private byte[]? _imageBuffer;
private string? _imagePreview;
private readonly long _maxAllowedSize = (1024 * 1024) * 5;
private async Task OnImageSelected(IBrowserFile? file)
{
_imageFile = file;
if (file != null)
{
using var ms = new MemoryStream();
await file.OpenReadStream(_maxAllowedSize).CopyToAsync(ms);
_imageBuffer = ms.ToArray();
_imagePreview = $"data:{file.ContentType};base64," + Convert.ToBase64String(_imageBuffer);
StateHasChanged();
}
}
private async Task SubmitAsync()
{
if (_form != null)
{
await _form.Validate();
if (!_isValid) return;
}
_loading = true;
// Handle image upload
if (_imageFile != null && _imageBuffer != null)
{
Model.ImageFile = _imageBuffer;
Model.ImageMime = _imageFile.ContentType;
Model.ImageFileName = Path.GetFileNameWithoutExtension(_imageFile.Name);
}
try
{
await SitePageService.CreateAsync(Model);
DialogInstance.Close(DialogResult.Ok(true));
}
catch (Exception ex)
{
Snackbar.Add($"خطا: {ex.Message}", Severity.Error);
}
finally
{
_loading = false;
}
}
private void Cancel()
{
DialogInstance.Cancel();
}
}
@@ -0,0 +1,78 @@
@using BackOffice.Services.Content
@using Microsoft.AspNetCore.Components.Forms
@using BackOffice.Common.BaseComponents
<MudDialog>
<DialogContent>
<MudForm @ref="_form" @bind-IsValid="@_isValid">
<MudStack Spacing="2">
<MudTextField @bind-Value="Model.Title"
Label="عنوان صفحه"
Required="true"
Variant="Variant.Outlined" />
<MudTextField @bind-Value="Model.MetaDescription"
Label="توضیحات متا (SEO)"
Variant="Variant.Outlined"
Lines="2" />
<MudTextField @bind-Value="Model.HeroTitle"
Label="عنوان هیرو"
Variant="Variant.Outlined" />
<MudTextField @bind-Value="Model.HeroSubtitle"
Label="زیرعنوان هیرو"
Variant="Variant.Outlined" />
<MudText Typo="Typo.subtitle2">تصویر هیرو</MudText>
<MudStack Justify="Justify.Center" AlignItems="AlignItems.Center">
@if (!string.IsNullOrWhiteSpace(_imagePreview))
{
<Image Src="@_imagePreview" Width="280" Height="160" ObjectPosition="ObjectPosition.Center" ObjectFit="ObjectFit.Cover" />
}
else if (!string.IsNullOrEmpty(Model.HeroImagePath))
{
<Image Src="@Model.HeroImagePath" Width="280" Height="160" ObjectPosition="ObjectPosition.Center" ObjectFit="ObjectFit.Cover" />
}
else
{
<MudPaper Class="d-flex align-center justify-center" Style="width:280px;height:160px;">
<MudText Typo="Typo.caption">تصویری انتخاب نشده است</MudText>
</MudPaper>
}
<MudFileUpload T="IBrowserFile" Accept="image/*" FilesChanged="OnImageSelected">
<ActivatorContent>
<MudButton HtmlTag="label"
Variant="Variant.Filled"
Color="Color.Primary"
ButtonType="ButtonType.Button"
StartIcon="@Icons.Material.Filled.Image">
انتخاب تصویر هیرو
</MudButton>
</ActivatorContent>
<SelectedTemplate>
@if (context != null)
{
<MudText Class="mt-1" Typo="Typo.caption">@context.Name</MudText>
}
</SelectedTemplate>
</MudFileUpload>
</MudStack>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<MudSwitch T="bool" @bind-Value="Model.IsActive" Color="Color.Primary" />
<MudText Typo="Typo.body2">فعال</MudText>
</MudStack>
</MudStack>
</MudForm>
</DialogContent>
<DialogActions>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SubmitAsync" Disabled="@(!_isValid || _loading)">
ذخیره تغییرات
</MudButton>
<MudButton Color="Color.Default" Variant="Variant.Text" OnClick="Cancel">
انصراف
</MudButton>
</DialogActions>
</MudDialog>
@@ -0,0 +1,80 @@
using BackOffice.Services.Content;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Forms;
using MudBlazor;
namespace BackOffice.Pages.Content.Components;
public partial class SitePageEditDialog
{
[CascadingParameter] IMudDialogInstance DialogInstance { get; set; } = default!;
[Inject] public ISitePageService SitePageService { get; set; } = default!;
[Parameter] public SitePageEditDto Model { get; set; } = new();
[Parameter] public long PageId { get; set; }
private MudForm? _form;
private bool _isValid = true;
private bool _loading;
private IBrowserFile? _imageFile;
private byte[]? _imageBuffer;
private string? _imagePreview;
private readonly long _maxAllowedSize = (1024 * 1024) * 5;
private async Task OnImageSelected(IBrowserFile? file)
{
_imageFile = file;
if (file != null)
{
using var ms = new MemoryStream();
await file.OpenReadStream(_maxAllowedSize).CopyToAsync(ms);
_imageBuffer = ms.ToArray();
_imagePreview = $"data:{file.ContentType};base64," + Convert.ToBase64String(_imageBuffer);
StateHasChanged();
}
}
private async Task HandleValidSubmit()
{
await SubmitAsync();
}
private async Task SubmitAsync()
{
if (_form != null)
{
await _form.Validate();
if (!_isValid) return;
}
_loading = true;
// Handle image upload
if (_imageFile != null && _imageBuffer != null)
{
Model.ImageFile = _imageBuffer;
Model.ImageMime = _imageFile.ContentType;
Model.ImageFileName = Path.GetFileNameWithoutExtension(_imageFile.Name);
}
try
{
await SitePageService.UpdateAsync(PageId, Model);
DialogInstance.Close(DialogResult.Ok(true));
}
catch (Exception ex)
{
Snackbar.Add($"خطا: {ex.Message}", Severity.Error);
}
finally
{
_loading = false;
}
}
private void Cancel()
{
DialogInstance.Cancel();
}
}
@@ -0,0 +1,95 @@
@using BackOffice.Services.Content
@using Microsoft.AspNetCore.Components.Forms
@using Tizzani.MudBlazor.HtmlEditor
@using BackOffice.Common.BaseComponents
<MudDialog>
<DialogContent>
<MudForm @ref="_form" @bind-IsValid="@_isValid">
<MudStack Spacing="2">
<MudTextField @bind-Value="Model.SectionKey"
Label="کلید بخش (اختیاری)"
Variant="Variant.Outlined"
HelperText="در صورت خالی بودن، به صورت خودکار تولید می‌شود" />
<MudTextField @bind-Value="Model.Title"
Label="عنوان"
Required="true"
Variant="Variant.Outlined" />
<MudTextField @bind-Value="Model.Subtitle"
Label="زیرعنوان"
Variant="Variant.Outlined" />
<MudText Typo="Typo.subtitle2">محتوای HTML</MudText>
<MudHtmlEditor @bind-Html="Model.HtmlContent" MinHeight="300px">
<MudHtmlToolbarOptions InsertImage="false" />
</MudHtmlEditor>
<MudTextField @bind-Value="Model.IconName"
Label="نام آیکون"
Variant="Variant.Outlined" />
<MudText Typo="Typo.subtitle2">تصویر بخش</MudText>
<MudStack Justify="Justify.Center" AlignItems="AlignItems.Center">
@if (!string.IsNullOrWhiteSpace(_imagePreview))
{
<Image Src="@_imagePreview" Width="220" Height="140" ObjectPosition="ObjectPosition.Center" ObjectFit="ObjectFit.Cover" />
}
else if (!string.IsNullOrEmpty(Model.ImagePath))
{
<Image Src="@Model.ImagePath" Width="220" Height="140" ObjectPosition="ObjectPosition.Center" ObjectFit="ObjectFit.Cover" />
}
else
{
<MudPaper Class="d-flex align-center justify-center" Style="width:220px;height:140px;">
<MudText Typo="Typo.caption">تصویری انتخاب نشده است</MudText>
</MudPaper>
}
<MudFileUpload T="IBrowserFile" Accept="image/*" FilesChanged="OnImageSelected">
<ActivatorContent>
<MudButton HtmlTag="label"
Variant="Variant.Filled"
Color="Color.Primary"
ButtonType="ButtonType.Button"
StartIcon="@Icons.Material.Filled.Image">
انتخاب تصویر بخش
</MudButton>
</ActivatorContent>
<SelectedTemplate>
@if (context != null)
{
<MudText Class="mt-1" Typo="Typo.caption">@context.Name</MudText>
}
</SelectedTemplate>
</MudFileUpload>
</MudStack>
<MudNumericField T="int"
@bind-Value="Model.SortOrder"
Label="ترتیب نمایش"
Variant="Variant.Outlined" />
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<MudSwitch T="bool" @bind-Value="Model.IsActive" Color="Color.Primary" />
<MudText Typo="Typo.body2">فعال</MudText>
</MudStack>
<MudTextField @bind-Value="Model.ExtraData"
Label="داده‌های اضافی (JSON)"
Variant="Variant.Outlined"
Lines="3"
HelperText="اطلاعات اضافی به صورت JSON" />
</MudStack>
</MudForm>
</DialogContent>
<DialogActions>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SubmitAsync" Disabled="@(!_isValid || _loading)">
@(_isEditMode ? "ذخیره تغییرات" : "ایجاد بخش")
</MudButton>
<MudButton Color="Color.Default" Variant="Variant.Text" OnClick="Cancel">
انصراف
</MudButton>
</DialogActions>
</MudDialog>
@@ -0,0 +1,90 @@
using BackOffice.Services.Content;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Forms;
using MudBlazor;
namespace BackOffice.Pages.Content.Components;
public partial class SitePageSectionEditDialog
{
[CascadingParameter] IMudDialogInstance DialogInstance { get; set; } = default!;
[Inject] public ISitePageService SitePageService { get; set; } = default!;
[Parameter] public SitePageSectionEditDto Model { get; set; } = new();
[Parameter] public long? SectionId { get; set; }
[Parameter] public bool IsEditMode { get; set; }
private bool _isEditMode => IsEditMode && SectionId.HasValue;
private MudForm? _form;
private bool _isValid = true;
private bool _loading;
private IBrowserFile? _imageFile;
private byte[]? _imageBuffer;
private string? _imagePreview;
private readonly long _maxAllowedSize = (1024 * 1024) * 5;
private async Task OnImageSelected(IBrowserFile? file)
{
_imageFile = file;
if (file != null)
{
using var ms = new MemoryStream();
await file.OpenReadStream(_maxAllowedSize).CopyToAsync(ms);
_imageBuffer = ms.ToArray();
_imagePreview = $"data:{file.ContentType};base64," + Convert.ToBase64String(_imageBuffer);
StateHasChanged();
}
}
private async Task HandleValidSubmit()
{
await SubmitAsync();
}
private async Task SubmitAsync()
{
if (_form != null)
{
await _form.Validate();
if (!_isValid) return;
}
_loading = true;
// Handle image upload
if (_imageFile != null && _imageBuffer != null)
{
Model.ImageFile = _imageBuffer;
Model.ImageMime = _imageFile.ContentType;
Model.ImageFileName = Path.GetFileNameWithoutExtension(_imageFile.Name);
}
try
{
if (_isEditMode && SectionId.HasValue)
{
await SitePageService.UpdateSectionAsync(SectionId.Value, Model);
}
else
{
await SitePageService.CreateSectionAsync(Model);
}
DialogInstance.Close(DialogResult.Ok(true));
}
catch (Exception ex)
{
Snackbar.Add($"خطا: {ex.Message}", Severity.Error);
}
finally
{
_loading = false;
}
}
private void Cancel()
{
DialogInstance.Cancel();
}
}
@@ -0,0 +1,64 @@
@using BackOffice.Services.Content
<MudDialog>
<DialogContent>
<MudStack Spacing="2">
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.subtitle1">بخش‌های صفحه</MudText>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
Size="Size.Small"
StartIcon="@Icons.Material.Filled.Add"
OnClick="AddSection">
افزودن بخش
</MudButton>
</MudStack>
<MudDataGrid T="SitePageSectionItemDto"
Items="Sections"
Hover="true"
Dense="true"
Elevation="0">
<Columns>
<PropertyColumn Property="x => x.SectionKey" Title="کلید بخش" />
<PropertyColumn Property="x => x.Title" Title="عنوان" />
<PropertyColumn Property="x => x.SortOrder" Title="ترتیب" />
<PropertyColumn Property="x => x.IsActive" Title="وضعیت">
<CellTemplate>
<MudChip Color="@(context.Item.IsActive ? Color.Success : Color.Error)"
Size="Size.Small">
@(context.Item.IsActive ? "فعال" : "غیرفعال")
</MudChip>
</CellTemplate>
</PropertyColumn>
<TemplateColumn Title="عملیات" Sortable="false">
<CellTemplate>
<MudStack Row="true" Spacing="1">
<MudTooltip Text="ویرایش">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Size="Size.Small"
Color="Color.Primary"
OnClick="@(() => EditSection(context.Item))" />
</MudTooltip>
<MudTooltip Text="حذف">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Size="Size.Small"
Color="Color.Error"
OnClick="@(() => DeleteSection(context.Item))" />
</MudTooltip>
</MudStack>
</CellTemplate>
</TemplateColumn>
</Columns>
<NoRecordsContent>
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Text" Class="my-4">موردی یافت نشد.</MudAlert>
</NoRecordsContent>
</MudDataGrid>
</MudStack>
</DialogContent>
<DialogActions>
<MudButton Color="Color.Default" Variant="Variant.Text" OnClick="Close">
بستن
</MudButton>
</DialogActions>
</MudDialog>
@@ -0,0 +1,114 @@
using BackOffice.Services.Content;
using Microsoft.AspNetCore.Components;
using MudBlazor;
namespace BackOffice.Pages.Content.Components;
public partial class SitePageSectionsDialog
{
[CascadingParameter] IMudDialogInstance DialogInstance { get; set; } = default!;
[Inject] public ISitePageService SitePageService { get; set; } = default!;
[Parameter] public long PageId { get; set; }
[Parameter] public string PageTitle { get; set; } = string.Empty;
[Parameter] public List<SitePageSectionItemDto> Sections { get; set; } = new();
private bool _changed;
private async Task AddSection()
{
var dto = new SitePageSectionEditDto
{
SitePageId = PageId,
SortOrder = Sections.Count + 1
};
var parameters = new DialogParameters<SitePageSectionEditDialog>
{
{ nameof(SitePageSectionEditDialog.Model), dto },
{ nameof(SitePageSectionEditDialog.IsEditMode), false }
};
var dialog = await DialogService.ShowAsync<SitePageSectionEditDialog>("افزودن بخش جدید", parameters,
new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Medium, FullWidth = true });
var result = await dialog.Result;
if (!result.Canceled)
{
Snackbar.Add("بخش با موفقیت ایجاد شد", Severity.Success);
_changed = true;
await RefreshSections();
}
}
private async Task EditSection(SitePageSectionItemDto item)
{
var dto = new SitePageSectionEditDto
{
SitePageId = PageId,
SectionKey = item.SectionKey,
Title = item.Title,
Subtitle = item.Subtitle,
HtmlContent = item.HtmlContent,
IconName = item.IconName,
ImagePath = item.ImagePath,
ImageThumbnailPath = item.ImageThumbnailPath,
SortOrder = item.SortOrder,
IsActive = item.IsActive,
ExtraData = item.ExtraData
};
var parameters = new DialogParameters<SitePageSectionEditDialog>
{
{ nameof(SitePageSectionEditDialog.Model), dto },
{ nameof(SitePageSectionEditDialog.SectionId), item.Id },
{ nameof(SitePageSectionEditDialog.IsEditMode), true }
};
var dialog = await DialogService.ShowAsync<SitePageSectionEditDialog>("ویرایش بخش", parameters,
new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Medium, FullWidth = true });
var result = await dialog.Result;
if (!result.Canceled)
{
Snackbar.Add("بخش با موفقیت به‌روزرسانی شد", Severity.Success);
_changed = true;
await RefreshSections();
}
}
private async Task DeleteSection(SitePageSectionItemDto item)
{
var confirmed = await DialogService.ShowMessageBox(
"حذف بخش",
$"آیا از حذف بخش «{item.Title}» مطمئن هستید؟",
yesText: "حذف",
cancelText: "لغو");
if (confirmed == true)
{
await SitePageService.DeleteSectionAsync(item.Id);
Snackbar.Add("بخش حذف شد", Severity.Success);
_changed = true;
await RefreshSections();
}
}
private async Task RefreshSections()
{
var details = await SitePageService.GetByIdAsync(PageId);
if (details != null)
{
Sections = details.Sections;
StateHasChanged();
}
}
private void Close()
{
if (_changed)
DialogInstance.Close(DialogResult.Ok(true));
else
DialogInstance.Cancel();
}
}
@@ -0,0 +1,74 @@
@page "/content/pages"
@attribute [Authorize(Roles = "Administrator")]
@using BackOffice.Services.Content
@inject ISitePageService SitePageService
<div>
<MudStack Spacing="2">
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.h5">مدیریت صفحات سایت</MudText>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="CreatePage">
افزودن صفحه جدید
</MudButton>
</MudStack>
<MudDataGrid T="SitePageSummaryDto"
Items="_pages"
Hover="true"
Elevation="1"
Dense="true"
Loading="_loading">
<Columns>
<PropertyColumn Property="x => x.Id" Title="شناسه" />
<PropertyColumn Property="x => x.PageKey" Title="کلید صفحه" />
<PropertyColumn Property="x => x.Title" Title="عنوان" />
<PropertyColumn Property="x => x.IsActive" Title="وضعیت">
<CellTemplate>
<MudChip Color="@(context.Item.IsActive ? Color.Success : Color.Error)"
Size="Size.Small">
@(context.Item.IsActive ? "فعال" : "غیرفعال")
</MudChip>
</CellTemplate>
</PropertyColumn>
<PropertyColumn Property="x => x.SectionCount" Title="تعداد بخش" />
<TemplateColumn Title="آخرین ویرایش">
<CellTemplate>
@context.Item.LastModified.MiladiToJalaliWithTime()
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="عملیات" Sortable="false">
<CellTemplate>
<MudStack Row="true" Spacing="1">
<MudTooltip Text="ویرایش">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Size="Size.Small"
Color="Color.Primary"
OnClick="@(() => EditPage(context.Item))" />
</MudTooltip>
<MudTooltip Text="مدیریت بخش‌ها">
<MudIconButton Icon="@Icons.Material.Filled.ViewList"
Size="Size.Small"
Color="Color.Info"
OnClick="@(() => ManageSections(context.Item))" />
</MudTooltip>
<MudTooltip Text="حذف">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Size="Size.Small"
Color="Color.Error"
OnClick="@(() => DeletePage(context.Item))" />
</MudTooltip>
</MudStack>
</CellTemplate>
</TemplateColumn>
</Columns>
<NoRecordsContent>
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Text" Class="my-4">موردی یافت نشد.</MudAlert>
</NoRecordsContent>
</MudDataGrid>
</MudStack>
</div>
@@ -0,0 +1,128 @@
using BackOffice.Services.Content;
using BackOffice.Pages.Content.Components;
using Microsoft.AspNetCore.Components;
using MudBlazor;
namespace BackOffice.Pages.Content;
public partial class SitePageManagementPage
{
private List<SitePageSummaryDto> _pages = new();
private bool _loading = true;
protected override async Task OnInitializedAsync()
{
await LoadPagesAsync();
}
private async Task LoadPagesAsync()
{
_loading = true;
_pages = await SitePageService.GetAllAsync();
_loading = false;
}
private async Task CreatePage()
{
var model = new SitePageCreateDto();
var parameters = new DialogParameters<SitePageCreateDialog>
{
{ nameof(SitePageCreateDialog.Model), model }
};
var dialog = await DialogService.ShowAsync<SitePageCreateDialog>("افزودن صفحه جدید", parameters,
new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Medium, FullWidth = true });
var result = await dialog.Result;
if (!result.Canceled)
{
Snackbar.Add("صفحه جدید با موفقیت ایجاد شد", Severity.Success);
await LoadPagesAsync();
}
}
private async Task EditPage(SitePageSummaryDto item)
{
var details = await SitePageService.GetByIdAsync(item.Id);
if (details == null)
{
Snackbar.Add("خطا در دریافت اطلاعات صفحه", Severity.Error);
return;
}
var dto = new SitePageEditDto
{
Title = details.Title,
MetaDescription = details.MetaDescription,
HeroTitle = details.HeroTitle,
HeroSubtitle = details.HeroSubtitle,
HeroImagePath = details.HeroImagePath,
IsActive = details.IsActive
};
var parameters = new DialogParameters<SitePageEditDialog>
{
{ nameof(SitePageEditDialog.Model), dto },
{ nameof(SitePageEditDialog.PageId), item.Id }
};
var dialog = await DialogService.ShowAsync<SitePageEditDialog>("ویرایش صفحه", parameters,
new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Medium, FullWidth = true });
var result = await dialog.Result;
if (!result.Canceled)
{
Snackbar.Add("صفحه با موفقیت به‌روزرسانی شد", Severity.Success);
await LoadPagesAsync();
}
}
private async Task ManageSections(SitePageSummaryDto item)
{
var details = await SitePageService.GetByIdAsync(item.Id);
if (details == null)
{
Snackbar.Add("خطا در دریافت اطلاعات صفحه", Severity.Error);
return;
}
var parameters = new DialogParameters<SitePageSectionsDialog>
{
{ nameof(SitePageSectionsDialog.PageId), item.Id },
{ nameof(SitePageSectionsDialog.PageTitle), item.Title },
{ nameof(SitePageSectionsDialog.Sections), details.Sections }
};
var dialog = await DialogService.ShowAsync<SitePageSectionsDialog>(
$"مدیریت بخش‌های «{item.Title}»", parameters,
new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Large, FullWidth = true });
var result = await dialog.Result;
if (!result.Canceled)
{
await LoadPagesAsync();
}
}
private async Task DeletePage(SitePageSummaryDto item)
{
var confirm = await DialogService.ShowMessageBox(
"تایید حذف",
$"آیا از حذف صفحه «{item.Title}» اطمینان دارید؟ بخش‌های وابسته نیز حذف خواهند شد.",
yesText: "بله، حذف شود",
cancelText: "انصراف");
if (confirm != true) return;
try
{
await SitePageService.DeleteAsync(item.Id);
Snackbar.Add("صفحه با موفقیت حذف شد", Severity.Success);
await LoadPagesAsync();
}
catch (Exception ex)
{
Snackbar.Add($"خطا در حذف: {ex.Message}", Severity.Error);
}
}
}
@@ -0,0 +1,63 @@
@using CMSMicroservice.Protobuf.Protos.UserContract
@inject Microsoft.JSInterop.IJSRuntime JsRuntime
<MudDialog>
<DialogContent>
<MudSimpleTable Dense="true">
<tbody>
<tr>
<td><strong>شناسه قرارداد:</strong></td>
<td>@Contract.Id</td>
</tr>
<tr>
<td><strong>شناسه کاربر:</strong></td>
<td>@Contract.UserId</td>
</tr>
<tr>
<td><strong>شناسه قالب قرارداد:</strong></td>
<td>@Contract.ContractId</td>
</tr>
<tr>
<td><strong>کد امضا:</strong></td>
<td>@Contract.SignGuid</td>
</tr>
<tr>
<td><strong>فایل PDF:</strong></td>
<td>
@if (!string.IsNullOrWhiteSpace(Contract.SignedPdfFile))
{
<MudButton Variant="Variant.Text" Color="Color.Primary" Size="Size.Small"
StartIcon="@Icons.Material.Filled.PictureAsPdf"
OnClick="DownloadPdf">
دانلود PDF
</MudButton>
}
else
{
<MudText Typo="Typo.body2" Color="Color.Secondary">بدون فایل</MudText>
}
</td>
</tr>
</tbody>
</MudSimpleTable>
</DialogContent>
<DialogActions>
<MudButton OnClick="Close">بستن</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter] public GetAllUserContractByFilterResponseModel Contract { get; set; } = null!;
private void Close() => MudDialog.Cancel();
private async Task DownloadPdf()
{
if (!string.IsNullOrWhiteSpace(Contract.SignedPdfFile))
{
var title = $"قرارداد کاربر {Contract.UserId}";
await JsRuntime.InvokeVoidAsync("jsHtmlToPdf", Contract.SignedPdfFile, title);
}
}
}
@@ -0,0 +1,120 @@
@page "/contracts"
@attribute [Authorize(Roles = "Administrator")]
@using CMSMicroservice.Protobuf.Protos.UserContract
@using BackOffice.Pages.AutoComplete
@inject Microsoft.JSInterop.IJSRuntime JsRuntime
@inject UserContractContract.UserContractContractClient UserContractClient
<div>
<MudText Typo="Typo.h5" Class="mb-3">قراردادهای کاربران</MudText>
<MudDataGrid T="GetAllUserContractByFilterResponseModel" @ref="_grid"
ServerData="LoadContracts"
Hover="true" Dense="true"
Height="calc(100vh - 240px)">
<ToolBarContent>
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center">
<UserAutoComplete Label="جستجوی کاربر" @bind-SelectedUserId="_filterUserId" />
<MudButton Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Search"
OnClick="ApplyFilter">جستجو</MudButton>
</MudStack>
</ToolBarContent>
<Columns>
<PropertyColumn Property="x => x.Id" Title="شناسه" />
<PropertyColumn Property="x => x.UserId" Title="شناسه کاربر" />
<PropertyColumn Property="x => x.ContractId" Title="شناسه قرارداد" />
<PropertyColumn Property="x => x.SignGuid" Title="کد امضا" />
<TemplateColumn Title="فایل PDF" Sortable="false">
<CellTemplate>
@if (!string.IsNullOrWhiteSpace(context.Item.SignedPdfFile))
{
<MudButton Variant="Variant.Text" Color="Color.Primary" Size="Size.Small"
StartIcon="@Icons.Material.Filled.PictureAsPdf"
OnClick="@(() => DownloadPdf(context.Item))">
دانلود PDF
</MudButton>
}
else
{
<MudText Typo="Typo.caption" Color="Color.Secondary">بدون فایل</MudText>
}
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="عملیات" Sortable="false">
<CellTemplate>
<MudTooltip Text="جزئیات">
<MudIconButton Icon="@Icons.Material.Filled.Visibility"
Size="Size.Small" Color="Color.Info"
OnClick="@(() => ViewDetails(context.Item))" />
</MudTooltip>
</CellTemplate>
</TemplateColumn>
</Columns>
<NoRecordsContent>
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Text" Class="my-4">قراردادی یافت نشد.</MudAlert>
</NoRecordsContent>
<PagerContent>
<MudDataGridPager T="GetAllUserContractByFilterResponseModel" PageSizeOptions="@(new int[] { 20, 50, 100 })"
InfoFormat="سطر {first_item} تا {last_item} از {all_items}"
RowsPerPageString="تعداد در صفحه" />
</PagerContent>
</MudDataGrid>
</div>
@code {
private MudDataGrid<GetAllUserContractByFilterResponseModel>? _grid;
private long? _filterUserId;
private async Task<GridData<GetAllUserContractByFilterResponseModel>> LoadContracts(GridState<GetAllUserContractByFilterResponseModel> state)
{
try
{
var request = new GetAllUserContractByFilterRequest
{
PaginationState = new() { PageNumber = state.Page + 1, PageSize = state.PageSize },
Filter = new GetAllUserContractByFilterFilter()
};
if (_filterUserId.HasValue && _filterUserId.Value > 0)
request.Filter.UserId = _filterUserId.Value;
var result = await UserContractClient.GetAllUserContractByFilterAsync(request);
return new GridData<GetAllUserContractByFilterResponseModel>
{
Items = result?.Models?.ToList() ?? new(),
TotalItems = (int)(result?.MetaData?.TotalCount ?? 0)
};
}
catch (Exception ex)
{
Snackbar.Add($"خطا: {ex.Message}", Severity.Error);
return new GridData<GetAllUserContractByFilterResponseModel>();
}
}
private async Task ApplyFilter()
{
if (_grid != null) await _grid.ReloadServerData();
}
private async Task ViewDetails(GetAllUserContractByFilterResponseModel contract)
{
var parameters = new DialogParameters
{
["Contract"] = contract
};
var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true };
await DialogService.ShowAsync<ContractDetailsDialog>("جزئیات قرارداد", parameters, options);
}
private async Task DownloadPdf(GetAllUserContractByFilterResponseModel contract)
{
if (!string.IsNullOrWhiteSpace(contract.SignedPdfFile))
{
var title = $"قرارداد کاربر {contract.UserId}";
await JsRuntime.InvokeVoidAsync("jsHtmlToPdf", contract.SignedPdfFile, title);
}
}
}
@@ -118,7 +118,7 @@
.OrderBy(g => g.Key)
.ToList();
_dailyLabels = groups.Select(g => g.Key.ToString("MM/dd")).ToArray();
_dailyLabels = groups.Select(g => { var j = g.Key.MiladiToJalali(); return j.Length >= 7 ? j.Substring(5) : j; }).ToArray();
_dailySeries = new List<ChartSeries>
{
@@ -1,340 +0,0 @@
@page "/dashboard/overview"
@attribute [Authorize]
@using CMSMicroservice.Protobuf.Protos.Commission
@using CMSMicroservice.Protobuf.Protos.ClubMembership
@using Google.Protobuf.WellKnownTypes
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="mt-4">
<MudText Typo="Typo.h4" GutterBottom="true">داشبورد سیستم</MudText>
<MudText Typo="Typo.body1" Color="Color.Secondary" Class="mb-4">
مرور کلی عملکرد سیستم شبکه، باشگاه و کمیسیون
</MudText>
@if (_loading)
{
<MudProgressCircular Color="Color.Primary" Indeterminate="true" />
}
else
{
<!-- Commission Stats -->
<MudCard Class="mb-4">
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h6">
<MudIcon Icon="@Icons.Material.Filled.AccountBalanceWallet" Class="mr-2" />
کمیسیون هفته جاری
</MudText>
</CardHeaderContent>
<CardHeaderActions>
<MudButton Variant="Variant.Text"
Color="Color.Primary"
Href="/commission/dashboard">
جزئیات بیشتر
</MudButton>
</CardHeaderActions>
</MudCardHeader>
<MudCardContent>
<MudGrid>
<MudItem xs="12" md="3">
<MudCard Outlined="true">
<MudCardContent>
<MudStack Spacing="1">
<MudText Typo="Typo.body2" Color="Color.Secondary">هفته جاری</MudText>
<MudText Typo="Typo.h5">@_currentWeekDisplayName</MudText>
</MudStack>
</MudCardContent>
</MudCard>
</MudItem>
<MudItem xs="12" md="3">
<MudCard Outlined="true">
<MudCardContent>
<MudStack Spacing="1">
<MudText Typo="Typo.body2" Color="Color.Secondary">مبلغ کل استخر</MudText>
<MudText Typo="Typo.h5" Color="Color.Primary">
@(_currentPool?.TotalPoolAmount.ToString("N0") ?? "0") ریال
</MudText>
</MudStack>
</MudCardContent>
</MudCard>
</MudItem>
<MudItem xs="12" md="3">
<MudCard Outlined="true">
<MudCardContent>
<MudStack Spacing="1">
<MudText Typo="Typo.body2" Color="Color.Secondary">مجموع موجودی‌ها</MudText>
<MudText Typo="Typo.h5">@(_currentPool?.TotalBalances.ToString("N0") ?? "0")</MudText>
</MudStack>
</MudCardContent>
</MudCard>
</MudItem>
<MudItem xs="12" md="3">
<MudCard Outlined="true">
<MudCardContent>
<MudStack Spacing="1">
<MudText Typo="Typo.body2" Color="Color.Secondary">وضعیت</MudText>
<MudChip T="string"
Color="@(_currentPool?.IsCalculated == true ? Color.Success : Color.Warning)"
Size="Size.Small">
@(_currentPool?.IsCalculated == true ? "محاسبه شده" : "در انتظار")
</MudChip>
</MudStack>
</MudCardContent>
</MudCard>
</MudItem>
</MudGrid>
</MudCardContent>
</MudCard>
<!-- Club Membership Stats -->
<MudCard Class="mb-4">
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h6">
<MudIcon Icon="@Icons.Material.Filled.CardMembership" Class="mr-2" />
عضویت باشگاه
</MudText>
</CardHeaderContent>
<CardHeaderActions>
<MudButton Variant="Variant.Text"
Color="Color.Primary"
Href="/club/members">
مشاهده اعضا
</MudButton>
</CardHeaderActions>
</MudCardHeader>
<MudCardContent>
<MudGrid>
<MudItem xs="12" md="4">
<MudCard Outlined="true">
<MudCardContent>
<MudStack Spacing="1">
<MudText Typo="Typo.body2" Color="Color.Secondary">کل اعضا</MudText>
<MudText Typo="Typo.h5" Color="Color.Primary">@_totalClubMembers</MudText>
</MudStack>
</MudCardContent>
</MudCard>
</MudItem>
<MudItem xs="12" md="4">
<MudCard Outlined="true">
<MudCardContent>
<MudStack Spacing="1">
<MudText Typo="Typo.body2" Color="Color.Secondary">اعضای فعال</MudText>
<MudText Typo="Typo.h5" Color="Color.Success">@_activeClubMembers</MudText>
</MudStack>
</MudCardContent>
</MudCard>
</MudItem>
<MudItem xs="12" md="4">
<MudCard Outlined="true">
<MudCardContent>
<MudStack Spacing="1">
<MudText Typo="Typo.body2" Color="Color.Secondary">اعضای غیرفعال</MudText>
<MudText Typo="Typo.h5" Color="Color.Warning">@_inactiveClubMembers</MudText>
</MudStack>
</MudCardContent>
</MudCard>
</MudItem>
</MudGrid>
</MudCardContent>
</MudCard>
<!-- Discount Shop Stats - TEMPORARILY DISABLED (needs DiscountOrder proto) -->
@* <BackOffice.Pages.Dashboard.DiscountShopWidget /> *@
<MudCard Class="mb-4">
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h6">
<MudIcon Icon="@Icons.Material.Filled.Discount" Class="mr-2" />
آمار فروشگاه تخفیفی
</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudAlert Severity="Severity.Info" Dense="true">
این بخش در حال توسعه است و به‌زودی فعال می‌شود.
</MudAlert>
</MudCardContent>
</MudCard>
<!-- Quick Actions -->
<MudCard>
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h6">
<MudIcon Icon="@Icons.Material.Filled.Speed" Class="mr-2" />
دسترسی سریع
</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudGrid>
<MudItem xs="12" md="4">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
FullWidth="true"
Href="/commission/dashboard"
StartIcon="@Icons.Material.Filled.AccountBalanceWallet">
داشبورد کمیسیون
</MudButton>
</MudItem>
<MudItem xs="12" md="4">
<MudButton Variant="Variant.Filled"
Color="Color.Secondary"
FullWidth="true"
Href="/commission/payouts"
StartIcon="@Icons.Material.Filled.Payments">
پرداخت‌های کاربران
</MudButton>
</MudItem>
<MudItem xs="12" md="4">
<MudButton Variant="Variant.Filled"
Color="Color.Tertiary"
FullWidth="true"
Href="/commission/withdrawals"
StartIcon="@Icons.Material.Filled.RequestQuote">
درخواست‌های برداشت
</MudButton>
</MudItem>
<MudItem xs="12" md="4">
<MudButton Variant="Variant.Outlined"
Color="Color.Info"
FullWidth="true"
Href="/network/tree"
StartIcon="@Icons.Material.Filled.AccountTree">
درخت شبکه
</MudButton>
</MudItem>
<MudItem xs="12" md="4">
<MudButton Variant="Variant.Outlined"
Color="Color.Success"
FullWidth="true"
Href="/network/balances"
StartIcon="@Icons.Material.Filled.AccountBalance">
گزارش موجودی‌ها
</MudButton>
</MudItem>
<MudItem xs="12" md="4">
<MudButton Variant="Variant.Outlined"
Color="Color.Warning"
FullWidth="true"
Href="/club/members"
StartIcon="@Icons.Material.Filled.Groups">
مدیریت باشگاه
</MudButton>
</MudItem>
</MudGrid>
</MudCardContent>
</MudCard>
}
</MudContainer>
@code {
[Inject] public CommissionContract.CommissionContractClient CommissionClient { get; set; } = null!;
[Inject] public ClubMembershipContract.ClubMembershipContractClient ClubClient { get; set; } = null!;
private bool _loading = false;
private long _currentWeekDefinitionId = 0;
private string _currentWeekDisplayName = string.Empty;
private GetWeeklyCommissionPoolResponse? _currentPool;
private long _totalClubMembers = 0;
private long _activeClubMembers = 0;
private long _inactiveClubMembers = 0;
protected override async Task OnInitializedAsync()
{
await LoadDashboardData();
}
private async Task LoadDashboardData()
{
_loading = true;
try
{
// Get current week from available weeks API
try
{
var availableWeeksRequest = new GetAvailableWeeksRequest
{
FutureWeeksCount = 0,
PastWeeksCount = 0
};
var availableWeeksResponse = await CommissionClient.GetAvailableWeeksAsync(availableWeeksRequest);
if (availableWeeksResponse?.CurrentWeek != null)
{
_currentWeekDefinitionId = availableWeeksResponse.CurrentWeek.WeekDefinitionId;
_currentWeekDisplayName = availableWeeksResponse.CurrentWeek.DisplayName;
}
}
catch { /* Ignore - will skip pool loading */ }
// Load Commission data
if (_currentWeekDefinitionId > 0)
{
try
{
var commissionRequest = new GetWeeklyCommissionPoolRequest
{
WeekDefinitionId = _currentWeekDefinitionId
};
var commissionResponse = await CommissionClient.GetWeeklyCommissionPoolAsync(commissionRequest);
_currentPool = commissionResponse;
}
catch (Exception ex)
{
Snackbar.Add($"خطا در بارگذاری داده‌های کمیسیون: {ex.Message}", Severity.Warning);
}
}
// Load Club membership data
try
{
// Get all members
var allMembersRequest = new GetAllClubMembershipsRequest
{
PageIndex = 1,
PageSize = 1
};
var allMembersResponse = await ClubClient.GetAllClubMembershipsAsync(allMembersRequest);
_totalClubMembers = allMembersResponse.MetaData?.TotalCount ?? 0;
// Get active members
var activeMembersRequest = new GetAllClubMembershipsRequest
{
PageIndex = 1,
PageSize = 1,
IsActive = true
};
var activeMembersResponse = await ClubClient.GetAllClubMembershipsAsync(activeMembersRequest);
_activeClubMembers = activeMembersResponse.MetaData?.TotalCount ?? 0;
_inactiveClubMembers = _totalClubMembers - _activeClubMembers;
}
catch (Exception ex)
{
Snackbar.Add($"خطا در بارگذاری داده‌های باشگاه: {ex.Message}", Severity.Warning);
}
}
catch (Exception ex)
{
Snackbar.Add($"خطا در بارگذاری داشبورد: {ex.Message}", Severity.Error);
}
finally
{
_loading = false;
}
}
private string GetCurrentWeekNumber()
{
var now = DateTime.Now;
var jan1 = new DateTime(now.Year, 1, 1);
var daysOffset = DayOfWeek.Monday - jan1.DayOfWeek;
var firstMonday = jan1.AddDays(daysOffset);
var cal = System.Globalization.CultureInfo.CurrentCulture.Calendar;
var weekNum = cal.GetWeekOfYear(now, System.Globalization.CalendarWeekRule.FirstDay, DayOfWeek.Saturday);
return $"{now.Year}-W{weekNum:D2}";
}
}
@@ -43,7 +43,7 @@
HelperText="برای دسته اصلی خالی بگذارید">
@foreach (var category in _availableParents)
{
<MudSelectItem Value="@((long?)category.Id)">@GetCategoryPath(category)</MudSelectItem>
<MudSelectItem T="long?" Value="@((long?)category.Id)">@GetCategoryPath(category)</MudSelectItem>
}
</MudSelect>
</MudItem>
@@ -21,13 +21,13 @@
Label="وضعیت جدید *"
Required="true"
Variant="Variant.Outlined">
<MudSelectItem Value="@OrderStatus.Pending">در انتظار پرداخت</MudSelectItem>
<MudSelectItem Value="@OrderStatus.Paid">پرداخت شده</MudSelectItem>
<MudSelectItem Value="@OrderStatus.Processing">در حال آماده‌سازی</MudSelectItem>
<MudSelectItem Value="@OrderStatus.Shipped">ارسال شده</MudSelectItem>
<MudSelectItem Value="@OrderStatus.Delivered">تحویل داده شده</MudSelectItem>
<MudSelectItem Value="@OrderStatus.Cancelled">لغو شده</MudSelectItem>
<MudSelectItem Value="@OrderStatus.Returned">مرجوع شده</MudSelectItem>
<MudSelectItem T="OrderStatus" Value="@OrderStatus.Pending">در انتظار پرداخت</MudSelectItem>
<MudSelectItem T="OrderStatus" Value="@OrderStatus.Paid">پرداخت شده</MudSelectItem>
<MudSelectItem T="OrderStatus" Value="@OrderStatus.Processing">در حال آماده‌سازی</MudSelectItem>
<MudSelectItem T="OrderStatus" Value="@OrderStatus.Shipped">ارسال شده</MudSelectItem>
<MudSelectItem T="OrderStatus" Value="@OrderStatus.Delivered">تحویل داده شده</MudSelectItem>
<MudSelectItem T="OrderStatus" Value="@OrderStatus.Cancelled">لغو شده</MudSelectItem>
<MudSelectItem T="OrderStatus" Value="@OrderStatus.Returned">مرجوع شده</MudSelectItem>
</MudSelect>
</MudItem>
@@ -26,6 +26,24 @@
</MudPaper>
</MudItem>
<!-- Timeline وضعیت -->
<MudItem xs="12">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.subtitle2" GutterBottom="true">Timeline وضعیت</MudText>
<MudStack Row="true" Spacing="3" AlignItems="AlignItems.Center" Justify="Justify.Center">
@foreach (var step in _timelineSteps)
{
<MudStack Spacing="0" AlignItems="AlignItems.Center">
<MudAvatar Color="@GetStepColor(step.State)" Size="Size.Small">
@step.Icon
</MudAvatar>
<MudText Typo="Typo.caption">@step.Title</MudText>
</MudStack>
}
</MudStack>
</MudPaper>
</MudItem>
<!-- وضعیت سفارش -->
<MudItem xs="12" sm="6">
<MudPaper Class="pa-4" Elevation="1">
@@ -36,7 +54,7 @@
@if (Order.Created.HasValue)
{
<MudText Typo="Typo.body2" Class="mt-2">
تاریخ ثبت: @Order.Created.Value.ToString("yyyy/MM/dd HH:mm")
تاریخ ثبت: @Order.Created.Value.MiladiToJalaliWithTime()
</MudText>
}
</MudPaper>
@@ -101,7 +119,14 @@
</HeaderContent>
<RowTemplate>
<MudTd>
<MudText>@context.ProductTitle</MudText>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
@if (!string.IsNullOrWhiteSpace(context.ThumbnailPath))
{
<MudImage Src="@context.ThumbnailPath" Width="40" Height="40"
ObjectFit="ObjectFit.Cover" Class="rounded" />
}
<MudText>@context.ProductTitle</MudText>
</MudStack>
</MudTd>
<MudTd>@context.Count</MudTd>
<MudTd>@context.UnitPrice.ToString("N0") ریال</MudTd>
@@ -147,6 +172,60 @@
</MudPaper>
</MudItem>
<!-- ویرایش وضعیت ارسال -->
<MudItem xs="12">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.subtitle2" GutterBottom="true">مدیریت وضعیت ارسال</MudText>
<MudDivider Class="mb-3" />
<MudGrid>
<MudItem xs="12" sm="6">
<MudSelect T="OrderStatus"
Label="وضعیت ارسال"
Dense="true"
Variant="Variant.Outlined"
@bind-Value="_newStatus">
<MudSelectItem T="OrderStatus" Value="@OrderStatus.Pending">در انتظار پرداخت</MudSelectItem>
<MudSelectItem T="OrderStatus" Value="@OrderStatus.Paid">پرداخت شده</MudSelectItem>
<MudSelectItem T="OrderStatus" Value="@OrderStatus.Processing">در حال آماده‌سازی</MudSelectItem>
<MudSelectItem T="OrderStatus" Value="@OrderStatus.Shipped">ارسال شده</MudSelectItem>
<MudSelectItem T="OrderStatus" Value="@OrderStatus.Delivered">تحویل داده شده</MudSelectItem>
<MudSelectItem T="OrderStatus" Value="@OrderStatus.Cancelled">لغو شده</MudSelectItem>
<MudSelectItem T="OrderStatus" Value="@OrderStatus.Returned">مرجوع شده</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12" sm="6">
<MudTextField T="string"
Label="کد رهگیری"
Variant="Variant.Outlined"
Dense="true"
@bind-Value="_trackingCode" />
</MudItem>
<MudItem xs="12">
<MudTextField T="string"
Label="یادداشت ادمین"
Variant="Variant.Outlined"
Dense="true"
Lines="3"
HelperText="دلیل تغییر وضعیت یا توضیحات اضافی"
@bind-Value="_adminNotes" />
</MudItem>
</MudGrid>
@if (_newStatus == OrderStatus.Cancelled || _newStatus == OrderStatus.Returned)
{
<MudAlert Severity="Severity.Warning" Class="mt-2">
توجه: در صورت لغو یا مرجوعی سفارش، موجودی محصولات به انبار بازگردانده می‌شود.
</MudAlert>
}
@if (_newStatus == OrderStatus.Shipped)
{
<MudAlert Severity="Severity.Info" Class="mt-2">
پس از تغییر وضعیت به "ارسال شده"، اطلاع‌رسانی به کاربر ارسال خواهد شد.
</MudAlert>
}
</MudPaper>
</MudItem>
<!-- یادداشت‌ها -->
@if (!string.IsNullOrEmpty(Order.Notes) || !string.IsNullOrEmpty(Order.AdminNotes))
{
@@ -174,46 +253,15 @@
</MudGrid>
</DialogContent>
<DialogActions>
<MudButton OnClick="Close" Variant="Variant.Filled" Color="Color.Primary">بستن</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Default" OnClick="Close">
بستن
</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveAsync" Disabled="_isSaving">
@if (_isSaving)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="ml-2" />
}
ثبت تغییرات
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter] public DiscountOrderDetailsDto Order { get; set; } = null!;
private void Close()
{
MudDialog.Close();
}
private Color GetStatusColor(OrderStatus status)
{
return status switch
{
OrderStatus.Pending => Color.Warning,
OrderStatus.Paid => Color.Info,
OrderStatus.Processing => Color.Primary,
OrderStatus.Shipped => Color.Secondary,
OrderStatus.Delivered => Color.Success,
OrderStatus.Cancelled => Color.Error,
OrderStatus.Returned => Color.Dark,
_ => Color.Default
};
}
private string GetStatusText(OrderStatus status)
{
return status switch
{
OrderStatus.Pending => "در انتظار پرداخت",
OrderStatus.Paid => "پرداخت شده",
OrderStatus.Processing => "در حال آماده‌سازی",
OrderStatus.Shipped => "ارسال شده",
OrderStatus.Delivered => "تحویل داده شده",
OrderStatus.Cancelled => "لغو شده",
OrderStatus.Returned => "مرجوع شده",
_ => "نامشخص"
};
}
}
@@ -0,0 +1,173 @@
using BackOffice.Services.DiscountOrder;
using Microsoft.AspNetCore.Components;
using MudBlazor;
namespace BackOffice.Pages.DiscountShop.Components;
public partial class OrderDetailsDialog
{
[CascadingParameter] IMudDialogInstance MudDialog { get; set; } = null!;
[Inject] public IDiscountOrderService OrderService { get; set; } = default!;
[Parameter] public DiscountOrderDetailsDto Order { get; set; } = null!;
private OrderStatus _newStatus;
private string? _trackingCode;
private string? _adminNotes;
private bool _isSaving;
private List<TimelineStep> _timelineSteps = new();
protected override void OnInitialized()
{
_newStatus = Order.Status;
_trackingCode = Order.TrackingCode;
_adminNotes = Order.AdminNotes;
BuildTimeline();
}
private void BuildTimeline()
{
var status = Order.Status;
var steps = new List<TimelineStep>
{
new()
{
Title = "ثبت سفارش",
State = status == OrderStatus.Pending ? StepState.Active : StepState.Completed,
Icon = "1"
},
new()
{
Title = "پرداخت",
State = status switch
{
OrderStatus.Pending => StepState.Inactive,
OrderStatus.Paid => StepState.Active,
OrderStatus.Cancelled => StepState.Inactive,
_ => Order.PaymentCompleted ? StepState.Completed : StepState.Inactive
},
Icon = "2"
},
new()
{
Title = "آماده‌سازی",
State = status switch
{
OrderStatus.Processing => StepState.Active,
OrderStatus.Shipped or OrderStatus.Delivered => StepState.Completed,
_ => StepState.Inactive
},
Icon = "3"
},
new()
{
Title = "ارسال",
State = status switch
{
OrderStatus.Shipped => StepState.Active,
OrderStatus.Delivered => StepState.Completed,
_ => StepState.Inactive
},
Icon = "4"
},
new()
{
Title = "تحویل / مرجوعی",
State = status switch
{
OrderStatus.Delivered => StepState.Completed,
OrderStatus.Returned => StepState.Completed,
OrderStatus.Cancelled => StepState.Completed,
_ => StepState.Inactive
},
Icon = "5"
}
};
_timelineSteps = steps;
}
private Color GetStepColor(StepState state)
{
return state switch
{
StepState.Completed => Color.Success,
StepState.Active => Color.Info,
_ => Color.Default
};
}
private async Task SaveAsync()
{
_isSaving = true;
StateHasChanged();
try
{
await OrderService.UpdateStatusAsync(Order.Id, new UpdateOrderStatusDto
{
Status = _newStatus,
TrackingCode = _trackingCode,
AdminNotes = _adminNotes
});
Snackbar.Add("تغییرات سفارش با موفقیت ذخیره شد.", Severity.Success);
MudDialog.Close(DialogResult.Ok(true));
}
catch (Exception ex)
{
Snackbar.Add($"خطا در ذخیره تغییرات: {ex.Message}", Severity.Error);
}
finally
{
_isSaving = false;
StateHasChanged();
}
}
private void Close() => MudDialog.Close();
private Color GetStatusColor(OrderStatus status)
{
return status switch
{
OrderStatus.Pending => Color.Warning,
OrderStatus.Paid => Color.Info,
OrderStatus.Processing => Color.Primary,
OrderStatus.Shipped => Color.Secondary,
OrderStatus.Delivered => Color.Success,
OrderStatus.Cancelled => Color.Error,
OrderStatus.Returned => Color.Dark,
_ => 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 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;
}
}
@@ -33,7 +33,7 @@
Color="Color.Primary"
ButtonType="ButtonType.Button"
StartIcon="@Icons.Material.Filled.Image"
Style="cursor:pointer;">
>
انتخاب تصویر اصلی
</MudButton>
</ActivatorContent>
@@ -106,7 +106,7 @@
Variant="Variant.Outlined"
Margin="Margin.Dense" />
<MudHtmlEditor @bind-Html="Model.FullInformation">
<MudHtmlEditor @bind-Html="Model.FullInformation" MinHeight="300px">
<MudHtmlToolbarOptions InsertImage="false" />
</MudHtmlEditor>
@@ -125,6 +125,14 @@
<MudStack Spacing="1">
<MudText Typo="Typo.subtitle2">تصویر بندانگشتی</MudText>
@if (!string.IsNullOrWhiteSpace(_thumbnailPreview))
{
<Image Src="@(_thumbnailPreview)" Width="120" Height="80" ObjectPosition="ObjectPosition.Center" ObjectFit="ObjectFit.Cover" />
}
else if (!string.IsNullOrEmpty(Model.ThumbnailPath))
{
<Image Src="@Model.ThumbnailPath" Width="120" Height="80" ObjectPosition="ObjectPosition.Center" ObjectFit="ObjectFit.Cover" />
}
<MudFileUpload T="IBrowserFile" Accept="image/*" FilesChanged="OnThumbnailSelected">
<ActivatorContent>
<MudButton HtmlTag="label"
@@ -132,7 +140,7 @@
Color="Color.Secondary"
ButtonType="ButtonType.Button"
StartIcon="@Icons.Material.Filled.Image"
Style="cursor:pointer;">
>
انتخاب تصویر بندانگشتی (اختیاری)
</MudButton>
</ActivatorContent>
@@ -165,6 +173,9 @@
private IBrowserFile? _thumbnailImageFile;
private readonly long _maxAllowedSize = (1024 * 1024) * 5;
private string? _mainImagePreview;
private string? _thumbnailPreview;
private byte[]? _mainImageBuffer;
private byte[]? _thumbnailImageBuffer;
private List<long> _selectedCategoryIds = new();
protected override Task OnInitializedAsync()
@@ -181,9 +192,10 @@
_mainImageFile = file;
if (file != null)
{
var buffer = new byte[file.Size];
await file.OpenReadStream(_maxAllowedSize).ReadAsync(buffer);
_mainImagePreview = $"data:{file.ContentType};base64," + Convert.ToBase64String(buffer);
using var ms = new MemoryStream();
await file.OpenReadStream(_maxAllowedSize).CopyToAsync(ms);
_mainImageBuffer = ms.ToArray();
_mainImagePreview = $"data:{file.ContentType};base64," + Convert.ToBase64String(_mainImageBuffer);
StateHasChanged();
}
}
@@ -191,6 +203,14 @@
private async Task OnThumbnailSelected(IBrowserFile? file)
{
_thumbnailImageFile = file;
if (file != null)
{
using var ms = new MemoryStream();
await file.OpenReadStream(_maxAllowedSize).CopyToAsync(ms);
_thumbnailImageBuffer = ms.ToArray();
_thumbnailPreview = $"data:{file.ContentType};base64," + Convert.ToBase64String(_thumbnailImageBuffer);
StateHasChanged();
}
}
private async Task Submit()
@@ -207,21 +227,17 @@
Model.CategoryIds = _selectedCategoryIds.ToList();
// Handle main image upload
if (_mainImageFile != null)
if (_mainImageFile != null && _mainImageBuffer != null)
{
var buffer = new byte[_mainImageFile.Size];
await _mainImageFile.OpenReadStream(_maxAllowedSize).ReadAsync(buffer);
Model.ImageFile = buffer;
Model.ImageFile = _mainImageBuffer;
Model.ImageMime = _mainImageFile.ContentType;
Model.ImageFileName = Path.GetFileNameWithoutExtension(_mainImageFile.Name);
}
// Handle thumbnail upload
if (_thumbnailImageFile != null)
if (_thumbnailImageFile != null && _thumbnailImageBuffer != null)
{
var buffer = new byte[_thumbnailImageFile.Size];
await _thumbnailImageFile.OpenReadStream(_maxAllowedSize).ReadAsync(buffer);
Model.ThumbnailFile = buffer;
Model.ThumbnailFile = _thumbnailImageBuffer;
Model.ThumbnailMime = _thumbnailImageFile.ContentType;
Model.ThumbnailFileName = Path.GetFileNameWithoutExtension(_thumbnailImageFile.Name);
}
@@ -12,7 +12,7 @@
Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Collections"
Style="cursor:pointer;">
>
انتخاب تصاویر (چندتایی)
</MudButton>
</ActivatorContent>
@@ -89,8 +89,9 @@
{
if (file == null) continue;
var buffer = new byte[file.Size];
await file.OpenReadStream(_maxAllowedSize).ReadAsync(buffer);
using var ms = new MemoryStream();
await file.OpenReadStream(_maxAllowedSize).CopyToAsync(ms);
var buffer = ms.ToArray();
var preview = $"data:{file.ContentType};base64," + Convert.ToBase64String(buffer);
_items.Add(new ProductImageItem
@@ -1,343 +1,157 @@
@page "/discount-categories"
@using BackOffice.Services.DiscountCategory
@using BackOffice.Pages.DiscountShop.Components
@using BackOffice.Common.BaseComponents
@using static BackOffice.Pages.DiscountShop.Components.CategoryFormDialog
@inject IDiscountCategoryService DiscountCategoryService
@inject IDialogService DialogService
@inject ISnackbar Snackbar
<PageTitle>مدیریت دسته‌بندی محصولات تخفیفی</PageTitle>
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="mt-4">
<MudText Typo="Typo.h4" GutterBottom="true">
<MudIcon Icon="@Icons.Material.Filled.Category" Class="ml-2" />
مدیریت دسته‌بندی‌های فروشگاه تخفیفی
</MudText>
<MudPaper Class="pa-4 mt-4">
<BasePageComponent @ref="_basePage" OnClearFilterClick="OnFilterCleared" OnSubmitClick="OnFilterSubmit">
<Filters>
<MudItem xs="12" sm="6" md="4">
<MudTextField T="string" Clearable="true" Label="جستجو در دسته‌بندی‌ها"
@bind-Value="@_searchQuery" Variant="Variant.Outlined" Margin="Margin.Dense" />
</MudItem>
</Filters>
<Content>
<MudGrid>
<MudItem xs="12" sm="8">
<MudTextField @bind-Value="_searchQuery"
Label="جستجو در دسته‌بندی‌ها"
Variant="Variant.Outlined"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search"
OnKeyUp="@(() => FilterCategories())" />
@* ────── Tree sidebar (same as regular store) ────── *@
<MudItem xs="12" md="3">
<MudPaper Class="pa-2" Style="height:calc(100vh - 240px); overflow:auto;">
<MudText Typo="Typo.subtitle2" Class="mb-2">درخت دسته‌بندی‌ها</MudText>
<MudList T="string" Dense="true">
<MudListItem T="string"
OnClick="@(() => OnTreeNodeSelected(null))"
Selected="@(_selectedParentId == null)">
<MudText Typo="Typo.body2">همه دسته‌بندی‌ها</MudText>
</MudListItem>
@foreach (var node in _treeNodes)
{
<MudListItem T="string"
OnClick="@(() => OnTreeNodeSelected(node.Id))"
Selected="@(_selectedParentId == node.Id)">
<MudStack Row="true" AlignItems="AlignItems.Center">
<div style="width:@(node.Depth * 16)px;"></div>
<MudText Typo="Typo.body2">@node.Title</MudText>
</MudStack>
</MudListItem>
}
</MudList>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="4">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
FullWidth="true"
StartIcon="@Icons.Material.Filled.Add"
OnClick="@(() => OpenCreateDialog())">
دسته‌بندی جدید
</MudButton>
@* ────── Grid (9 cols) ────── *@
<MudItem xs="12" md="9">
<MudDataGrid T="DiscountCategoryDto"
Items="@_filteredCategories"
Hover="true"
Dense="true"
Height="calc(100vh - 240px)">
<ColGroup>
<col />
<col />
<col />
<col />
<col />
<col style="width: 80px;" />
</ColGroup>
<ToolBarContent>
<MudText>مدیریت دسته‌بندی‌ها</MudText>
<MudSpacer />
<MudStack Spacing="2" Row="true" Justify="Justify.Center" AlignItems="AlignItems.Center">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
Size="Size.Large"
ButtonType="ButtonType.Button"
OnClick="@(() => OpenCreateDialog())">
افزودن
</MudButton>
</MudStack>
</ToolBarContent>
<Columns>
<PropertyColumn Property="x => x.Id" Title="شناسه" />
<PropertyColumn Property="x => x.Name" Title="نام لاتین" />
<PropertyColumn Property="x => x.Title" Title="عنوان" />
<TemplateColumn Title="دسته‌بندی والد">
<CellTemplate>
@{
var parentTitle = "-";
if (context.Item.ParentCategoryId.HasValue &&
context.Item.ParentCategoryId.Value > 0)
{
var parent = _allCategories.FirstOrDefault(c => c.Id == context.Item.ParentCategoryId.Value);
if (parent != null)
{
parentTitle = parent.Title;
}
}
}
<MudText Typo="Typo.body2">@parentTitle</MudText>
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="تعداد محصولات">
<CellTemplate>
<MudChip T="string" Color="Color.Info" Size="Size.Small">@context.Item.ProductCount</MudChip>
</CellTemplate>
</TemplateColumn>
<PropertyColumn Property="x => x.SortOrder" Title="ترتیب" />
<TemplateColumn Title="فعال؟">
<CellTemplate>
<MudChip T="string"
Color="@(context.Item.IsActive ? Color.Success : Color.Error)"
Variant="Variant.Filled"
Size="Size.Small">
@(context.Item.IsActive ? "فعال" : "غیرفعال")
</MudChip>
</CellTemplate>
</TemplateColumn>
<TemplateColumn StickyLeft="true" Title="عملیات" CellStyle="text-wrap: nowrap;" HeaderStyle="text-wrap: nowrap;">
<CellTemplate>
<MudStack Row="true" AlignItems="AlignItems.Center">
<MudTooltip Text="افزودن زیردسته">
<MudIconButton Icon="@Icons.Material.Filled.Add"
Color="Color.Success"
Size="Size.Small"
OnClick="@(() => OpenCreateDialog(context.Item.Id))" />
</MudTooltip>
<MudTooltip Text="ویرایش">
<MudIconButton Icon="@Icons.Material.Filled.EditNote"
Size="Size.Small"
ButtonType="ButtonType.Button"
OnClick="@(() => OpenEditDialog(context.Item.Id))" />
</MudTooltip>
<MudTooltip Text="حذف">
<MudIconButton Icon="@Icons.Material.Filled.DeleteOutline"
Size="Size.Small"
ButtonType="ButtonType.Button"
Disabled="@(context.Item.Children.Any())"
OnClick="@(() => DeleteCategory(context.Item.Id))" />
</MudTooltip>
<MudTooltip Text="مدیریت محصولات این دسته (درگ و دراپ)">
<MudIconButton Icon="@Icons.Material.Filled.ShoppingCart"
Size="Size.Small"
ButtonType="ButtonType.Button"
OnClick="@(() => OpenCategoryProducts(context.Item))" />
</MudTooltip>
</MudStack>
</CellTemplate>
</TemplateColumn>
</Columns>
<NoRecordsContent>
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Text" Class="my-4">موردی یافت نشد.</MudAlert>
</NoRecordsContent>
<PagerContent>
<MudDataGridPager T="DiscountCategoryDto"
PageSizeOptions="@(new int[] { 20, 50, 100 })"
InfoFormat="سطر {first_item} تا {last_item} از {all_items}"
RowsPerPageString="تعداد در صفحه" />
</PagerContent>
</MudDataGrid>
</MudItem>
</MudGrid>
@if (_loading)
{
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mt-4" />
}
else
{
<MudDataGrid T="DiscountCategoryDto"
Items="@_filteredCategories"
Hover="true"
Dense="true"
Class="mt-4">
<Columns>
<PropertyColumn Property="x => x.Id" Title="شناسه" />
<TemplateColumn Title="عنوان">
<CellTemplate>
@{
var indent = GetIndentLevel(context.Item);
}
<div style="padding-right: @(indent * 20)px;">
@if (context.Item.Children.Any())
{
<MudIcon Icon="@Icons.Material.Filled.Folder" Size="Size.Small" Class="ml-1" />
}
else
{
<MudIcon Icon="@Icons.Material.Filled.FolderOpen" Size="Size.Small" Class="ml-1" />
}
<strong>@context.Item.Title</strong>
@if (!string.IsNullOrEmpty(context.Item.Name))
{
<MudText Typo="Typo.caption" Class="mr-2">(@context.Item.Name)</MudText>
}
</div>
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="تعداد محصولات">
<CellTemplate>
<MudChip T="string" Color="Color.Info" Size="Size.Small">@context.Item.ProductCount</MudChip>
</CellTemplate>
</TemplateColumn>
<PropertyColumn Property="x => x.SortOrder" Title="ترتیب" />
<TemplateColumn Title="وضعیت">
<CellTemplate>
<MudChip T="string" Color="@(context.Item.IsActive ? Color.Success : Color.Default)"
Size="Size.Small">
@(context.Item.IsActive ? "فعال" : "غیرفعال")
</MudChip>
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="عملیات" Sortable="false">
<CellTemplate>
<MudIconButton Icon="@Icons.Material.Filled.Add"
Color="Color.Success"
Size="Size.Small"
Title="افزودن زیردسته"
OnClick="@(() => OpenCreateDialog(context.Item.Id))" />
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Color="Color.Primary"
Size="Size.Small"
Title="ویرایش"
OnClick="@(() => OpenEditDialog(context.Item.Id))" />
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Color="Color.Error"
Size="Size.Small"
Title="حذف"
Disabled="@(context.Item.Children.Any())"
OnClick="@(() => DeleteCategory(context.Item.Id))" />
</CellTemplate>
</TemplateColumn>
</Columns>
<PagerContent>
<MudDataGridPager T="DiscountCategoryDto" />
</PagerContent>
</MudDataGrid>
@if (!_filteredCategories.Any())
{
<MudAlert Severity="Severity.Info" Class="mt-4">
@if (string.IsNullOrEmpty(_searchQuery))
{
<text>هنوز دسته‌بندی‌ای ایجاد نشده است.</text>
}
else
{
<text>دسته‌بندی با عبارت "@_searchQuery" یافت نشد.</text>
}
</MudAlert>
}
}
</MudPaper>
</MudContainer>
@code {
private List<DiscountCategoryDto> _allCategories = new();
private List<DiscountCategoryDto> _filteredCategories = new();
private bool _loading = false;
private string? _searchQuery;
protected override async Task OnInitializedAsync()
{
await LoadCategories();
}
private async Task LoadCategories()
{
_loading = true;
try
{
var rootCategories = await DiscountCategoryService.GetCategoriesAsync();
_allCategories = FlattenCategoriesWithDepth(rootCategories, 0);
FilterCategories();
Snackbar.Add("دسته‌بندی‌ها بارگذاری شدند", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"خطا در بارگذاری دسته‌بندی‌ها: {ex.Message}", Severity.Error);
}
finally
{
_loading = false;
}
}
private List<DiscountCategoryDto> FlattenCategoriesWithDepth(List<DiscountCategoryDto> categories, int depth)
{
var result = new List<DiscountCategoryDto>();
foreach (var category in categories.OrderBy(c => c.SortOrder))
{
category.IsExpanded = false; // Use IsExpanded to store depth temporarily
result.Add(category);
if (category.Children.Any())
{
result.AddRange(FlattenCategoriesWithDepth(category.Children, depth + 1));
}
}
return result;
}
private int GetIndentLevel(DiscountCategoryDto category)
{
// Calculate depth by traversing parents
int depth = 0;
var current = category;
while (current.ParentCategoryId.HasValue)
{
var parent = _allCategories.FirstOrDefault(c => c.Id == current.ParentCategoryId.Value);
if (parent == null) break;
current = parent;
depth++;
}
return depth;
}
private void FilterCategories()
{
if (string.IsNullOrWhiteSpace(_searchQuery))
{
_filteredCategories = _allCategories;
}
else
{
var query = _searchQuery.ToLower();
_filteredCategories = _allCategories
.Where(c => c.Title.ToLower().Contains(query) ||
c.Name.ToLower().Contains(query) ||
(c.Description?.ToLower().Contains(query) ?? false))
.ToList();
}
}
private async Task OpenCreateDialog(long? parentId = null)
{
var model = new CategoryFormModel { ParentCategoryId = parentId };
var parameters = new DialogParameters<CategoryFormDialog>
{
{ x => x.Model, model },
{ x => x.IsEditMode, false },
{ x => x.ExcludeCategoryId, (long?)null }
};
var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true };
var dialog = await DialogService.ShowAsync<CategoryFormDialog>(
parentId.HasValue ? "ایجاد زیردسته" : "ایجاد دسته‌بندی جدید",
parameters,
options);
var result = await dialog.Result;
if (result is { Canceled: false, Data: CategoryFormModel formData })
{
try
{
var dto = new CreateDiscountCategoryDto
{
ParentCategoryId = formData.ParentCategoryId,
Name = formData.Name,
Title = formData.Title,
Description = formData.Description,
ImagePath = formData.ImagePath,
SortOrder = formData.SortOrder,
IsActive = formData.IsActive
};
await DiscountCategoryService.CreateAsync(dto);
Snackbar.Add("دسته‌بندی با موفقیت ایجاد شد", Severity.Success);
await LoadCategories();
}
catch (Exception ex)
{
Snackbar.Add($"خطا در ایجاد دسته‌بندی: {ex.Message}", Severity.Error);
}
}
}
private async Task OpenEditDialog(long categoryId)
{
try
{
var category = await DiscountCategoryService.GetByIdAsync(categoryId);
if (category == null)
{
Snackbar.Add("دسته‌بندی یافت نشد", Severity.Error);
return;
}
var model = new CategoryFormModel
{
ParentCategoryId = category.ParentCategoryId,
Name = category.Name,
Title = category.Title,
Description = category.Description,
ImagePath = category.ImagePath,
SortOrder = category.SortOrder,
IsActive = category.IsActive
};
var parameters = new DialogParameters<CategoryFormDialog>
{
{ x => x.Model, model },
{ x => x.IsEditMode, true },
{ x => x.ExcludeCategoryId, categoryId }
};
var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true };
var dialog = await DialogService.ShowAsync<CategoryFormDialog>("ویرایش دسته‌بندی", parameters, options);
var result = await dialog.Result;
if (result is { Canceled: false, Data: CategoryFormModel formData })
{
var dto = new UpdateDiscountCategoryDto
{
ParentCategoryId = formData.ParentCategoryId,
Name = formData.Name,
Title = formData.Title,
Description = formData.Description,
ImagePath = formData.ImagePath,
SortOrder = formData.SortOrder,
IsActive = formData.IsActive
};
await DiscountCategoryService.UpdateAsync(categoryId, dto);
Snackbar.Add("دسته‌بندی با موفقیت ویرایش شد", Severity.Success);
await LoadCategories();
}
}
catch (Exception ex)
{
Snackbar.Add($"خطا در ویرایش دسته‌بندی: {ex.Message}", Severity.Error);
}
}
private async Task DeleteCategory(long categoryId)
{
var category = _allCategories.FirstOrDefault(c => c.Id == categoryId);
if (category?.Children.Any() == true)
{
Snackbar.Add("ابتدا زیردسته‌های این دسته‌بندی را حذف کنید", Severity.Warning);
return;
}
var result = await DialogService.ShowMessageBox(
"تأیید حذف",
"آیا از حذف این دسته‌بندی اطمینان دارید؟",
yesText: "بله", cancelText: "خیر");
if (result == true)
{
try
{
await DiscountCategoryService.DeleteAsync(categoryId);
Snackbar.Add("دسته‌بندی با موفقیت حذف شد", Severity.Success);
await LoadCategories();
}
catch (Exception ex)
{
Snackbar.Add($"خطا در حذف دسته‌بندی: {ex.Message}", Severity.Error);
}
}
}
}
</Content>
</BasePageComponent>
@@ -0,0 +1,275 @@
using BackOffice.Common.BaseComponents;
using BackOffice.Common.Utilities;
using BackOffice.Services.DiscountCategory;
using BackOffice.Pages.DiscountShop.Components;
using static BackOffice.Pages.DiscountShop.Components.CategoryFormDialog;
using Microsoft.AspNetCore.Components;
using MudBlazor;
namespace BackOffice.Pages.DiscountShop;
public partial class DiscountCategoriesMainPage
{
[Inject] public IDiscountCategoryService DiscountCategoryService { get; set; } = default!;
private List<DiscountCategoryDto> _allCategories = new();
private List<DiscountCategoryDto> _filteredCategories = new();
private List<TreeNodeModel> _treeNodes = new();
private BasePageComponent _basePage = default!;
private bool _loading;
private string? _searchQuery;
private long? _selectedParentId;
protected override async Task OnInitializedAsync()
{
await LoadCategories();
}
private async Task LoadCategories()
{
_loading = true;
try
{
var rootCategories = await DiscountCategoryService.GetCategoriesAsync();
_allCategories = FlattenCategoriesWithDepth(rootCategories, 0);
BuildTreeNodes(rootCategories);
FilterCategories();
}
catch (Exception ex)
{
Snackbar.Add($"خطا در بارگذاری دسته‌بندی‌ها: {ex.Message}", Severity.Error);
}
finally
{
_loading = false;
}
}
private void BuildTreeNodes(List<DiscountCategoryDto> categories, int depth = 0)
{
_treeNodes.Clear();
BuildTreeNodesRecursive(categories, depth);
}
private void BuildTreeNodesRecursive(List<DiscountCategoryDto> categories, int depth)
{
foreach (var cat in categories.OrderBy(c => c.SortOrder))
{
_treeNodes.Add(new TreeNodeModel
{
Id = cat.Id,
Title = cat.Title,
Depth = depth,
HasChildren = cat.Children.Any()
});
if (cat.Children.Any())
{
BuildTreeNodesRecursive(cat.Children, depth + 1);
}
}
}
private List<DiscountCategoryDto> FlattenCategoriesWithDepth(List<DiscountCategoryDto> categories, int depth)
{
var result = new List<DiscountCategoryDto>();
foreach (var category in categories.OrderBy(c => c.SortOrder))
{
result.Add(category);
if (category.Children.Any())
{
result.AddRange(FlattenCategoriesWithDepth(category.Children, depth + 1));
}
}
return result;
}
private void OnTreeNodeSelected(long? parentId)
{
_selectedParentId = parentId;
FilterCategories();
StateHasChanged();
}
private void FilterCategories()
{
var items = _allCategories.AsEnumerable();
if (_selectedParentId.HasValue)
{
items = items.Where(c =>
c.Id == _selectedParentId.Value ||
c.ParentCategoryId == _selectedParentId.Value);
}
if (!string.IsNullOrWhiteSpace(_searchQuery))
{
var query = _searchQuery.ToLower();
items = items.Where(c =>
c.Title.ToLower().Contains(query) ||
c.Name.ToLower().Contains(query) ||
(c.Description?.ToLower().Contains(query) ?? false));
}
_filteredCategories = items.ToList();
}
private async Task OnFilterSubmit()
{
_basePage.IsFiltered = true;
FilterCategories();
StateHasChanged();
}
private async Task OnFilterCleared()
{
_basePage.IsFiltered = false;
_searchQuery = null;
_selectedParentId = null;
FilterCategories();
StateHasChanged();
}
private async Task OpenCreateDialog(long? parentId = null)
{
var model = new CategoryFormModel { ParentCategoryId = parentId };
var parameters = new DialogParameters<CategoryFormDialog>
{
{ x => x.Model, model },
{ x => x.IsEditMode, false },
{ x => x.ExcludeCategoryId, (long?)null }
};
var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true };
var dialog = await DialogService.ShowAsync<CategoryFormDialog>(
parentId.HasValue ? "ایجاد زیردسته" : "ایجاد دسته‌بندی جدید",
parameters,
options);
var result = await dialog.Result;
if (result is { Canceled: false, Data: CategoryFormModel formData })
{
try
{
var dto = new CreateDiscountCategoryDto
{
ParentCategoryId = formData.ParentCategoryId,
Name = formData.Name,
Title = formData.Title,
Description = formData.Description,
ImagePath = formData.ImagePath,
SortOrder = formData.SortOrder,
IsActive = formData.IsActive
};
await DiscountCategoryService.CreateAsync(dto);
Snackbar.Add("دسته‌بندی با موفقیت ایجاد شد", Severity.Success);
await LoadCategories();
}
catch (Exception ex)
{
Snackbar.Add($"خطا در ایجاد دسته‌بندی: {ex.Message}", Severity.Error);
}
}
}
private async Task OpenEditDialog(long categoryId)
{
try
{
var category = await DiscountCategoryService.GetByIdAsync(categoryId);
if (category == null)
{
Snackbar.Add("دسته‌بندی یافت نشد", Severity.Error);
return;
}
var model = new CategoryFormModel
{
ParentCategoryId = category.ParentCategoryId,
Name = category.Name,
Title = category.Title,
Description = category.Description,
ImagePath = category.ImagePath,
SortOrder = category.SortOrder,
IsActive = category.IsActive
};
var parameters = new DialogParameters<CategoryFormDialog>
{
{ x => x.Model, model },
{ x => x.IsEditMode, true },
{ x => x.ExcludeCategoryId, categoryId }
};
var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true };
var dialog = await DialogService.ShowAsync<CategoryFormDialog>("ویرایش دسته‌بندی", parameters, options);
var result = await dialog.Result;
if (result is { Canceled: false, Data: CategoryFormModel formData })
{
var dto = new UpdateDiscountCategoryDto
{
ParentCategoryId = formData.ParentCategoryId,
Name = formData.Name,
Title = formData.Title,
Description = formData.Description,
ImagePath = formData.ImagePath,
SortOrder = formData.SortOrder,
IsActive = formData.IsActive
};
await DiscountCategoryService.UpdateAsync(categoryId, dto);
Snackbar.Add("دسته‌بندی با موفقیت ویرایش شد", Severity.Success);
await LoadCategories();
}
}
catch (Exception ex)
{
Snackbar.Add($"خطا در ویرایش دسته‌بندی: {ex.Message}", Severity.Error);
}
}
private async Task DeleteCategory(long categoryId)
{
var category = _allCategories.FirstOrDefault(c => c.Id == categoryId);
if (category?.Children.Any() == true)
{
Snackbar.Add("ابتدا زیردسته‌های این دسته‌بندی را حذف کنید", Severity.Warning);
return;
}
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small };
bool? result = await DialogService.ShowMessageBox(
"اخطار",
$"آیا از حذف دسته‌بندی «{category?.Title}» مطمئن هستید؟",
yesText: "حذف", cancelText: "لغو",
options: options);
if (result == true)
{
try
{
await DiscountCategoryService.DeleteAsync(categoryId);
Snackbar.Add("دسته‌بندی با موفقیت حذف شد", Severity.Success);
await LoadCategories();
}
catch (Exception ex)
{
Snackbar.Add($"خطا در حذف دسته‌بندی: {ex.Message}", Severity.Error);
}
}
}
private class TreeNodeModel
{
public long Id { get; set; }
public string Title { get; set; } = string.Empty;
public int Depth { get; set; }
public bool HasChildren { get; set; }
}
public void OpenCategoryProducts(DiscountCategoryDto model)
{
Navigation.NavigateTo($"{RouteConstance.CategoryProducts}{model.Id}");
}
}
@@ -1,283 +1,213 @@
@page "/discount-orders"
@* Merged into /discount-shop *@
@using BackOffice.Services.DiscountOrder
@using BackOffice.Pages.DiscountShop.Components
@inject IDiscountOrderService DiscountOrderService
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@using BackOffice.Common.BaseComponents
@using BackOffice.Pages.AutoComplete
<PageTitle>مدیریت سفارشات فروشگاه تخفیفی</PageTitle>
<MudContainer MaxWidth="MaxWidth.ExtraExtraLarge" Class="mt-4">
<MudText Typo="Typo.h4" GutterBottom="true">
<MudIcon Icon="@Icons.Material.Filled.ShoppingCart" Class="ml-2" />
مدیریت سفارشات فروشگاه تخفیفی
</MudText>
<MudPaper Class="pa-4 mt-4">
<MudGrid>
<MudItem xs="12" sm="6" md="3">
<MudTextField @bind-Value="_searchQuery"
Label="جستجو (شماره سفارش)"
Variant="Variant.Outlined"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search"
OnKeyUp="@(() => OnSearch())" />
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudSelect @bind-Value="_statusFilter"
Label="وضعیت سفارش"
Variant="Variant.Outlined"
T="OrderStatus?"
Clearable="true"
OnClearButtonClick="@(() => OnSearch())">
<MudSelectItem Value="@((OrderStatus?)null)">همه</MudSelectItem>
<MudSelectItem Value="@OrderStatus.Pending">در انتظار پرداخت</MudSelectItem>
<MudSelectItem Value="@OrderStatus.Paid">پرداخت شده</MudSelectItem>
<MudSelectItem Value="@OrderStatus.Processing">در حال آماده‌سازی</MudSelectItem>
<MudSelectItem Value="@OrderStatus.Shipped">ارسال شده</MudSelectItem>
<MudSelectItem Value="@OrderStatus.Delivered">تحویل داده شده</MudSelectItem>
<MudSelectItem Value="@OrderStatus.Cancelled">لغو شده</MudSelectItem>
<MudSelectItem Value="@OrderStatus.Returned">مرجوع شده</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudSelect @bind-Value="_paymentFilter"
Label="وضعیت پرداخت"
Variant="Variant.Outlined"
T="bool?"
Clearable="true">
<MudSelectItem Value="@((bool?)null)">همه</MudSelectItem>
<MudSelectItem Value="@((bool?)true)">پرداخت شده</MudSelectItem>
<MudSelectItem Value="@((bool?)false)">پرداخت نشده</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
FullWidth="true"
StartIcon="@Icons.Material.Filled.Search"
OnClick="@(() => OnSearch())">
جستجو
</MudButton>
</MudItem>
</MudGrid>
<MudDataGrid @ref="_dataGrid" T="DiscountOrderDto"
Items="@_orders"
<BackOffice.Common.BaseComponents.BasePageComponent @ref="_basePage" OnClearFilterClick="OnFilterCleared"
OnSubmitClick="OnFilterSubmit">
<Filters>
<MudItem xs="12" sm="6" md="3">
<MudNumericField HideSpinButtons="true" T="long?" Clearable="true" Label="شناسه سفارش"
@bind-Value="@_orderIdFilter" Variant="Variant.Outlined" Margin="Margin.Dense"/>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<UserAutoComplete Label="جستجوی کاربر" @bind-SelectedUserId="@_userIdFilter" />
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudTextField T="string" Clearable="true" Label="موبایل کاربر"
@bind-Value="@_userMobileFilter" Variant="Variant.Outlined" Margin="Margin.Dense"/>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudTextField T="string" Clearable="true" Label="کد رهگیری"
@bind-Value="@_trackingCodeFilter" Variant="Variant.Outlined" Margin="Margin.Dense"/>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudDatePicker T="DateTime?" Label="از تاریخ" @bind-Date="_fromDateFilter"
Variant="Variant.Outlined" Margin="Margin.Dense"/>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudDatePicker T="DateTime?" Label="تا تاریخ" @bind-Date="_toDateFilter"
Variant="Variant.Outlined" Margin="Margin.Dense"/>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudSelect T="int?" Clearable="true" Label="وضعیت پرداخت" Variant="Variant.Outlined"
Margin="Margin.Dense" @bind-Value="_paymentStatusFilter">
<MudSelectItem T="int?" Value="@((int?)null)">همه</MudSelectItem>
<MudSelectItem T="int?" Value="@(0)">در انتظار پرداخت</MudSelectItem>
<MudSelectItem T="int?" Value="@(1)">پرداخت شده</MudSelectItem>
<MudSelectItem T="int?" Value="@(2)">ناموفق</MudSelectItem>
<MudSelectItem T="int?" Value="@(3)">بازپرداخت</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudSelect T="OrderStatus?" Clearable="true" Label="وضعیت ارسال" Variant="Variant.Outlined"
Margin="Margin.Dense" @bind-Value="_statusFilter">
<MudSelectItem T="OrderStatus?" Value="@((OrderStatus?)null)">همه</MudSelectItem>
<MudSelectItem T="OrderStatus?" Value="@OrderStatus.Pending">در انتظار ارسال</MudSelectItem>
<MudSelectItem T="OrderStatus?" Value="@OrderStatus.Processing">در حال آماده‌سازی</MudSelectItem>
<MudSelectItem T="OrderStatus?" Value="@OrderStatus.Shipped">ارسال شده</MudSelectItem>
<MudSelectItem T="OrderStatus?" Value="@OrderStatus.Delivered">تحویل داده شده</MudSelectItem>
<MudSelectItem T="OrderStatus?" Value="@OrderStatus.Cancelled">لغو شده</MudSelectItem>
</MudSelect>
</MudItem>
</Filters>
<Content>
<MudDataGrid T="DiscountOrderDto"
Items="@_orders"
Loading="@_loading"
Hover="true"
Dense="true"
Class="mt-4">
Hover="true" @ref="_dataGrid" Height="calc(100vh - 240px)">
<ColGroup>
<col/>
<col/>
<col/>
<col style="width: 58px;"/>
</ColGroup>
<ToolBarContent>
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Style="width:100%">
<MudText>لیست سفارشات</MudText>
<MudButton Variant="Variant.Outlined"
Color="Color.Info"
OnClick="ExportToExcel"
StartIcon="@Icons.Material.Filled.FileDownload"
Size="Size.Small">
خروجی Excel
</MudButton>
</MudStack>
</ToolBarContent>
<Columns>
<PropertyColumn Property="x => x.OrderNumber" Title="شماره سفارش" />
<PropertyColumn Property="x => x.Created" Title="تاریخ ثبت" Format="yyyy/MM/dd HH:mm" />
<PropertyColumn Property="x => x.Id" Title="شناسه"/>
<TemplateColumn Title="نام کاربر">
<CellTemplate>
@if (context.Item.UserId > 0)
{
<MudLink Href="@($"/network/user-info/{context.Item.UserId}")" Underline="Underline.Hover" Color="Color.Primary">
@context.Item.UserFullName
</MudLink>
}
else
{
<MudText>@context.Item.UserFullName</MudText>
}
</CellTemplate>
</TemplateColumn>
<PropertyColumn Property="x => x.UserMobile" Title="موبایل"/>
<TemplateColumn Title="مبلغ کل">
<CellTemplate>
<MudText>@context.Item.TotalPrice.ToString("N0") ریال</MudText>
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="تخفیف">
<TemplateColumn Title="تخفیف کیف‌پول">
<CellTemplate>
<MudText Color="Color.Success">@context.Item.DiscountBalanceUsed.ToString("N0") ریال</MudText>
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="قابل پرداخت">
<TemplateColumn Title="پرداخت درگاه">
<CellTemplate>
<MudText Typo="Typo.body2">
<strong>@context.Item.GatewayAmount.ToString("N0") ریال</strong>
</MudText>
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="تعداد">
<TemplateColumn Title="تعداد آیتم">
<CellTemplate>
<MudChip T="string" Color="Color.Info" Size="Size.Small">@context.Item.ItemsCount آیتم</MudChip>
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="وضعیت">
<TemplateColumn Title="وضعیت پرداخت">
<CellTemplate>
<MudChip T="string" Color="@GetStatusColor(context.Item.Status)" Size="Size.Small">
<MudChip T="string"
Size="Size.Small"
Color="@GetPaymentStatusColor(context.Item.PaymentStatusValue)">
@GetPaymentStatusText(context.Item.PaymentStatusValue)
</MudChip>
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="تاریخ پرداخت" CellStyle="text-wrap: nowrap;" HeaderStyle="text-wrap: nowrap;">
<CellTemplate>
@(context.Item.PaymentDate?.MiladiToJalaliWithTime() ?? "-")
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="آدرس">
<CellTemplate>
<MudTooltip Text="@context.Item.ShippingAddress">
<MudText Typo="Typo.body2">
@(string.IsNullOrWhiteSpace(context.Item.ShippingAddress)
? "-"
: (context.Item.ShippingAddress.Length > 30
? context.Item.ShippingAddress.Substring(0, 30) + "..."
: context.Item.ShippingAddress))
</MudText>
</MudTooltip>
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="وضعیت ارسال">
<CellTemplate>
<MudChip T="string"
Size="Size.Small"
Color="@GetStatusColor(context.Item.Status)">
@GetStatusText(context.Item.Status)
</MudChip>
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="پرداخت">
<TemplateColumn Title="تاریخ ثبت" CellStyle="text-wrap: nowrap;" HeaderStyle="text-wrap: nowrap;">
<CellTemplate>
@if (context.Item.PaymentCompleted)
{
<MudChip T="string" Color="Color.Success" Size="Size.Small" Icon="@Icons.Material.Filled.CheckCircle">
پرداخت شده
</MudChip>
}
else
{
<MudChip T="string" Color="Color.Warning" Size="Size.Small" Icon="@Icons.Material.Filled.Schedule">
در انتظار
</MudChip>
}
@(context.Item.Created?.MiladiToJalaliWithTime() ?? "—")
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="عملیات" Sortable="false">
<TemplateColumn StickyLeft="true" Title="عملیات" CellStyle="text-wrap: nowrap;"
HeaderStyle="text-wrap: nowrap;">
<CellTemplate>
<MudIconButton Icon="@Icons.Material.Filled.Visibility"
Color="Color.Info"
Size="Size.Small"
Title="مشاهده جزئیات"
OnClick="@(() => OpenOrderDetails(context.Item.Id))" />
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Color="Color.Primary"
Size="Size.Small"
Title="تغییر وضعیت"
OnClick="@(() => OpenChangeStatusDialog(context.Item.Id))" />
<MudStack Row="true" AlignItems="AlignItems.Center">
<MudTooltip Text="جزئیات سفارش">
<MudIconButton Icon="@Icons.Material.Filled.Info"
Size="Size.Small"
ButtonType="ButtonType.Button"
OnClick="@(() => OpenOrderDetails(context.Item.Id))"
/>
</MudTooltip>
<MudTooltip Text="تغییر وضعیت ارسال">
<MudIconButton Icon="@Icons.Material.Filled.LocalShipping"
Size="Size.Small"
Color="Color.Primary"
ButtonType="ButtonType.Button"
OnClick="@(() => OpenChangeStatusDialog(context.Item))"
/>
</MudTooltip>
<MudTooltip Text="لغو سفارش">
<MudIconButton Icon="@Icons.Material.Filled.Cancel" Size="Size.Small"
Color="Color.Error"
ButtonType="ButtonType.Button" OnClick="@(() => OnDelete(context.Item))"
/>
</MudTooltip>
</MudStack>
</CellTemplate>
</TemplateColumn>
</Columns>
<NoRecordsContent>
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Text" Class="my-4">موردی یافت نشد.</MudAlert>
</NoRecordsContent>
<PagerContent>
<MudDataGridPager T="DiscountOrderDto" />
<MudDataGridPager T="DiscountOrderDto" PageSizeOptions=@(new int[] { 20, 50, 100 })
InfoFormat="سطر {first_item} تا {last_item} از {all_items}"
RowsPerPageString="تعداد در صفحه"/>
</PagerContent>
</MudDataGrid>
</MudPaper>
</MudContainer>
@code {
private MudDataGrid<DiscountOrderDto>? _dataGrid;
private List<DiscountOrderDto> _orders = new();
private bool _loading = false;
private int _totalCount;
private int _totalPages;
private string? _searchQuery;
private OrderStatus? _statusFilter;
private bool? _paymentFilter;
protected override async Task OnInitializedAsync()
{
await LoadOrders();
}
private async Task LoadOrders()
{
_loading = true;
try
{
var filter = new OrderFilterDto
{
Status = _statusFilter,
PaymentCompleted = _paymentFilter
};
var (orders, totalCount, totalPages) = await DiscountOrderService.GetOrdersAsync(filter);
_orders = orders;
_totalCount = totalCount;
_totalPages = totalPages;
Snackbar.Add("سفارشات بارگذاری شدند", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"خطا در بارگذاری سفارشات: {ex.Message}", Severity.Error);
}
finally
{
_loading = false;
}
}
private async Task OnSearch()
{
await LoadOrders();
}
private async Task OpenOrderDetails(long orderId)
{
try
{
var order = await DiscountOrderService.GetByIdAsync(orderId);
if (order == null)
{
Snackbar.Add("سفارش یافت نشد", Severity.Error);
return;
}
var parameters = new DialogParameters<OrderDetailsDialog> { { x => x.Order, order } };
var options = new DialogOptions { MaxWidth = MaxWidth.Large, FullWidth = true };
await DialogService.ShowAsync<OrderDetailsDialog>("جزئیات سفارش", parameters, options);
}
catch (Exception ex)
{
Snackbar.Add($"خطا در بارگذاری جزئیات: {ex.Message}", Severity.Error);
}
}
private async Task OpenChangeStatusDialog(long orderId)
{
try
{
var order = _orders.FirstOrDefault(o => o.Id == orderId);
if (order == null)
{
Snackbar.Add("سفارش یافت نشد", Severity.Error);
return;
}
var parameters = new DialogParameters<ChangeOrderStatusDialog>
{
{ x => x.OrderId, orderId },
{ x => x.CurrentStatus, order.Status }
};
var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true };
var dialog = await DialogService.ShowAsync<ChangeOrderStatusDialog>("تغییر وضعیت سفارش", parameters, options);
var result = await dialog.Result;
if (result is { Canceled: false, Data: UpdateOrderStatusDto dto })
{
await DiscountOrderService.UpdateStatusAsync(orderId, dto);
Snackbar.Add("وضعیت سفارش با موفقیت تغییر یافت", Severity.Success);
await LoadOrders();
}
}
catch (Exception ex)
{
Snackbar.Add($"خطا در تغییر وضعیت: {ex.Message}", Severity.Error);
}
}
private Color GetStatusColor(OrderStatus status)
{
return status switch
{
OrderStatus.Pending => Color.Warning,
OrderStatus.Paid => Color.Info,
OrderStatus.Processing => Color.Primary,
OrderStatus.Shipped => Color.Secondary,
OrderStatus.Delivered => Color.Success,
OrderStatus.Cancelled => Color.Error,
OrderStatus.Returned => Color.Dark,
_ => Color.Default
};
}
private string GetStatusText(OrderStatus status)
{
return status switch
{
OrderStatus.Pending => "در انتظار پرداخت",
OrderStatus.Paid => "پرداخت شده",
OrderStatus.Processing => "در حال آماده‌سازی",
OrderStatus.Shipped => "ارسال شده",
OrderStatus.Delivered => "تحویل داده شده",
OrderStatus.Cancelled => "لغو شده",
OrderStatus.Returned => "مرجوع شده",
_ => "نامشخص"
};
}
}
</Content>
</BackOffice.Common.BaseComponents.BasePageComponent>
@@ -0,0 +1,276 @@
using System.Text;
using BackOffice.Common.BaseComponents;
using BackOffice.Services.DiscountOrder;
using BackOffice.Pages.DiscountShop.Components;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using MudBlazor;
using DateTimeConverterCL;
namespace BackOffice.Pages.DiscountShop;
public partial class DiscountOrdersMainPage
{
[Inject] public IDiscountOrderService DiscountOrderService { get; set; } = default!;
[Inject] public IJSRuntime JsRuntime { get; set; } = default!;
private MudDataGrid<DiscountOrderDto>? _dataGrid;
private BasePageComponent _basePage = default!;
private List<DiscountOrderDto> _orders = new();
private bool _loading;
// فیلترها - یکسان با فروشگاه عادی
private long? _orderIdFilter;
private long? _userIdFilter;
private string? _userMobileFilter;
private string? _trackingCodeFilter;
private DateTime? _fromDateFilter;
private DateTime? _toDateFilter;
private int? _paymentStatusFilter;
private OrderStatus? _statusFilter;
protected override async Task OnInitializedAsync()
{
await LoadOrders();
}
private async Task LoadOrders()
{
_loading = true;
try
{
var filter = new OrderFilterDto
{
UserId = _userIdFilter ?? _orderIdFilter, // orderId → search by userId too
Status = _statusFilter,
PaymentStatus = _paymentStatusFilter,
UserMobile = _userMobileFilter,
TrackingCode = _trackingCodeFilter,
FromDate = _fromDateFilter,
ToDate = _toDateFilter
};
var (orders, totalCount, totalPages) = await DiscountOrderService.GetOrdersAsync(filter);
_orders = orders;
}
catch (Exception ex)
{
Snackbar.Add($"خطا در بارگذاری سفارشات: {ex.Message}", Severity.Error);
}
finally
{
_loading = false;
}
}
private async Task OpenOrderDetails(long orderId)
{
try
{
var order = await DiscountOrderService.GetByIdAsync(orderId);
if (order == null)
{
Snackbar.Add("سفارش یافت نشد", Severity.Error);
return;
}
var parameters = new DialogParameters<OrderDetailsDialog> { { x => x.Order, order } };
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Large, FullWidth = true };
var dialog = await DialogService.ShowAsync<OrderDetailsDialog>("جزئیات سفارش", parameters, options);
var result = await dialog.Result;
if (result is { Canceled: false })
{
await LoadOrders();
}
}
catch (Exception ex)
{
Snackbar.Add($"خطا در بارگذاری جزئیات: {ex.Message}", Severity.Error);
}
}
private async Task OpenChangeStatusDialog(DiscountOrderDto order)
{
var parameters = new DialogParameters<ChangeOrderStatusDialog>
{
{ x => x.OrderId, order.Id },
{ x => x.CurrentStatus, order.Status }
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true };
var dialog = await DialogService.ShowAsync<ChangeOrderStatusDialog>(
"تغییر وضعیت سفارش", parameters, options);
var result = await dialog.Result;
if (result is { Canceled: false, Data: UpdateOrderStatusDto dto })
{
try
{
await DiscountOrderService.UpdateStatusAsync(order.Id, dto);
Snackbar.Add("وضعیت سفارش با موفقیت تغییر یافت", Severity.Success);
await LoadOrders();
}
catch (Exception ex)
{
Snackbar.Add($"خطا در تغییر وضعیت: {ex.Message}", Severity.Error);
}
}
}
private async Task OnDelete(DiscountOrderDto model)
{
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small };
bool? result = await DialogService.ShowMessageBox(
"اخطار",
$"آیا از لغو سفارش شماره «{model.Id}» مطمئن هستید؟ این عملیات وضعیت سفارش را به «لغو شده» تغییر می‌دهد.",
yesText: "لغو سفارش", cancelText: "انصراف",
options: options);
if (result == true)
{
try
{
await DiscountOrderService.UpdateStatusAsync(model.Id, new UpdateOrderStatusDto
{
Status = OrderStatus.Cancelled,
AdminNotes = "لغو شده توسط ادمین"
});
Snackbar.Add("سفارش با موفقیت لغو شد.", Severity.Success);
await LoadOrders();
}
catch (Exception ex)
{
Snackbar.Add($"خطا در لغو سفارش: {ex.Message}", Severity.Error);
}
}
StateHasChanged();
}
public async Task OnFilterSubmit()
{
_basePage.IsFiltered = true;
StateHasChanged();
await LoadOrders();
}
public async Task OnFilterCleared()
{
_basePage.IsFiltered = false;
StateHasChanged();
_orderIdFilter = null;
_userIdFilter = null;
_userMobileFilter = null;
_trackingCodeFilter = null;
_fromDateFilter = null;
_toDateFilter = null;
_paymentStatusFilter = null;
_statusFilter = null;
await LoadOrders();
}
private Color GetStatusColor(OrderStatus status)
{
return status switch
{
OrderStatus.Pending => Color.Warning,
OrderStatus.Paid => Color.Info,
OrderStatus.Processing => Color.Primary,
OrderStatus.Shipped => Color.Secondary,
OrderStatus.Delivered => Color.Success,
OrderStatus.Cancelled => Color.Error,
OrderStatus.Returned => Color.Dark,
_ => 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 Color GetPaymentStatusColor(int status)
{
return status switch
{
0 => Color.Warning, // Pending
1 => Color.Success, // Completed
2 => Color.Error, // Failed
3 => Color.Info, // Refunded
_ => Color.Default
};
}
private string GetPaymentStatusText(int status)
{
return status switch
{
0 => "در انتظار پرداخت",
1 => "پرداخت شده",
2 => "ناموفق",
3 => "بازپرداخت شده",
_ => "نامشخص"
};
}
private async Task ExportToExcel()
{
if (_orders == null || !_orders.Any())
{
Snackbar.Add("داده‌ای برای خروجی وجود ندارد.", Severity.Info);
return;
}
try
{
var sb = new StringBuilder();
sb.AppendLine("شناسه,نام کاربر,موبایل,مبلغ کل,تخفیف کیف‌پول,پرداخت درگاه,تعداد آیتم,وضعیت پرداخت,وضعیت ارسال,تاریخ پرداخت,تاریخ ثبت,آدرس");
foreach (var o in _orders)
{
sb.AppendLine(string.Join(",",
o.Id,
EscapeCsv(o.UserFullName),
EscapeCsv(o.UserMobile),
o.TotalPrice.ToString("N0"),
o.DiscountBalanceUsed.ToString("N0"),
o.GatewayAmount.ToString("N0"),
o.ItemsCount,
EscapeCsv(GetPaymentStatusText(o.PaymentStatusValue)),
EscapeCsv(GetStatusText(o.Status)),
o.PaymentDate?.MiladiToJalaliWithTime() ?? "-",
o.Created?.MiladiToJalaliWithTime() ?? "-",
EscapeCsv(o.ShippingAddress ?? "-")));
}
var bytes = Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes(sb.ToString())).ToArray();
var base64 = Convert.ToBase64String(bytes);
var filename = $"discount-orders-{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);
}
}
private static string EscapeCsv(string value)
{
if (string.IsNullOrEmpty(value)) return "";
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
return $"\"{value.Replace("\"", "\"\"")}\"";
return value;
}
}
@@ -1,359 +1,208 @@
@page "/discount-products"
@using BackOffice.Services.DiscountProduct
@using BackOffice.Services.DiscountCategory
@using BackOffice.Pages.DiscountShop.Components
@using BackOffice.Common.BaseComponents
@using static BackOffice.Pages.DiscountShop.Components.ProductFormDialog
@inject IDiscountProductService DiscountProductService
@inject IDialogService DialogService
@inject ISnackbar Snackbar
<PageTitle>مدیریت محصولات تخفیفی</PageTitle>
<MudContainer MaxWidth="MaxWidth.ExtraExtraLarge" Class="mt-4">
<MudText Typo="Typo.h4" GutterBottom="true">
<MudIcon Icon="@Icons.Material.Filled.Discount" Class="ml-2" />
مدیریت محصولات تخفیفی
</MudText>
<MudPaper Class="pa-4 mt-4">
<MudGrid>
<MudItem xs="12" sm="6" md="3">
<MudTextField @bind-Value="_searchQuery"
Label="جستجو"
Variant="Variant.Outlined"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search"
OnKeyUp="@(() => OnSearch())" />
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudSelect @bind-Value="_categoryFilter"
Label="دسته‌بندی"
Variant="Variant.Outlined"
T="long?"
Clearable="true"
OnClearButtonClick="@(() => OnCategoryFilterChanged())">
<MudSelectItem Value="@((long?)null)">همه</MudSelectItem>
@* TODO: Load categories *@
</MudSelect>
</MudItem>
<MudItem xs="12" sm="6" md="2">
<MudSelect @bind-Value="_statusFilter"
Label="وضعیت"
Variant="Variant.Outlined"
T="bool?"
Clearable="true">
<MudSelectItem Value="@((bool?)null)">همه</MudSelectItem>
<MudSelectItem Value="@((bool?)true)">فعال</MudSelectItem>
<MudSelectItem Value="@((bool?)false)">غیرفعال</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12" sm="6" md="2">
<MudSelect @bind-Value="_stockFilter"
Label="موجودی"
Variant="Variant.Outlined"
T="bool?"
Clearable="true">
<MudSelectItem Value="@((bool?)null)">همه</MudSelectItem>
<MudSelectItem Value="@((bool?)true)">موجود</MudSelectItem>
<MudSelectItem Value="@((bool?)false)">ناموجود</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12" sm="12" md="2">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
FullWidth="true"
StartIcon="@Icons.Material.Filled.Add"
OnClick="@(() => OpenCreateDialog())">
محصول جدید
</MudButton>
</MudItem>
</MudGrid>
<MudDataGrid @ref="_dataGrid" T="DiscountProductDto"
Items="@_products"
<BasePageComponent @ref="_basePage" OnClearFilterClick="OnFilterCleared" OnSubmitClick="OnFilterSubmit">
<Filters>
<MudItem xs="12" sm="6" md="3">
<MudTextField T="string" Clearable="true" Label="جستجو"
@bind-Value="@_searchQuery" Variant="Variant.Outlined" Margin="Margin.Dense" />
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudSelect @bind-Value="_categoryFilter"
Label="دسته‌بندی"
Variant="Variant.Outlined"
Margin="Margin.Dense"
T="long?"
Clearable="true">
<MudSelectItem T="long?" Value="@((long?)null)">همه</MudSelectItem>
@foreach (var cat in _categories)
{
<MudSelectItem T="long?" Value="@((long?)cat.Id)">@cat.Title</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudSelect @bind-Value="_statusFilter"
Label="وضعیت"
Variant="Variant.Outlined"
Margin="Margin.Dense"
T="bool?"
Clearable="true">
<MudSelectItem T="bool?" Value="@((bool?)null)">همه</MudSelectItem>
<MudSelectItem T="bool?" Value="@((bool?)true)">فعال</MudSelectItem>
<MudSelectItem T="bool?" Value="@((bool?)false)">غیرفعال</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudSelect @bind-Value="_stockFilter"
Label="موجودی"
Variant="Variant.Outlined"
Margin="Margin.Dense"
T="bool?"
Clearable="true">
<MudSelectItem T="bool?" Value="@((bool?)null)">همه</MudSelectItem>
<MudSelectItem T="bool?" Value="@((bool?)true)">موجود</MudSelectItem>
<MudSelectItem T="bool?" Value="@((bool?)false)">ناموجود</MudSelectItem>
</MudSelect>
</MudItem>
</Filters>
<Content>
<MudDataGrid @ref="_dataGrid" T="DiscountProductDto"
ServerData="LoadServerData"
Loading="@_loading"
Hover="true"
Dense="true"
Class="mt-4">
Hover="true"
Height="calc(100vh - 240px)">
<ColGroup>
<col />
<col />
<col />
<col style="width: 58px;" />
</ColGroup>
<ToolBarContent>
<MudText>مدیریت محصولات</MudText>
<MudSpacer />
<MudStack Spacing="2" Row="true" Justify="Justify.Center" AlignItems="AlignItems.Center">
<MudButton Variant="Variant.Outlined"
Color="Color.Secondary"
Size="Size.Small"
ButtonType="ButtonType.Button"
StartIcon="@Icons.Material.Filled.Download"
OnClick="ExportToExcel">
خروجی Excel
</MudButton>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
Size="Size.Large"
ButtonType="ButtonType.Button"
OnClick="@(() => OpenCreateDialog())">
افزودن
</MudButton>
</MudStack>
</ToolBarContent>
<Columns>
<PropertyColumn Property="x => x.Id" Title="شناسه" />
<TemplateColumn Title="تصویر">
<PropertyColumn Property="x => x.Title" Title="عنوان" CellStyle="text-wrap: nowrap;" HeaderStyle="text-wrap: nowrap;">
<CellTemplate>
@if (!string.IsNullOrEmpty(context.Item.ThumbnailPath))
{
<MudAvatar Image="@context.Item.ThumbnailPath" Size="Size.Medium" />
}
else
{
<MudAvatar Color="Color.Secondary" Size="Size.Medium">
<MudIcon Icon="@Icons.Material.Filled.Image" />
</MudAvatar>
}
<MudStack Row="true" AlignItems="AlignItems.Center">
<MudTooltip Text="پیش‌نمایش تصویر">
<div @onclick="@(() => OpenImagePreview(context.Item.ImagePath, context.Item.Title))">
<Image Src="@context.Item.ImagePath"
Width="25"
Height="25"
ObjectPosition="ObjectPosition.Center"
ObjectFit="ObjectFit.Fill" />
</div>
</MudTooltip>
@if (string.IsNullOrWhiteSpace(context.Item.Title))
{
<MudText Typo="Typo.inherit">-</MudText>
}
else
{
<MudTooltip Text="@(context.Item.Title)" Arrow="true">
<MudText Typo="Typo.inherit" Style="text-wrap: nowrap;">
@(context.Item.Title.Truncate(20, true))
</MudText>
</MudTooltip>
}
</MudStack>
</CellTemplate>
</TemplateColumn>
<PropertyColumn Property="x => x.Title" Title="عنوان" />
<TemplateColumn Title="قیمت">
</PropertyColumn>
<TemplateColumn Title="قیمت" CellStyle="text-wrap: nowrap;">
<CellTemplate>
<MudText>@context.Item.Price.ToString("N0") ریال</MudText>
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="حداکثر تخفیف">
<TemplateColumn Title="حداکثر تخفیف" CellStyle="text-wrap: nowrap;">
<CellTemplate>
<MudChip T="string" Color="Color.Success" Size="Size.Small">@context.Item.MaxDiscountPercent%</MudChip>
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="موجودی">
<TemplateColumn Title="موجودی" CellStyle="text-wrap: nowrap;">
<CellTemplate>
<MudChip T="string" Color="@(context.Item.RemainingCount > 0 ? Color.Info : Color.Error)" Size="Size.Small">
@context.Item.RemainingCount
</MudChip>
@if (context.Item.RemainingCount <= 0)
{
<MudChip T="string" Color="Color.Error" Size="Size.Small">ناموجود</MudChip>
}
else if (context.Item.RemainingCount < 10)
{
<MudChip T="string" Color="Color.Warning" Size="Size.Small">@context.Item.RemainingCount</MudChip>
}
else
{
<MudChip T="string" Color="Color.Success" Size="Size.Small">@context.Item.RemainingCount</MudChip>
}
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="بازدید">
<CellTemplate>
<MudText>@context.Item.ViewCount</MudText>
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="وضعیت">
<CellTemplate>
<MudChip T="string" Color="@(context.Item.IsActive ? Color.Success : Color.Default)"
<MudChip T="string"
Color="@(context.Item.IsActive ? Color.Success : Color.Error)"
Variant="Variant.Filled"
Size="Size.Small">
@(context.Item.IsActive ? "فعال" : "غیرفعال")
</MudChip>
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="عملیات" Sortable="false">
<TemplateColumn StickyLeft="true" Title="عملیات" CellStyle="text-wrap: nowrap;" HeaderStyle="text-wrap: nowrap;">
<CellTemplate>
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Color="Color.Primary"
Size="Size.Small"
OnClick="@(() => OpenEditDialog(context.Item.Id))" />
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Color="Color.Error"
Size="Size.Small"
OnClick="@(() => DeleteProduct(context.Item.Id))" />
<MudStack Row="true" AlignItems="AlignItems.Center">
<MudTooltip Text="ویرایش">
<MudIconButton Icon="@Icons.Material.Filled.EditNote"
Size="Size.Small"
ButtonType="ButtonType.Button"
OnClick="@(() => OpenEditDialog(context.Item.Id))" />
</MudTooltip>
<MudTooltip Text="حذف">
<MudIconButton Icon="@Icons.Material.Filled.DeleteOutline"
Size="Size.Small"
ButtonType="ButtonType.Button"
OnClick="@(() => DeleteProduct(context.Item.Id))" />
</MudTooltip>
<MudTooltip Text="گالری تصاویر">
<MudIconButton Icon="@Icons.Material.Filled.Collections"
Size="Size.Small"
ButtonType="ButtonType.Button"
OnClick="@(() => OpenGallery(context.Item))" />
</MudTooltip>
<MudTooltip Text="مدیریت دسته‌بندی (درگ و دراپ)">
<MudIconButton Icon="@Icons.Material.Filled.Category"
Size="Size.Small"
ButtonType="ButtonType.Button"
OnClick="@(() => OpenCategoryMapping(context.Item))" />
</MudTooltip>
<MudTooltip Text="تگ‌های محصول">
<MudIconButton Icon="@Icons.Material.Filled.Label"
Size="Size.Small"
ButtonType="ButtonType.Button"
OnClick="@(() => OpenTagAssignment(context.Item))" />
</MudTooltip>
</MudStack>
</CellTemplate>
</TemplateColumn>
</Columns>
<NoRecordsContent>
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Text" Class="my-4">موردی یافت نشد.</MudAlert>
</NoRecordsContent>
<PagerContent>
<MudDataGridPager T="DiscountProductDto" />
<MudDataGridPager T="DiscountProductDto" PageSizeOptions="@(new int[] { 20, 50, 100 })"
InfoFormat="سطر {first_item} تا {last_item} از {all_items}"
RowsPerPageString="تعداد در صفحه" />
</PagerContent>
</MudDataGrid>
</MudPaper>
</MudContainer>
@code {
private MudDataGrid<DiscountProductDto>? _dataGrid;
private List<DiscountProductDto> _products = new();
private bool _loading = false;
private int _totalCount;
private int _totalPages;
private string? _searchQuery;
private long? _categoryFilter;
private bool? _statusFilter;
private bool? _stockFilter;
protected override async Task OnInitializedAsync()
{
await LoadProducts();
}
private async Task LoadProducts()
{
_loading = true;
try
{
var filter = new ProductFilterDto
{
SearchQuery = _searchQuery,
CategoryId = _categoryFilter,
IsActive = _statusFilter,
InStock = _stockFilter
};
var (products, totalCount, totalPages) = await DiscountProductService.GetProductsAsync(filter);
_products = products;
_totalCount = totalCount;
_totalPages = totalPages;
Console.WriteLine($"Loaded {_products.Count} products, Total: {_totalCount}, Pages: {_totalPages}");
if (_products.Count > 0)
{
Snackbar.Add($"{_products.Count} محصول بارگذاری شد", Severity.Success);
}
else
{
Snackbar.Add("هیچ محصولی یافت نشد", Severity.Info);
}
}
catch (Exception ex)
{
Console.WriteLine($"Error loading products: {ex.Message}");
Console.WriteLine($"StackTrace: {ex.StackTrace}");
Snackbar.Add($"خطا در بارگذاری محصولات: {ex.Message}", Severity.Error);
}
finally
{
_loading = false;
}
}
private async Task OnSearch()
{
await LoadProducts();
}
private async Task OnCategoryFilterChanged()
{
await LoadProducts();
}
private async Task OpenCreateDialog()
{
var model = new ProductFormModel();
var parameters = new DialogParameters<ProductFormDialog>
{
{ x => x.Model, model },
{ x => x.IsEditMode, false }
};
var options = new DialogOptions { MaxWidth = MaxWidth.Medium, FullWidth = true };
var dialog = await DialogService.ShowAsync<ProductFormDialog>("ایجاد محصول جدید", parameters, options);
var result = await dialog.Result;
if (result is { Canceled: false, Data: ProductFormModel formData })
{
try
{
var dto = new CreateDiscountProductDto
{
Title = formData.Title,
ShortInformation = formData.ShortInformation,
FullInformation = formData.FullInformation,
ImagePath = formData.ImagePath,
ThumbnailPath = formData.ThumbnailPath,
Price = formData.Price,
MaxDiscountPercent = formData.MaxDiscountPercent,
InitialCount = 0, // Always 0 - must use inventory management
SortOrder = formData.SortOrder,
IsActive = formData.IsActive,
CategoryIds = formData.CategoryIds,
ImageFile = formData.ImageFile,
ImageMime = formData.ImageMime,
ImageFileName = formData.ImageFileName,
ThumbnailFile = formData.ThumbnailFile,
ThumbnailMime = formData.ThumbnailMime,
ThumbnailFileName = formData.ThumbnailFileName
};
await DiscountProductService.CreateAsync(dto);
Snackbar.Add("محصول با موفقیت ایجاد شد", Severity.Success);
await LoadProducts();
}
catch (Exception ex)
{
Snackbar.Add($"خطا در ایجاد محصول: {ex.Message}", Severity.Error);
}
}
}
private async Task OpenEditDialog(long productId)
{
try
{
var product = await DiscountProductService.GetByIdAsync(productId);
if (product == null)
{
Snackbar.Add("محصول یافت نشد", Severity.Error);
return;
}
var model = new ProductFormModel
{
Title = product.Title,
ShortInformation = product.ShortInformation,
FullInformation = product.FullInformation,
ImagePath = product.ImagePath,
ThumbnailPath = product.ThumbnailPath,
Price = product.Price,
MaxDiscountPercent = product.MaxDiscountPercent,
SortOrder = product.SortOrder,
IsActive = product.IsActive,
CategoryIds = product.Categories?.Select(c => c.Id).ToList() ?? new()
};
var parameters = new DialogParameters<ProductFormDialog>
{
{ x => x.Model, model },
{ x => x.IsEditMode, true }
};
var options = new DialogOptions { MaxWidth = MaxWidth.Medium, FullWidth = true };
var dialog = await DialogService.ShowAsync<ProductFormDialog>("ویرایش محصول", parameters, options);
var result = await dialog.Result;
if (result is { Canceled: false, Data: ProductFormModel formData })
{
var dto = new UpdateDiscountProductDto
{
Title = formData.Title,
ShortInformation = formData.ShortInformation,
FullInformation = formData.FullInformation,
ImagePath = formData.ImagePath,
ThumbnailPath = formData.ThumbnailPath,
Price = formData.Price,
MaxDiscountPercent = formData.MaxDiscountPercent,
SortOrder = formData.SortOrder,
IsActive = formData.IsActive,
CategoryIds = formData.CategoryIds,
ImageFile = formData.ImageFile,
ImageMime = formData.ImageMime,
ImageFileName = formData.ImageFileName,
ThumbnailFile = formData.ThumbnailFile,
ThumbnailMime = formData.ThumbnailMime,
ThumbnailFileName = formData.ThumbnailFileName
};
await DiscountProductService.UpdateAsync(productId, dto);
Snackbar.Add("محصول با موفقیت ویرایش شد", Severity.Success);
await LoadProducts();
}
}
catch (Exception ex)
{
Snackbar.Add($"خطا در ویرایش محصول: {ex.Message}", Severity.Error);
}
}
private async Task DeleteProduct(long productId)
{
var result = await DialogService.ShowMessageBox(
"تأیید حذف",
"آیا از حذف این محصول اطمینان دارید؟",
yesText: "بله", cancelText: "خیر");
if (result == true)
{
try
{
await DiscountProductService.DeleteAsync(productId);
Snackbar.Add("محصول با موفقیت حذف شد", Severity.Success);
await LoadProducts();
}
catch (Exception ex)
{
Snackbar.Add($"خطا در حذف محصول: {ex.Message}", Severity.Error);
}
}
}
}
</Content>
</BasePageComponent>
@@ -0,0 +1,349 @@
using System.Text;
using BackOffice.Common.BaseComponents;
using BackOffice.Common.Utilities;
using BackOffice.Services.DiscountProduct;
using BackOffice.Services.DiscountCategory;
using BackOffice.Pages.DiscountShop.Components;
using BackOffice.Pages.Products.Components;
using BackOffice.Pages.Tag.Components;
using static BackOffice.Pages.DiscountShop.Components.ProductFormDialog;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using MudBlazor;
namespace BackOffice.Pages.DiscountShop;
public partial class DiscountProductsMainPage
{
[Inject] public IDiscountProductService DiscountProductService { get; set; } = default!;
[Inject] public IDiscountCategoryService DiscountCategoryService { get; set; } = default!;
private MudDataGrid<DiscountProductDto>? _dataGrid;
private BasePageComponent _basePage = default!;
private List<DiscountProductDto> _products = new();
private List<DiscountCategoryDto> _categories = new();
private bool _loading;
private string? _searchQuery;
private long? _categoryFilter;
private bool? _statusFilter;
private bool? _stockFilter;
protected override async Task OnInitializedAsync()
{
await LoadCategories();
}
private async Task LoadCategories()
{
try
{
_categories = await DiscountCategoryService.GetCategoriesAsync(isActive: true);
}
catch
{
_categories = new();
}
}
private async Task<GridData<DiscountProductDto>> LoadServerData(GridState<DiscountProductDto> state)
{
_loading = true;
try
{
var filter = new ProductFilterDto
{
SearchQuery = _searchQuery,
CategoryId = _categoryFilter,
IsActive = _statusFilter,
InStock = _stockFilter,
PageNumber = state.Page + 1,
PageSize = state.PageSize
};
var (products, totalCount, _) = await DiscountProductService.GetProductsAsync(filter);
_products = products;
return new GridData<DiscountProductDto>
{
Items = products,
TotalItems = totalCount
};
}
catch (Exception ex)
{
Snackbar.Add($"خطا در بارگذاری محصولات: {ex.Message}", Severity.Error);
return new GridData<DiscountProductDto> { Items = new List<DiscountProductDto>(), TotalItems = 0 };
}
finally
{
_loading = false;
}
}
private void ReLoadData()
{
_dataGrid?.ReloadServerData();
}
private async Task OnFilterSubmit()
{
_basePage.IsFiltered = true;
StateHasChanged();
ReLoadData();
}
private async Task OnFilterCleared()
{
_basePage.IsFiltered = false;
_searchQuery = null;
_categoryFilter = null;
_statusFilter = null;
_stockFilter = null;
StateHasChanged();
ReLoadData();
}
private async Task OpenCreateDialog()
{
var model = new ProductFormModel();
var parameters = new DialogParameters<ProductFormDialog>
{
{ x => x.Model, model },
{ x => x.IsEditMode, false }
};
var options = new DialogOptions { MaxWidth = MaxWidth.Medium, FullWidth = true };
var dialog = await DialogService.ShowAsync<ProductFormDialog>("ایجاد محصول جدید", parameters, options);
var result = await dialog.Result;
if (result is { Canceled: false, Data: ProductFormModel formData })
{
try
{
var dto = new CreateDiscountProductDto
{
Title = formData.Title,
ShortInformation = formData.ShortInformation,
FullInformation = formData.FullInformation,
ImagePath = formData.ImagePath,
ThumbnailPath = formData.ThumbnailPath,
Price = formData.Price,
MaxDiscountPercent = formData.MaxDiscountPercent,
InitialCount = 0,
SortOrder = formData.SortOrder,
IsActive = formData.IsActive,
CategoryIds = formData.CategoryIds,
ImageFile = formData.ImageFile,
ImageMime = formData.ImageMime,
ImageFileName = formData.ImageFileName,
ThumbnailFile = formData.ThumbnailFile,
ThumbnailMime = formData.ThumbnailMime,
ThumbnailFileName = formData.ThumbnailFileName
};
await DiscountProductService.CreateAsync(dto);
Snackbar.Add("محصول با موفقیت ایجاد شد", Severity.Success);
ReLoadData();
}
catch (Exception ex)
{
Snackbar.Add($"خطا در ایجاد محصول: {ex.Message}", Severity.Error);
}
}
}
private async Task OpenEditDialog(long productId)
{
try
{
var product = await DiscountProductService.GetByIdAsync(productId);
if (product == null)
{
Snackbar.Add("محصول یافت نشد", Severity.Error);
return;
}
var model = new ProductFormModel
{
Title = product.Title,
ShortInformation = product.ShortInformation,
FullInformation = product.FullInformation,
ImagePath = product.ImagePath,
ThumbnailPath = product.ThumbnailPath,
Price = product.Price,
MaxDiscountPercent = product.MaxDiscountPercent,
SortOrder = product.SortOrder,
IsActive = product.IsActive,
CategoryIds = product.Categories?.Select(c => c.Id).ToList() ?? new()
};
var parameters = new DialogParameters<ProductFormDialog>
{
{ x => x.Model, model },
{ x => x.IsEditMode, true }
};
var options = new DialogOptions { MaxWidth = MaxWidth.Medium, FullWidth = true };
var dialog = await DialogService.ShowAsync<ProductFormDialog>("ویرایش محصول", parameters, options);
var result = await dialog.Result;
if (result is { Canceled: false, Data: ProductFormModel formData })
{
var dto = new UpdateDiscountProductDto
{
Title = formData.Title,
ShortInformation = formData.ShortInformation,
FullInformation = formData.FullInformation,
ImagePath = formData.ImagePath,
ThumbnailPath = formData.ThumbnailPath,
Price = formData.Price,
MaxDiscountPercent = formData.MaxDiscountPercent,
SortOrder = formData.SortOrder,
IsActive = formData.IsActive,
CategoryIds = formData.CategoryIds,
ImageFile = formData.ImageFile,
ImageMime = formData.ImageMime,
ImageFileName = formData.ImageFileName,
ThumbnailFile = formData.ThumbnailFile,
ThumbnailMime = formData.ThumbnailMime,
ThumbnailFileName = formData.ThumbnailFileName
};
await DiscountProductService.UpdateAsync(productId, dto);
Snackbar.Add("محصول با موفقیت ویرایش شد", Severity.Success);
ReLoadData();
}
}
catch (Exception ex)
{
Snackbar.Add($"خطا در ویرایش محصول: {ex.Message}", Severity.Error);
}
}
private async Task DeleteProduct(long productId)
{
var product = _products.FirstOrDefault(p => p.Id == productId);
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small };
bool? result = await DialogService.ShowMessageBox(
"اخطار",
$"آیا از حذف محصول «{product?.Title}» مطمئن هستید؟",
yesText: "حذف", cancelText: "لغو",
options: options);
if (result == true)
{
try
{
await DiscountProductService.DeleteAsync(productId);
Snackbar.Add("محصول با موفقیت حذف شد", Severity.Success);
ReLoadData();
}
catch (Exception ex)
{
Snackbar.Add($"خطا در حذف محصول: {ex.Message}", Severity.Error);
}
}
}
private async Task OpenGallery(DiscountProductDto product)
{
var parameters = new DialogParameters<GalleryDialog>
{
{ x => x.ProductId, product.Id },
{ x => x.ProductTitle, product.Title }
};
await DialogService.ShowAsync<GalleryDialog>("گالری تصاویر", parameters,
new DialogOptions { CloseButton = true, FullWidth = true, MaxWidth = MaxWidth.Medium });
}
public void OpenCategoryMapping(DiscountProductDto product)
{
Navigation.NavigateTo($"{RouteConstance.ProductCategories}{product.Id}");
}
private async Task OpenImagePreview(string? imagePath, string? title)
{
if (string.IsNullOrWhiteSpace(imagePath))
return;
var parameters = new DialogParameters<ImagePreviewDialog>
{
{ x => x.ImageUrl, imagePath },
{ x => x.Title, title }
};
await DialogService.ShowAsync<ImagePreviewDialog>("پیش‌نمایش تصویر محصول", parameters,
new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Medium, FullWidth = true });
}
private async Task ExportToExcel()
{
try
{
var filter = new ProductFilterDto
{
SearchQuery = _searchQuery,
CategoryId = _categoryFilter,
IsActive = _statusFilter,
InStock = _stockFilter,
PageNumber = 1,
PageSize = 1000
};
var (products, _, _) = await DiscountProductService.GetProductsAsync(filter);
if (products == null || products.Count == 0)
{
Snackbar.Add("داده‌ای برای خروجی وجود ندارد.", Severity.Info);
return;
}
var sb = new StringBuilder();
sb.AppendLine("شناسه,عنوان,قیمت,حداکثر تخفیف,موجودی,بازدید,وضعیت");
foreach (var p in products)
{
sb.AppendLine(string.Join(",",
p.Id,
EscapeCsv(p.Title),
p.Price,
p.MaxDiscountPercent,
p.RemainingCount,
p.ViewCount,
p.IsActive ? "فعال" : "غیرفعال"));
}
var bytes = Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes(sb.ToString())).ToArray();
var base64 = Convert.ToBase64String(bytes);
var filename = $"discount-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 OpenTagAssignment(DiscountProductDto model)
{
var parameters = new DialogParameters<AssignTagsDialog>
{
{ x => x.ProductId, model.Id },
{ x => x.ProductTitle, model.Title }
};
await DialogService.ShowAsync<AssignTagsDialog>("مدیریت تگ‌های محصول", parameters,
new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Small, FullWidth = true });
}
private static string EscapeCsv(string? value)
{
if (string.IsNullOrEmpty(value)) return "";
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
return "\"" + value.Replace("\"", "\"\"") + "\"";
return value;
}
}
@@ -0,0 +1,32 @@
@page "/discount-shop"
@page "/discount-orders"
@page "/discount-sales-reports"
@attribute [Authorize(Roles = "Administrator")]
<div>
<MudText Typo="Typo.h5" Class="mb-3">سفارشات فروشگاه تخفیفی</MudText>
<MudTabs Elevation="0" Rounded="true" Border="true" ApplyEffectsToContainer="true"
PanelClass="pt-4" @bind-ActivePanelIndex="_activeTab">
<MudTabPanel Text="سفارشات" Icon="@Icons.Material.Filled.ReceiptLong">
<DiscountOrdersMainPage />
</MudTabPanel>
<MudTabPanel Text="گزارش فروش" Icon="@Icons.Material.Filled.BarChart">
<SalesReports />
</MudTabPanel>
</MudTabs>
</div>
@code {
private int _activeTab;
protected override void OnInitialized()
{
var uri = Navigation.ToBaseRelativePath(Navigation.Uri).ToLowerInvariant().TrimEnd('/');
_activeTab = uri switch
{
"discount-sales-reports" => 1,
_ => 0
};
}
}
@@ -1,4 +1,4 @@
@page "/discount-sales-reports"
@* Merged into /discount-shop *@
@attribute [Authorize(Roles = "Administrator")]
@using MudBlazor
@@ -10,13 +10,13 @@
@inject ISnackbar Snackbar
@inject IJSRuntime JsRuntime
<MudContainer MaxWidth="MaxWidth.ExtraExtraLarge" Class="mt-4">
<div>
<MudText Typo="Typo.h4" GutterBottom="true">
<MudIcon Icon="@Icons.Material.Filled.BarChart" Class="ml-2" />
گزارش فروش فروشگاه تخفیفی
گزارش فروش فروشگاه
</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-4">
آمار فروش، تخفیف و وضعیت سفارش‌های فروشگاه تخفیفی بر اساس بازه تاریخ و وضعیت سفارش
آمار فروش و وضعیت سفارش‌ها بر اساس بازه تاریخ و وضعیت سفارش
</MudText>
<MudPaper Class="pa-4 mb-4">
@@ -42,14 +42,14 @@
Clearable="true"
Variant="Variant.Outlined"
Margin="Margin.Dense">
<MudSelectItem Value="@(null as OrderStatus?)">همه وضعیت‌ها</MudSelectItem>
<MudSelectItem Value="OrderStatus.Pending">در انتظار</MudSelectItem>
<MudSelectItem Value="OrderStatus.Paid">پرداخت شده</MudSelectItem>
<MudSelectItem Value="OrderStatus.Processing">در حال پردازش</MudSelectItem>
<MudSelectItem Value="OrderStatus.Shipped">ارسال شده</MudSelectItem>
<MudSelectItem Value="OrderStatus.Delivered">تحویل شده</MudSelectItem>
<MudSelectItem Value="OrderStatus.Cancelled">لغو شده</MudSelectItem>
<MudSelectItem Value="OrderStatus.Returned">مرجوع شده</MudSelectItem>
<MudSelectItem T="OrderStatus?" Value="@(null as OrderStatus?)">همه وضعیت‌ها</MudSelectItem>
<MudSelectItem T="OrderStatus?" Value="OrderStatus.Pending">در انتظار</MudSelectItem>
<MudSelectItem T="OrderStatus?" Value="OrderStatus.Paid">پرداخت شده</MudSelectItem>
<MudSelectItem T="OrderStatus?" Value="OrderStatus.Processing">در حال پردازش</MudSelectItem>
<MudSelectItem T="OrderStatus?" Value="OrderStatus.Shipped">ارسال شده</MudSelectItem>
<MudSelectItem T="OrderStatus?" Value="OrderStatus.Delivered">تحویل شده</MudSelectItem>
<MudSelectItem T="OrderStatus?" Value="OrderStatus.Cancelled">لغو شده</MudSelectItem>
<MudSelectItem T="OrderStatus?" Value="OrderStatus.Returned">مرجوع شده</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12" sm="6" md="3">
@@ -137,14 +137,49 @@
<MudText Typo="Typo.h6">روند فروش</MudText>
</MudCardHeader>
<MudCardContent>
<MudChart ChartType="ChartType.Line"
ChartSeries="@_salesSeries"
XAxisLabels="@_salesLabels"
Width="100%"
Height="300px" />
@if (_salesLabels.Length == 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">
داده کافی برای نمایش نمودار وجود ندارد.
</MudText>
}
else
{
<MudChart ChartType="ChartType.Line"
ChartSeries="@_salesSeries"
XAxisLabels="@_salesLabels"
Width="100%"
Height="300px" />
}
</MudCardContent>
</MudCard>
</MudItem>
<MudItem xs="12" md="6">
<MudCard>
<MudCardHeader>
<MudText Typo="Typo.h6">وضعیت سفارش‌ها</MudText>
</MudCardHeader>
<MudCardContent>
@if (_statusChartLabels.Length == 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">
داده کافی برای نمایش نمودار وجود ندارد.
</MudText>
}
else
{
<MudChart ChartType="ChartType.Bar"
ChartSeries="@_statusChartSeries"
XAxisLabels="@_statusChartLabels"
Width="100%"
Height="300px" />
}
</MudCardContent>
</MudCard>
</MudItem>
</MudGrid>
<MudGrid Class="mb-4">
<MudItem xs="12" md="6">
<MudCard>
<MudCardHeader>
@@ -180,10 +215,24 @@
Hover="true"
Dense="true">
<Columns>
<PropertyColumn Property="x => x.OrderNumber" Title="شماره سفارش" />
<PropertyColumn Property="x => x.Id" Title="شناسه" />
<TemplateColumn Title="نام کاربر">
<CellTemplate>
@if (context.Item.UserId > 0)
{
<MudLink Href="@($"/network/user-info/{context.Item.UserId}")" Underline="Underline.Hover" Color="Color.Primary">
@context.Item.UserFullName
</MudLink>
}
else
{
<MudText>@context.Item.UserFullName</MudText>
}
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="تاریخ ثبت">
<CellTemplate>
@(context.Item.Created?.ToString("yyyy/MM/dd HH:mm") ?? "-")
@(context.Item.Created?.MiladiToJalaliWithTime() ?? "-")
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="تعداد آیتم">
@@ -211,11 +260,14 @@
</CellTemplate>
</TemplateColumn>
</Columns>
<NoRecordsContent>
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Text" Class="my-4">موردی یافت نشد.</MudAlert>
</NoRecordsContent>
</MudDataGrid>
</MudCardContent>
</MudCard>
}
</MudContainer>
</div>
@code {
private readonly List<DiscountOrderDto> _orders = new();
@@ -230,6 +282,8 @@
private long _totalSales;
private long _totalDiscount;
private long _averageOrder;
private int _uniqueCustomers;
private int _totalProductsSold;
private string[] _salesLabels = Array.Empty<string>();
private List<ChartSeries> _salesSeries = new();
@@ -237,6 +291,9 @@
private string[] _topProductLabels = Array.Empty<string>();
private List<ChartSeries> _topProductSeries = new();
private string[] _statusChartLabels = Array.Empty<string>();
private List<ChartSeries> _statusChartSeries = new();
protected override async Task OnInitializedAsync()
{
await LoadReports();
@@ -255,32 +312,82 @@
try
{
// ۱) گزارش سرور-ساید (خلاصه + نمودار روزانه + محصولات پرفروش)
DiscountSalesReportDto? report = null;
if (_fromDate.HasValue && _toDate.HasValue)
{
try
{
report = await DiscountOrderService.GetSalesReportAsync(_fromDate.Value, _toDate.Value);
}
catch
{
// اگر RPC سرور ساید پیاده‌سازی نشده بود، fallback به کلاینت‌ساید
}
}
// ۲) لیست سفارشات برای جدول و نمودار وضعیت
var filter = new OrderFilterDto
{
Status = _statusFilter,
FromDate = _fromDate,
ToDate = _toDate,
PageNumber = 1,
PageSize = 200
};
var (orders, _, _) = await DiscountOrderService.GetOrdersAsync(filter);
_orders.AddRange(orders);
BuildSummary();
BuildSalesChart();
await BuildTopProductsChart();
if (report?.Summary != null && report.Summary.TotalOrders > 0)
{
// استفاده از گزارش سرور-ساید
_totalOrders = report.Summary.TotalOrders;
_totalSales = report.Summary.TotalGatewayPaid;
_totalDiscount = report.Summary.TotalDiscountUsed;
_averageOrder = report.Summary.AverageOrderValue;
_uniqueCustomers = report.Summary.UniqueCustomers;
_totalProductsSold = report.Summary.TotalProductsSold;
// نمودار روند فروش از periods
if (report.Periods.Count > 0)
{
_salesLabels = report.Periods.Select(p => p.PeriodLabel).ToArray();
_salesSeries = new List<ChartSeries>
{
new ChartSeries { Name = "مبلغ نهایی", Data = report.Periods.Select(p => (double)p.GatewayPaid).ToArray() },
new ChartSeries { Name = "تخفیف", Data = report.Periods.Select(p => (double)p.DiscountUsed).ToArray() }
};
}
// نمودار محصولات پرفروش
if (report.TopProducts.Count > 0)
{
_topProductLabels = report.TopProducts.Take(5).Select(x => x.ProductTitle).ToArray();
_topProductSeries = new List<ChartSeries>
{
new ChartSeries
{
Name = "مبلغ فروش",
Data = report.TopProducts.Take(5).Select(x => (double)x.TotalRevenue).ToArray()
}
};
}
}
else
{
// Fallback: محاسبه کلاینت‌ساید
BuildSummary();
BuildSalesChart();
BuildTopProductsChartFromOrders();
}
// نمودار وضعیت همیشه از لیست سفارشات
BuildStatusChart();
}
catch (Exception ex)
{
Snackbar.Add($"خطا در بارگذاری گزارش فروش: {ex.Message}", Severity.Error);
_orders.Clear();
_totalOrders = 0;
_totalSales = 0;
_totalDiscount = 0;
_averageOrder = 0;
_salesLabels = Array.Empty<string>();
_salesSeries = new List<ChartSeries>();
_topProductLabels = Array.Empty<string>();
_topProductSeries = new List<ChartSeries>();
ClearData();
}
finally
{
@@ -288,6 +395,23 @@
}
}
private void ClearData()
{
_orders.Clear();
_totalOrders = 0;
_totalSales = 0;
_totalDiscount = 0;
_averageOrder = 0;
_uniqueCustomers = 0;
_totalProductsSold = 0;
_salesLabels = Array.Empty<string>();
_salesSeries = new List<ChartSeries>();
_topProductLabels = Array.Empty<string>();
_topProductSeries = new List<ChartSeries>();
_statusChartLabels = Array.Empty<string>();
_statusChartSeries = new List<ChartSeries>();
}
private void BuildSummary()
{
_totalOrders = _orders.Count;
@@ -312,7 +436,7 @@
.ToList();
_salesLabels = grouped
.Select(g => g.Key.ToString("yyyy/MM/dd"))
.Select(g => g.Key.MiladiToJalali())
.ToArray();
var totalSeries = grouped
@@ -330,73 +454,41 @@
};
}
private async Task BuildTopProductsChart()
private void BuildStatusChart()
{
_topProductLabels = Array.Empty<string>();
_topProductSeries = new List<ChartSeries>();
if (_orders.Count == 0)
return;
// برای جلوگیری از فشار بیش‌ازحد، فقط روی 50 سفارش اول کار می‌کنیم
var sampleOrders = _orders
.Where(o => o.Created.HasValue)
.OrderByDescending(o => o.Created)
.Take(50)
.ToList();
var productTotals = new Dictionary<long, (string Name, long Amount)>();
foreach (var order in sampleOrders)
{
DiscountOrderDetailsDto? details;
try
{
details = await DiscountOrderService.GetByIdAsync(order.Id);
}
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);
}
}
_statusChartLabels = Array.Empty<string>();
_statusChartSeries = new List<ChartSeries>();
return;
}
if (productTotals.Count == 0)
return;
var top = productTotals.Values
.OrderByDescending(x => x.Amount)
.Take(5)
var groups = _orders
.GroupBy(x => x.Status)
.OrderBy(g => (int)g.Key)
.ToList();
_topProductLabels = top
.Select(x => x.Name)
_statusChartLabels = groups
.Select(g => GetStatusText(g.Key))
.ToArray();
_topProductSeries = new List<ChartSeries>
_statusChartSeries = new List<ChartSeries>
{
new ChartSeries
{
Name = "مبلغ فروش",
Data = top.Select(x => (double)x.Amount).ToArray()
Name = "تعداد سفارش‌ها",
Data = groups.Select(g => (double)g.Count()).ToArray()
}
};
}
private void BuildTopProductsChartFromOrders()
{
// Fallback ساده بدون N+1 — فقط از اطلاعات لیست سفارشات
_topProductLabels = Array.Empty<string>();
_topProductSeries = new List<ChartSeries>();
}
private Color GetStatusColor(OrderStatus status)
{
return status switch
@@ -436,20 +528,22 @@
}
var sb = new StringBuilder();
sb.AppendLine("OrderNumber,Created,ItemsCount,GatewayAmount,DiscountBalanceUsed,Status");
sb.AppendLine("شناسه,نام کاربر,موبایل,تاریخ ثبت,تعداد آیتم,مبلغ نهایی,تخفیف,وضعیت");
foreach (var o in _orders.Where(o => o.Created.HasValue).OrderBy(o => o.Created))
{
sb.AppendLine(string.Join(",",
EscapeCsv(o.OrderNumber),
o.Created?.ToString("yyyy-MM-dd HH:mm") ?? "-",
o.Id,
EscapeCsv(o.UserFullName),
EscapeCsv(o.UserMobile),
o.Created?.MiladiToJalaliWithTime() ?? "-",
o.ItemsCount,
o.GatewayAmount,
o.DiscountBalanceUsed,
GetStatusText(o.Status)));
}
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
var bytes = Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes(sb.ToString())).ToArray();
var base64 = Convert.ToBase64String(bytes);
var filename = $"discount-sales-{DateTime.Now:yyyyMMddHHmmss}.csv";
@@ -467,7 +561,7 @@
var sb = new StringBuilder();
sb.AppendLine("گزارش فروش فروشگاه تخفیفی");
sb.AppendLine($"بازه: از {_fromDate:yyyy/MM/dd} تا {_toDate:yyyy/MM/dd}");
sb.AppendLine($"بازه: از {_fromDate?.MiladiToJalali() ?? "-"} تا {_toDate?.MiladiToJalali() ?? "-"}");
sb.AppendLine();
sb.AppendLine("خلاصه:");
@@ -478,15 +572,15 @@
sb.AppendLine();
sb.AppendLine("جزئیات سفارش‌ها:");
sb.AppendLine("شماره سفارش | تاریخ | تعداد آیتم | مبلغ نهایی | تخفیف | وضعیت");
sb.AppendLine("شناسه | نام کاربر | تاریخ | تعداد آیتم | مبلغ نهایی | تخفیف | وضعیت");
foreach (var o in _orders.Where(o => o.Created.HasValue).OrderBy(o => o.Created))
{
sb.AppendLine(
$"{o.OrderNumber} | {o.Created?.ToString("yyyy/MM/dd HH:mm") ?? "-"} | {o.ItemsCount} | {o.GatewayAmount:N0} | {o.DiscountBalanceUsed:N0} | {GetStatusText(o.Status)}");
$"{o.Id} | {o.UserFullName} | {o.Created?.MiladiToJalaliWithTime() ?? "-"} | {o.ItemsCount} | {o.GatewayAmount:N0} | {o.DiscountBalanceUsed:N0} | {GetStatusText(o.Status)}");
}
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
var bytes = Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes(sb.ToString())).ToArray();
var base64 = Convert.ToBase64String(bytes);
var filename = $"discount-sales-{DateTime.Now:yyyyMMddHHmmss}.txt";
+322 -117
View File
@@ -1,4 +1,5 @@
@attribute [Route(RouteConstance.HomePage)]
@attribute [Route("/dashboard/overview")]
@using CMSMicroservice.Protobuf.Protos.User
@using CMSMicroservice.Protobuf.Protos.UserOrder
@@ -6,109 +7,237 @@
@using CMSMicroservice.Protobuf.Protos.Products
@using CMSMicroservice.Protobuf.Protos.Package
@using CMSMicroservice.Protobuf.Protos.Category
@using CMSMicroservice.Protobuf.Protos.Commission
@using CMSMicroservice.Protobuf.Protos.ClubMembership
@inject UserContract.UserContractClient UserContract
@inject UserOrderContract.UserOrderContractClient UserOrderContract
@inject ProductsContract.ProductsContractClient ProductsContract
@inject PackageContract.PackageContractClient PackageContract
@inject CategoryContract.CategoryContractClient CategoryContract
@inject CommissionContract.CommissionContractClient CommissionClient
@inject ClubMembershipContract.ClubMembershipContractClient ClubClient
<MudStack Spacing="3">
<MudText Typo="Typo.h5">داشبورد مدیریت</MudText>
<MudGrid GutterSize="3">
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4">
<MudStack Spacing="1">
<MudText Typo="Typo.subtitle2">تعداد کاربران</MudText>
<MudText Typo="Typo.h5">@_totalUsers.ToString("N0")</MudText>
@* ── عنوان و وضعیت بارگذاری ── *@
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.h5">داشبورد مدیریت</MudText>
@if (_loading)
{
<MudProgressCircular Color="Color.Primary" Size="Size.Small" Indeterminate="true" />
}
</MudStack>
@* ── آمار کلی فروشگاه ── *@
<MudGrid Spacing="2">
<MudItem xs="6" sm="4" md="2">
<MudPaper Class="pa-3 rounded-lg" Elevation="0" Outlined="true">
<MudStack Spacing="1" AlignItems="AlignItems.Start">
<MudIcon Icon="@Icons.Material.Filled.People" Color="Color.Primary" Size="Size.Small" />
<MudText Typo="Typo.caption" Color="Color.Secondary">کاربران</MudText>
<MudText Typo="Typo.h6">@_totalUsers.ToString("N0")</MudText>
</MudStack>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4">
<MudStack Spacing="1">
<MudText Typo="Typo.subtitle2">تعداد محصولات</MudText>
<MudText Typo="Typo.h5">@_totalProducts.ToString("N0")</MudText>
<MudItem xs="6" sm="4" md="2">
<MudPaper Class="pa-3 rounded-lg" Elevation="0" Outlined="true">
<MudStack Spacing="1" AlignItems="AlignItems.Start">
<MudIcon Icon="@Icons.Material.Filled.ShoppingBag" Color="Color.Info" Size="Size.Small" />
<MudText Typo="Typo.caption" Color="Color.Secondary">محصولات</MudText>
<MudText Typo="Typo.h6">@_totalProducts.ToString("N0")</MudText>
</MudStack>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4">
<MudStack Spacing="1">
<MudText Typo="Typo.subtitle2">تعداد پکیج‌ها</MudText>
<MudText Typo="Typo.h5">@_totalPackages.ToString("N0")</MudText>
<MudItem xs="6" sm="4" md="2">
<MudPaper Class="pa-3 rounded-lg" Elevation="0" Outlined="true">
<MudStack Spacing="1" AlignItems="AlignItems.Start">
<MudIcon Icon="@Icons.Material.Filled.Inventory2" Color="Color.Secondary" Size="Size.Small" />
<MudText Typo="Typo.caption" Color="Color.Secondary">پکیج‌ها</MudText>
<MudText Typo="Typo.h6">@_totalPackages.ToString("N0")</MudText>
</MudStack>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4">
<MudStack Spacing="1">
<MudText Typo="Typo.subtitle2">تعداد دسته‌بندی‌ها</MudText>
<MudText Typo="Typo.h5">@_totalCategories.ToString("N0")</MudText>
<MudItem xs="6" sm="4" md="2">
<MudPaper Class="pa-3 rounded-lg" Elevation="0" Outlined="true">
<MudStack Spacing="1" AlignItems="AlignItems.Start">
<MudIcon Icon="@Icons.Material.Filled.Category" Color="Color.Tertiary" Size="Size.Small" />
<MudText Typo="Typo.caption" Color="Color.Secondary">دسته‌بندی‌ها</MudText>
<MudText Typo="Typo.h6">@_totalCategories.ToString("N0")</MudText>
</MudStack>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4">
<MudStack Spacing="1">
<MudText Typo="Typo.subtitle2">تعداد سفارش‌ها</MudText>
<MudText Typo="Typo.h5">@_totalOrders.ToString("N0")</MudText>
<MudItem xs="6" sm="4" md="2">
<MudPaper Class="pa-3 rounded-lg" Elevation="0" Outlined="true">
<MudStack Spacing="1" AlignItems="AlignItems.Start">
<MudIcon Icon="@Icons.Material.Filled.Receipt" Color="Color.Success" Size="Size.Small" />
<MudText Typo="Typo.caption" Color="Color.Secondary">سفارش‌ها</MudText>
<MudText Typo="Typo.h6">@_totalOrders.ToString("N0")</MudText>
</MudStack>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4">
<MudStack Spacing="1">
<MudText Typo="Typo.subtitle2">سفارش‌های پرداخت‌شده</MudText>
<MudText Typo="Typo.h5">@_paidOrders.ToString("N0")</MudText>
</MudStack>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4">
<MudStack Spacing="1">
<MudText Typo="Typo.subtitle2">جمع مبلغ سفارش‌های پرداخت‌شده</MudText>
<MudText Typo="Typo.h6">@_totalPaidAmount.ToString("N0") تومان</MudText>
<MudItem xs="6" sm="4" md="2">
<MudPaper Class="pa-3 rounded-lg" Elevation="0" Outlined="true">
<MudStack Spacing="1" AlignItems="AlignItems.Start">
<MudIcon Icon="@Icons.Material.Filled.Paid" Color="Color.Warning" Size="Size.Small" />
<MudText Typo="Typo.caption" Color="Color.Secondary">مبلغ پرداخت‌شده</MudText>
<MudText Typo="Typo.h6">@_totalPaidAmount.ToString("N0")</MudText>
</MudStack>
</MudPaper>
</MudItem>
</MudGrid>
<MudPaper Class="pa-4">
<MudStack Spacing="2">
<MudText Typo="Typo.subtitle2">آخرین سفارش‌های پرداخت‌شده</MudText>
@if (_lastOrders.Count == 0)
{
<MudText Typo="Typo.body2">سفارشی ثبت نشده است.</MudText>
}
else
{
<MudTable Items="_lastOrders" Dense="true">
<HeaderContent>
<MudTh>شناسه سفارش</MudTh>
<MudTh>کاربر</MudTh>
<MudTh>مبلغ</MudTh>
<MudTh>تاریخ پرداخت</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Id</MudTd>
<MudTd>@context.UserFullName (@context.UserNationalCode)</MudTd>
<MudTd>@context.Amount.ToString("N0")</MudTd>
<MudTd>@context.PaymentDate.ToDateTime().MiladiToJalali()</MudTd>
</RowTemplate>
</MudTable>
}
@* ── کمیسیون هفته جاری ── *@
<MudPaper Class="pa-4 rounded-lg" Outlined="true">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-3">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.AccountBalanceWallet" Color="Color.Primary" />
<MudText Typo="Typo.subtitle1">کمیسیون هفته جاری</MudText>
</MudStack>
<MudButton Variant="Variant.Text" Color="Color.Primary" Href="/commission/dashboard" Size="Size.Small">
جزئیات بیشتر
</MudButton>
</MudStack>
<MudGrid Spacing="2">
<MudItem xs="12" sm="6" md="3">
<MudStack Spacing="0">
<MudText Typo="Typo.caption" Color="Color.Secondary">هفته جاری</MudText>
<MudText Typo="Typo.h6">@(_currentWeekDisplayName ?? "—")</MudText>
</MudStack>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudStack Spacing="0">
<MudText Typo="Typo.caption" Color="Color.Secondary">مبلغ کل استخر</MudText>
<MudText Typo="Typo.h6" Color="Color.Primary">
@(_currentPool?.TotalPoolAmount.ToString("N0") ?? "0") ریال
</MudText>
</MudStack>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudStack Spacing="0">
<MudText Typo="Typo.caption" Color="Color.Secondary">مجموع موجودی‌ها</MudText>
<MudText Typo="Typo.h6">@(_currentPool?.TotalBalances.ToString("N0") ?? "0")</MudText>
</MudStack>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudStack Spacing="0">
<MudText Typo="Typo.caption" Color="Color.Secondary">وضعیت</MudText>
<MudChip T="string" Color="@(_currentPool?.IsCalculated == true ? Color.Success : Color.Warning)"
Size="Size.Small">
@(_currentPool?.IsCalculated == true ? "محاسبه شده" : "در انتظار")
</MudChip>
</MudStack>
</MudItem>
</MudGrid>
</MudPaper>
@* ── آمار باشگاه ── *@
<MudPaper Class="pa-4 rounded-lg" Outlined="true">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-3">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.CardMembership" Color="Color.Secondary" />
<MudText Typo="Typo.subtitle1">عضویت باشگاه</MudText>
</MudStack>
<MudButton Variant="Variant.Text" Color="Color.Primary" Href="/club/members" Size="Size.Small">
مشاهده اعضا
</MudButton>
</MudStack>
<MudGrid Spacing="2">
<MudItem xs="12" sm="4">
<MudStack Spacing="0">
<MudText Typo="Typo.caption" Color="Color.Secondary">کل اعضا</MudText>
<MudText Typo="Typo.h6" Color="Color.Primary">@_totalClubMembers.ToString("N0")</MudText>
</MudStack>
</MudItem>
<MudItem xs="12" sm="4">
<MudStack Spacing="0">
<MudText Typo="Typo.caption" Color="Color.Secondary">فعال</MudText>
<MudText Typo="Typo.h6" Color="Color.Success">@_activeClubMembers.ToString("N0")</MudText>
</MudStack>
</MudItem>
<MudItem xs="12" sm="4">
<MudStack Spacing="0">
<MudText Typo="Typo.caption" Color="Color.Secondary">غیرفعال</MudText>
<MudText Typo="Typo.h6" Color="Color.Warning">@_inactiveClubMembers.ToString("N0")</MudText>
</MudStack>
</MudItem>
</MudGrid>
</MudPaper>
@* ── آخرین سفارش‌ها ── *@
<MudPaper Class="pa-4 rounded-lg" Outlined="true">
<MudText Typo="Typo.subtitle1" Class="mb-2">آخرین سفارش‌های پرداخت‌شده</MudText>
@if (_lastOrders.Count == 0)
{
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Text">سفارشی ثبت نشده است.</MudAlert>
}
else
{
<MudTable Items="_lastOrders" Dense="true" Hover="true" Elevation="0">
<HeaderContent>
<MudTh>شناسه</MudTh>
<MudTh>کاربر</MudTh>
<MudTh>مبلغ</MudTh>
<MudTh>تاریخ پرداخت</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Id</MudTd>
<MudTd>@context.UserFullName</MudTd>
<MudTd>@context.Amount.ToString("N0")</MudTd>
<MudTd>@(context.PaymentDate != null ? context.PaymentDate.ToDateTime().MiladiToJalali() : "—")</MudTd>
</RowTemplate>
</MudTable>
}
</MudPaper>
@* ── دسترسی سریع ── *@
<MudPaper Class="pa-4 rounded-lg" Outlined="true">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="mb-3">
<MudIcon Icon="@Icons.Material.Filled.Speed" Color="Color.Info" />
<MudText Typo="Typo.subtitle1">دسترسی سریع</MudText>
</MudStack>
<MudGrid Spacing="2">
<MudItem xs="6" md="4">
<MudButton Variant="Variant.Outlined" Color="Color.Primary" FullWidth="true"
Href="/commission/dashboard" StartIcon="@Icons.Material.Filled.AccountBalanceWallet">
داشبورد کمیسیون
</MudButton>
</MudItem>
<MudItem xs="6" md="4">
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" FullWidth="true"
Href="/commission/payouts" StartIcon="@Icons.Material.Filled.Payments">
واریزی‌ها
</MudButton>
</MudItem>
<MudItem xs="6" md="4">
<MudButton Variant="Variant.Outlined" Color="Color.Warning" FullWidth="true"
Href="/commission/withdrawals" StartIcon="@Icons.Material.Filled.RequestQuote">
درخواست‌های برداشت
</MudButton>
</MudItem>
<MudItem xs="6" md="4">
<MudButton Variant="Variant.Outlined" Color="Color.Info" FullWidth="true"
Href="/network/tree" StartIcon="@Icons.Material.Filled.AccountTree">
درخت شبکه
</MudButton>
</MudItem>
<MudItem xs="6" md="4">
<MudButton Variant="Variant.Outlined" Color="Color.Success" FullWidth="true"
Href="/network/balances" StartIcon="@Icons.Material.Filled.AccountBalance">
گزارش موجودی‌ها
</MudButton>
</MudItem>
<MudItem xs="6" md="4">
<MudButton Variant="Variant.Outlined" Color="Color.Tertiary" FullWidth="true"
Href="/club/members" StartIcon="@Icons.Material.Filled.Groups">
مدیریت باشگاه
</MudButton>
</MudItem>
</MudGrid>
</MudPaper>
</MudStack>
@code {
private bool _loading = true;
private long _totalUsers;
private long _totalOrders;
private long _paidOrders;
@@ -118,63 +247,139 @@
private long _totalCategories;
private readonly List<GetUserOrderResponse> _lastOrders = new();
// Commission
private string? _currentWeekDisplayName;
private long _currentWeekDefinitionId;
private GetWeeklyCommissionPoolResponse? _currentPool;
// Club
private long _totalClubMembers;
private long _activeClubMembers;
private long _inactiveClubMembers;
protected override async Task OnInitializedAsync()
{
// کاربران
var userRequest = new GetAllUserByFilterRequest
_loading = true;
try
{
PaginationState = new() { PageNumber = 1, PageSize = 1 },
Filter = new()
};
var userResult = await UserContract.GetAllUserByFilterAsync(userRequest);
_totalUsers = userResult?.MetaData?.TotalCount ?? 0;
// سفارش‌ها
var orderRequest = new GetAllUserOrderByFilterRequest
{
PaginationState = new() { PageNumber = 1, PageSize = 10 },
};
var orderResult = await UserOrderContract.GetAllUserOrderByFilterAsync(orderRequest);
if (orderResult != null && orderResult.Models != null)
{
_totalOrders = orderResult.MetaData?.TotalCount ?? 0;
_paidOrders = orderResult.Models.Count(m => m.PaymentStatus==PaymentStatus.Success);
_totalPaidAmount = orderResult.Models
.Where(m => m.PaymentStatus==PaymentStatus.Success)
.Aggregate(0L, (sum, m) => sum + m.Amount);
foreach (var item in orderResult.Models.OrderByDescending(m => m.PaymentDate).Take(5))
// Load all data in parallel for speed
var tasks = new List<Task>
{
var full = await UserOrderContract.GetUserOrderAsync(new GetUserOrderRequest { Id = item.Id });
_lastOrders.Add(full);
LoadCmsStats(),
LoadCommissionStats(),
LoadClubStats(),
LoadRecentOrders()
};
await Task.WhenAll(tasks);
}
catch { /* Individual loaders handle their own errors */ }
finally
{
_loading = false;
}
}
private async Task LoadCmsStats()
{
try
{
var userCall = UserContract.GetAllUserByFilterAsync(new GetAllUserByFilterRequest
{
PaginationState = new() { PageNumber = 1, PageSize = 1 },
Filter = new()
});
var productCall = ProductsContract.GetAllProductsByFilterAsync(new GetAllProductsByFilterRequest
{
PaginationState = new() { PageNumber = 1, PageSize = 1 },
Filter = new()
});
var packageCall = PackageContract.GetAllPackageByFilterAsync(new GetAllPackageByFilterRequest
{
PaginationState = new() { PageNumber = 1, PageSize = 1 },
Filter = new()
});
var categoryCall = CategoryContract.GetAllCategoryByFilterAsync(new GetAllCategoryByFilterRequest
{
PaginationState = new() { PageNumber = 1, PageSize = 1 },
Filter = new()
});
await Task.WhenAll(userCall.ResponseAsync, productCall.ResponseAsync, packageCall.ResponseAsync, categoryCall.ResponseAsync);
_totalUsers = (await userCall)?.MetaData?.TotalCount ?? 0;
_totalProducts = (await productCall)?.MetaData?.TotalCount ?? 0;
_totalPackages = (await packageCall)?.MetaData?.TotalCount ?? 0;
_totalCategories = (await categoryCall)?.MetaData?.TotalCount ?? 0;
}
catch { }
}
private async Task LoadCommissionStats()
{
try
{
var availableWeeks = await CommissionClient.GetAvailableWeeksAsync(new GetAvailableWeeksRequest
{
FutureWeeksCount = 0,
PastWeeksCount = 0
});
if (availableWeeks?.CurrentWeek != null)
{
_currentWeekDefinitionId = availableWeeks.CurrentWeek.WeekDefinitionId;
_currentWeekDisplayName = availableWeeks.CurrentWeek.DisplayName;
_currentPool = await CommissionClient.GetWeeklyCommissionPoolAsync(new GetWeeklyCommissionPoolRequest
{
WeekDefinitionId = _currentWeekDefinitionId
});
}
}
catch { }
}
// محصولات
var productRequest = new GetAllProductsByFilterRequest
private async Task LoadClubStats()
{
try
{
PaginationState = new() { PageNumber = 1, PageSize = 1 },
Filter = new()
};
var productResult = await ProductsContract.GetAllProductsByFilterAsync(productRequest);
_totalProducts = productResult?.MetaData?.TotalCount ?? 0;
var allReq = ClubClient.GetAllClubMembershipsAsync(new GetAllClubMembershipsRequest { PageIndex = 1, PageSize = 1 });
var activeReq = ClubClient.GetAllClubMembershipsAsync(new GetAllClubMembershipsRequest { PageIndex = 1, PageSize = 1, IsActive = true });
// پکیج‌ها
var packageRequest = new GetAllPackageByFilterRequest
{
PaginationState = new() { PageNumber = 1, PageSize = 1 },
Filter = new()
};
var packageResult = await PackageContract.GetAllPackageByFilterAsync(packageRequest);
_totalPackages = packageResult?.MetaData?.TotalCount ?? 0;
await Task.WhenAll(allReq.ResponseAsync, activeReq.ResponseAsync);
// دسته‌بندی‌ها
var categoryRequest = new GetAllCategoryByFilterRequest
_totalClubMembers = (await allReq).MetaData?.TotalCount ?? 0;
_activeClubMembers = (await activeReq).MetaData?.TotalCount ?? 0;
_inactiveClubMembers = _totalClubMembers - _activeClubMembers;
}
catch { }
}
private async Task LoadRecentOrders()
{
try
{
PaginationState = new() { PageNumber = 1, PageSize = 1 },
Filter = new()
};
var categoryResult = await CategoryContract.GetAllCategoryByFilterAsync(categoryRequest);
_totalCategories = categoryResult?.MetaData?.TotalCount ?? 0;
var orderResult = await UserOrderContract.GetAllUserOrderByFilterAsync(new GetAllUserOrderByFilterRequest
{
PaginationState = new() { PageNumber = 1, PageSize = 5 },
});
if (orderResult?.Models != null)
{
_totalOrders = orderResult.MetaData?.TotalCount ?? 0;
_paidOrders = orderResult.Models.Count(m => m.PaymentStatus == PaymentStatus.Success);
_totalPaidAmount = orderResult.Models
.Where(m => m.PaymentStatus == PaymentStatus.Success)
.Sum(m => m.Amount);
foreach (var item in orderResult.Models.OrderByDescending(m => m.PaymentDate).Take(5))
{
try
{
var full = await UserOrderContract.GetUserOrderAsync(new GetUserOrderRequest { Id = item.Id });
_lastOrders.Add(full);
}
catch { }
}
}
}
catch { }
}
}
@@ -14,8 +14,8 @@
Variant="Variant.Outlined"
Required="true"
RequiredError="نوع محصول الزامی است">
<MudSelectItem Value="InventoryProductType.RegularProduct">محصول عادی</MudSelectItem>
<MudSelectItem Value="InventoryProductType.DiscountProduct">محصول تخفیفی</MudSelectItem>
<MudSelectItem T="InventoryProductType" Value="InventoryProductType.RegularProduct">محصول عادی</MudSelectItem>
<MudSelectItem T="InventoryProductType" Value="InventoryProductType.DiscountProduct">محصول تخفیفی</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12">
@@ -44,7 +44,7 @@
RequiredError="انبار الزامی است">
@foreach (var wh in Warehouses)
{
<MudSelectItem Value="@((long?)wh.Id)">@wh.Name</MudSelectItem>
<MudSelectItem T="long?" Value="@((long?)wh.Id)">@wh.Name</MudSelectItem>
}
</MudSelect>
</MudItem>
@@ -38,9 +38,9 @@
Variant="Variant.Outlined"
Required="true"
RequiredError="نوع خسارت الزامی است">
<MudSelectItem Value="StockMovementType.Loss">خسارت عمومی</MudSelectItem>
<MudSelectItem Value="StockMovementType.Damaged">آسیب‌دیده</MudSelectItem>
<MudSelectItem Value="StockMovementType.Expired">منقضی شده</MudSelectItem>
<MudSelectItem T="StockMovementType" Value="StockMovementType.Loss">خسارت عمومی</MudSelectItem>
<MudSelectItem T="StockMovementType" Value="StockMovementType.Damaged">آسیب‌دیده</MudSelectItem>
<MudSelectItem T="StockMovementType" Value="StockMovementType.Expired">منقضی شده</MudSelectItem>
</MudSelect>
@if (_quantity > 0 && _quantity <= Item.Quantity)
@@ -4,7 +4,7 @@
@using CMSMicroservice.Protobuf.Protos.Inventory
@using BackOffice.Pages.Inventory.Components
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="mt-4">
<div>
<MudText Typo="Typo.h4" Class="mb-4">مدیریت انبار</MudText>
@* Filters Section *@
@@ -18,7 +18,7 @@
Clearable="true">
@foreach (var wh in _warehouses)
{
<MudSelectItem Value="@((long?)wh.Id)">@wh.Name</MudSelectItem>
<MudSelectItem T="long?" Value="@((long?)wh.Id)">@wh.Name</MudSelectItem>
}
</MudSelect>
</MudItem>
@@ -27,9 +27,9 @@
Label="نوع محصول"
Variant="Variant.Outlined"
Margin="Margin.Dense">
<MudSelectItem Value="ProductType.Unspecified">همه</MudSelectItem>
<MudSelectItem Value="ProductType.RegularProduct">محصول عادی</MudSelectItem>
<MudSelectItem Value="ProductType.DiscountProduct">محصول تخفیفی</MudSelectItem>
<MudSelectItem T="ProductType" Value="ProductType.Unspecified">همه</MudSelectItem>
<MudSelectItem T="ProductType" Value="ProductType.RegularProduct">محصول عادی</MudSelectItem>
<MudSelectItem T="ProductType" Value="ProductType.DiscountProduct">محصول تخفیفی</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12" sm="6" md="3">
@@ -94,11 +94,17 @@
ServerData="@(new Func<GridState<InventoryItemDto>, Task<GridData<InventoryItemDto>>>(ServerReload))"
Hover="true"
@ref="_gridData"
Height="60vh">
Height="calc(100vh - 240px)">
<ToolBarContent>
<MudText Typo="Typo.h6">لیست موجودی انبار</MudText>
<MudSpacer />
<MudStack Row="true" Spacing="2">
<MudButton Variant="Variant.Outlined"
Color="Color.Info"
OnClick="ExportToExcel"
StartIcon="@Icons.Material.Filled.FileDownload">
خروجی Excel
</MudButton>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
OnClick="OpenAddStockDialog"
@@ -206,11 +212,14 @@
</CellTemplate>
</TemplateColumn>
</Columns>
<NoRecordsContent>
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Text" Class="my-4">موردی یافت نشد.</MudAlert>
</NoRecordsContent>
<PagerContent>
<MudDataGridPager T="InventoryItemDto"
PageSizeOptions="@(new int[] { 20, 50, 100 })"
InfoFormat="سطر {first_item} تا {last_item} از {all_items}"
RowsPerPageString="تعداد سطرهای صفحه" />
RowsPerPageString="تعداد در صفحه" />
</PagerContent>
</MudDataGrid>
</MudContainer>
</div>
@@ -4,12 +4,15 @@ using Google.Protobuf.WellKnownTypes;
using CMSMicroservice.Protobuf.Protos.Inventory;
using BackOffice.Pages.Inventory.Components;
using BackOffice.Common.Utilities;
using Microsoft.JSInterop;
using System.Text;
namespace BackOffice.Pages.Inventory;
public partial class InventoryMainPage
{
[Inject] public InventoryContract.InventoryContractClient InventoryContract { get; set; } = default!;
[Inject] public IJSRuntime JsRuntime { get; set; } = default!;
private MudDataGrid<InventoryItemDto>? _gridData;
private List<WarehouseDto> _warehouses = new();
@@ -248,4 +251,66 @@ public partial class InventoryMainPage
{
Navigation.NavigateTo($"{RouteConstance.InventoryMovements}?itemId={item.Id}&productName={item.ProductTitle}");
}
private async Task ExportToExcel()
{
try
{
var request = new GetAllInventoryItemsRequest
{
Page = 1,
PageSize = 1000,
ProductType = _filterProductType,
Search = _searchTerm ?? string.Empty
};
if (_filterWarehouseId.HasValue)
{
request.WarehouseId = _filterWarehouseId.Value;
}
var result = await InventoryContract.GetAllInventoryItemsAsync(request);
if (result?.Items == null || !result.Items.Any())
{
Snackbar.Add("داده‌ای برای خروجی وجود ندارد.", Severity.Info);
return;
}
var sb = new StringBuilder();
sb.AppendLine("شناسه,محصول,انبار,موجودی,رزرو,قابل فروش,حد هشدار,وضعیت");
foreach (var i in result.Items)
{
sb.AppendLine(string.Join(",",
i.Id,
EscapeCsv(i.ProductTitle),
EscapeCsv(i.WarehouseName),
i.Quantity,
i.ReservedQuantity,
i.AvailableQuantity,
i.LowStockThreshold,
i.IsLowStock ? "کمبود" : "عادی"));
}
var bytes = Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes(sb.ToString())).ToArray();
var base64 = Convert.ToBase64String(bytes);
var filename = $"inventory-{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);
}
}
private static string EscapeCsv(string value)
{
if (string.IsNullOrEmpty(value)) return "";
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
return $"\"{value.Replace("\"", "\"\"")}\"";
return value;
}
}
@@ -3,7 +3,7 @@
@using CMSMicroservice.Protobuf.Protos.Inventory
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="mt-4">
<div>
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-4">
<MudStack Spacing="0">
<MudText Typo="Typo.h4">
@@ -33,7 +33,7 @@
Clearable="true">
@foreach (var wh in _warehouses)
{
<MudSelectItem Value="@((long?)wh.Id)">@wh.Name</MudSelectItem>
<MudSelectItem T="long?" Value="@((long?)wh.Id)">@wh.Name</MudSelectItem>
}
</MudSelect>
</MudItem>
@@ -42,9 +42,9 @@
Label="نوع محصول"
Variant="Variant.Outlined"
Margin="Margin.Dense">
<MudSelectItem Value="ProductType.Unspecified">همه</MudSelectItem>
<MudSelectItem Value="ProductType.RegularProduct">محصول عادی</MudSelectItem>
<MudSelectItem Value="ProductType.DiscountProduct">محصول تخفیفی</MudSelectItem>
<MudSelectItem T="ProductType" Value="ProductType.Unspecified">همه</MudSelectItem>
<MudSelectItem T="ProductType" Value="ProductType.RegularProduct">محصول عادی</MudSelectItem>
<MudSelectItem T="ProductType" Value="ProductType.DiscountProduct">محصول تخفیفی</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12" sm="6" md="4" Class="d-flex align-end">
@@ -73,7 +73,7 @@
}
else
{
<MudDataGrid T="InventoryItemDto" Items="_items" Hover="true" Height="60vh">
<MudDataGrid T="InventoryItemDto" Items="_items" Hover="true" Height="calc(100vh - 240px)">
<Columns>
<PropertyColumn Property="x => x.Id" Title="شناسه" />
@@ -135,12 +135,15 @@
</CellTemplate>
</TemplateColumn>
</Columns>
<NoRecordsContent>
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Text" Class="my-4">موردی یافت نشد.</MudAlert>
</NoRecordsContent>
<PagerContent>
<MudDataGridPager T="InventoryItemDto"
PageSizeOptions="@(new int[] { 20, 50, 100 })"
InfoFormat="سطر {first_item} تا {last_item} از {all_items}"
RowsPerPageString="تعداد سطرهای صفحه" />
RowsPerPageString="تعداد در صفحه" />
</PagerContent>
</MudDataGrid>
}
</MudContainer>
</div>
@@ -4,7 +4,7 @@
@using CMSMicroservice.Protobuf.Protos.Inventory
@using Google.Protobuf.WellKnownTypes
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="mt-4">
<div>
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-4">
<MudStack Spacing="0">
<MudText Typo="Typo.h4">تاریخچه تغییرات موجودی</MudText>
@@ -29,20 +29,20 @@
Label="نوع تغییر"
Variant="Variant.Outlined"
Margin="Margin.Dense">
<MudSelectItem Value="StockMovementType.MovementTypeUnspecified">همه</MudSelectItem>
<MudSelectItem Value="StockMovementType.InitialStock">موجودی اولیه</MudSelectItem>
<MudSelectItem Value="StockMovementType.Restock">ورود کالا</MudSelectItem>
<MudSelectItem Value="StockMovementType.Return">برگشت از مشتری</MudSelectItem>
<MudSelectItem Value="StockMovementType.Sale">فروش</MudSelectItem>
<MudSelectItem Value="StockMovementType.AdjustmentIncrease">افزایش موجودی</MudSelectItem>
<MudSelectItem Value="StockMovementType.AdjustmentDecrease">کاهش موجودی</MudSelectItem>
<MudSelectItem Value="StockMovementType.Reserved">رزرو</MudSelectItem>
<MudSelectItem Value="StockMovementType.Released">آزادسازی رزرو</MudSelectItem>
<MudSelectItem Value="StockMovementType.Loss">خسارت</MudSelectItem>
<MudSelectItem Value="StockMovementType.Damaged">آسیب‌دیده</MudSelectItem>
<MudSelectItem Value="StockMovementType.Expired">منقضی</MudSelectItem>
<MudSelectItem Value="StockMovementType.TransferOut">انتقال به بیرون</MudSelectItem>
<MudSelectItem Value="StockMovementType.TransferIn">انتقال به داخل</MudSelectItem>
<MudSelectItem T="StockMovementType" Value="StockMovementType.MovementTypeUnspecified">همه</MudSelectItem>
<MudSelectItem T="StockMovementType" Value="StockMovementType.InitialStock">موجودی اولیه</MudSelectItem>
<MudSelectItem T="StockMovementType" Value="StockMovementType.Restock">ورود کالا</MudSelectItem>
<MudSelectItem T="StockMovementType" Value="StockMovementType.Return">برگشت از مشتری</MudSelectItem>
<MudSelectItem T="StockMovementType" Value="StockMovementType.Sale">فروش</MudSelectItem>
<MudSelectItem T="StockMovementType" Value="StockMovementType.AdjustmentIncrease">افزایش موجودی</MudSelectItem>
<MudSelectItem T="StockMovementType" Value="StockMovementType.AdjustmentDecrease">کاهش موجودی</MudSelectItem>
<MudSelectItem T="StockMovementType" Value="StockMovementType.Reserved">رزرو</MudSelectItem>
<MudSelectItem T="StockMovementType" Value="StockMovementType.Released">آزادسازی رزرو</MudSelectItem>
<MudSelectItem T="StockMovementType" Value="StockMovementType.Loss">خسارت</MudSelectItem>
<MudSelectItem T="StockMovementType" Value="StockMovementType.Damaged">آسیب‌دیده</MudSelectItem>
<MudSelectItem T="StockMovementType" Value="StockMovementType.Expired">منقضی</MudSelectItem>
<MudSelectItem T="StockMovementType" Value="StockMovementType.TransferOut">انتقال به بیرون</MudSelectItem>
<MudSelectItem T="StockMovementType" Value="StockMovementType.TransferIn">انتقال به داخل</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12" sm="6" md="3">
@@ -50,9 +50,9 @@
Label="نوع محصول"
Variant="Variant.Outlined"
Margin="Margin.Dense">
<MudSelectItem Value="ProductType.Unspecified">همه</MudSelectItem>
<MudSelectItem Value="ProductType.RegularProduct">محصول عادی</MudSelectItem>
<MudSelectItem Value="ProductType.DiscountProduct">محصول تخفیفی</MudSelectItem>
<MudSelectItem T="ProductType" Value="ProductType.Unspecified">همه</MudSelectItem>
<MudSelectItem T="ProductType" Value="ProductType.RegularProduct">محصول عادی</MudSelectItem>
<MudSelectItem T="ProductType" Value="ProductType.DiscountProduct">محصول تخفیفی</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12" sm="6" md="3">
@@ -92,13 +92,20 @@
ServerData="@(new Func<GridState<StockMovementDto>, Task<GridData<StockMovementDto>>>(ServerReload))"
Hover="true"
@ref="_gridData"
Height="65vh">
Height="calc(100vh - 240px)">
<ToolBarContent>
<MudText Typo="Typo.h6">تغییرات موجودی</MudText>
<MudSpacer />
<MudText Typo="Typo.body2" Color="Color.Secondary">
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="ml-4">
کل: @_totalCount ردیف
</MudText>
<MudButton Variant="Variant.Outlined"
Color="Color.Info"
OnClick="ExportToExcel"
StartIcon="@Icons.Material.Filled.FileDownload"
Size="Size.Small">
خروجی Excel
</MudButton>
</ToolBarContent>
<Columns>
<PropertyColumn Property="x => x.Id" Title="شناسه" />
@@ -157,7 +164,7 @@
@if (context.Item.Created != null)
{
<MudText Typo="Typo.caption">
@context.Item.Created.ToDateTime().ToLocalTime().ToString("yyyy/MM/dd HH:mm")
@context.Item.Created.ToDateTime().ToLocalTime().MiladiToJalaliWithTime()
</MudText>
}
</CellTemplate>
@@ -176,11 +183,14 @@
</CellTemplate>
</PropertyColumn>
</Columns>
<NoRecordsContent>
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Text" Class="my-4">موردی یافت نشد.</MudAlert>
</NoRecordsContent>
<PagerContent>
<MudDataGridPager T="StockMovementDto"
PageSizeOptions="@(new int[] { 20, 50, 100 })"
InfoFormat="سطر {first_item} تا {last_item} از {all_items}"
RowsPerPageString="تعداد سطرهای صفحه" />
RowsPerPageString="تعداد در صفحه" />
</PagerContent>
</MudDataGrid>
</MudContainer>
</div>
@@ -1,14 +1,18 @@
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using MudBlazor;
using Google.Protobuf.WellKnownTypes;
using CMSMicroservice.Protobuf.Protos.Inventory;
using BackOffice.Common.Utilities;
using System.Text;
using DateTimeConverterCL;
namespace BackOffice.Pages.Inventory;
public partial class MovementsPage
{
[Inject] public InventoryContract.InventoryContractClient InventoryContract { get; set; } = default!;
[Inject] public IJSRuntime JsRuntime { get; set; } = default!;
[SupplyParameterFromQuery(Name = "itemId")]
public long? ItemId { get; set; }
@@ -150,4 +154,66 @@ public partial class MovementsPage
{
Navigation.NavigateTo(RouteConstance.Inventory);
}
private async Task ExportToExcel()
{
try
{
var request = new GetStockMovementsRequest
{
Page = 1,
PageSize = 1000,
MovementType = _filterMovementType,
ProductType = _filterProductType
};
if (ItemId.HasValue) request.InventoryItemId = ItemId.Value;
if (_filterFromDate.HasValue) request.FromDate = Timestamp.FromDateTime(_filterFromDate.Value.ToUniversalTime());
if (_filterToDate.HasValue) request.ToDate = Timestamp.FromDateTime(_filterToDate.Value.ToUniversalTime());
var result = await InventoryContract.GetStockMovementsAsync(request);
if (result?.Movements == null || !result.Movements.Any())
{
Snackbar.Add("داده‌ای برای خروجی وجود ندارد.", Severity.Info);
return;
}
var sb = new StringBuilder();
sb.AppendLine("شناسه,محصول,نوع تغییر,تعداد,قبل,بعد,شماره مرجع,یادداشت,تاریخ");
foreach (var m in result.Movements)
{
sb.AppendLine(string.Join(",",
m.Id,
EscapeCsv(m.ProductTitle),
EscapeCsv(GetMovementTypeLabel(m.MovementType)),
m.Quantity,
m.QuantityBefore,
m.QuantityAfter,
EscapeCsv(m.ReferenceNumber),
EscapeCsv(m.Note),
m.Created?.ToDateTime().ToLocalTime().MiladiToJalaliWithTime() ?? "-"));
}
var bytes = Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes(sb.ToString())).ToArray();
var base64 = Convert.ToBase64String(bytes);
var filename = $"stock-movements-{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);
}
}
private static string EscapeCsv(string value)
{
if (string.IsNullOrEmpty(value)) return "";
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
return $"\"{value.Replace("\"", "\"\"")}\"";
return value;
}
}
@@ -4,7 +4,7 @@
@using CMSMicroservice.Protobuf.Protos.Inventory
@using BackOffice.Pages.Inventory.Components
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="mt-4">
<div>
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-4">
<MudStack Spacing="0">
<MudText Typo="Typo.h4">
@@ -92,7 +92,7 @@
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center">
<MudIcon Icon="@Icons.Material.Filled.CalendarToday" Size="Size.Small" Color="Color.Secondary" />
<MudText Typo="Typo.caption" Color="Color.Secondary">
ایجاد: @(warehouse.Created?.ToDateTime().ToLocalTime().ToString("yyyy/MM/dd") ?? "-")
ایجاد: @(warehouse.Created?.ToDateTime().ToLocalTime().MiladiToJalali() ?? "-")
</MudText>
</MudStack>
</MudStack>
@@ -120,4 +120,4 @@
}
</MudGrid>
}
</MudContainer>
</div>
+23 -11
View File
@@ -4,18 +4,30 @@
@attribute [AllowAnonymous]
<MudStack Style="width:100%;height:80vh;" AlignItems="AlignItems.Center" Justify="Justify.Center">
<MudPaper Elevation="4" Width="35%" Height="200px" Class="mx-auto my-auto pa-4">
<MudForm @ref="@_form" Model="@_request" Validation="@(_requestValidator.ValidateValue)">
<MudStack>
<MudTextField T="string" Variant="Variant.Outlined" Label="شماره موبایل" @bind-Value="@_request.Mobile" For="() => _request.Mobile" HelperText="لطفا شماره موبایل خود را وارد کنید"></MudTextField>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Disabled="_isLoading" OnClick="OnSubmitClick" ButtonType="ButtonType.Button" Style="cursor:pointer;">ارسال کد</MudButton>
<MudRTLProvider RightToLeft="true">
<MudThemeProvider Theme="CustomMudTheme.CustomMudBlazorTheme" />
<MudStack Style="width:100%;height:100vh;background:var(--mud-palette-background);" AlignItems="AlignItems.Center" Justify="Justify.Center">
<MudPaper Elevation="4" Class="pa-8" Style="width:100%;max-width:420px;border-radius:16px;">
<MudStack Spacing="4" AlignItems="AlignItems.Center">
<MudIcon Icon="@Icons.Material.Filled.AdminPanelSettings" Color="Color.Primary" Size="Size.Large" />
<MudText Typo="Typo.h5" Align="Align.Center">پنل مدیریت</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary" Align="Align.Center">کارا بازار سلامت</MudText>
</MudStack>
</MudForm>
</MudPaper>
</MudStack>
<MudForm @ref="@_form" Model="@_request" Validation="@(_requestValidator.ValidateValue)" Class="mt-6">
<MudStack Spacing="3">
<MudTextField T="string" Variant="Variant.Outlined" Label="شماره موبایل" @bind-Value="@_request.Mobile" For="() => _request.Mobile" HelperText="لطفا شماره موبایل خود را وارد کنید" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Phone" />
<MudButton Variant="Variant.Filled" Color="Color.Primary" Disabled="_isLoading" OnClick="OnSubmitClick" ButtonType="ButtonType.Button" FullWidth="true" Size="Size.Large">
@if (_isLoading)
{
<MudProgressCircular Color="Color.Inherit" Size="Size.Small" Indeterminate="true" Class="me-2" />
}
ارسال کد
</MudButton>
</MudStack>
</MudForm>
</MudPaper>
</MudStack>
</MudRTLProvider>
+26 -11
View File
@@ -4,17 +4,32 @@
@attribute [AllowAnonymous]
<MudStack Style="width:100%;height:100vh;" AlignItems="AlignItems.Center" Justify="Justify.Center">
<MudPaper Elevation="4" Width="35%" Height="230px" Class="mx-auto my-auto pa-4">
<MudForm @ref="@_form" Model="@_request" Validation="@(_requestValidator.ValidateValue)">
<MudStack Spacing="5">
<MudTextField T="string" @bind-Value="@_request.Code" For="() => _request.Code" Variant="Variant.Outlined" Label="رمز پویا" HelperText="لطفا کد شش رقمی دریافتی را وارد کنید" InputType="InputType.Telephone" />
<MudButton Color="Color.Primary" Variant="Variant.Filled" Disabled="_isLoading" OnClick="OnSubmitClick">ورود به حساب کاربری</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Text" Disabled="@(_currentCount > 1 || _isLoading)" OnClick="OnResendOtpClick">@(_currentCount < 1 ? "ارسال مجدد رمز پویا" : $"ارسال مجدد رمز پویا {_currentCount}")</MudButton>
<MudRTLProvider RightToLeft="true">
<MudThemeProvider Theme="CustomMudTheme.CustomMudBlazorTheme" />
<MudStack Style="width:100%;height:100vh;background:var(--mud-palette-background);" AlignItems="AlignItems.Center" Justify="Justify.Center">
<MudPaper Elevation="4" Class="pa-8" Style="width:100%;max-width:420px;border-radius:16px;">
<MudStack Spacing="4" AlignItems="AlignItems.Center">
<MudIcon Icon="@Icons.Material.Filled.LockOpen" Color="Color.Primary" Size="Size.Large" />
<MudText Typo="Typo.h5" Align="Align.Center">تأیید ورود</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary" Align="Align.Center">کد ارسال‌شده به @Mobile را وارد کنید</MudText>
</MudStack>
</MudForm>
</MudPaper>
</MudStack>
<MudForm @ref="@_form" Model="@_request" Validation="@(_requestValidator.ValidateValue)" Class="mt-6">
<MudStack Spacing="3">
<MudTextField T="string" @bind-Value="@_request.Code" For="() => _request.Code" Variant="Variant.Outlined" Label="رمز پویا" HelperText="لطفا کد شش رقمی دریافتی را وارد کنید" InputType="InputType.Telephone" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Pin" />
<MudButton Color="Color.Primary" Variant="Variant.Filled" Disabled="_isLoading" OnClick="OnSubmitClick" FullWidth="true" Size="Size.Large">
@if (_isLoading)
{
<MudProgressCircular Color="Color.Inherit" Size="Size.Small" Indeterminate="true" Class="me-2" />
}
ورود به حساب کاربری
</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Text" Disabled="@(_currentCount > 1 || _isLoading)" OnClick="OnResendOtpClick" FullWidth="true">
@(_currentCount < 1 ? "ارسال مجدد رمز پویا" : $"ارسال مجدد رمز پویا {_currentCount}")
</MudButton>
</MudStack>
</MudForm>
</MudPaper>
</MudStack>
</MudRTLProvider>
@@ -4,7 +4,7 @@
@using MudBlazor
@using CMSMicroservice.Protobuf.Protos.Commission
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="mt-4">
<div>
<MudText Typo="Typo.h4" GutterBottom="true">گزارش موجودی‌های هفتگی</MudText>
<MudText Typo="Typo.body1" Color="Color.Secondary" Class="mb-4">
مشاهده موجودی‌های شبکه کاربران به تفکیک هفته
@@ -151,6 +151,9 @@
</CellTemplate>
</TemplateColumn>
</Columns>
<NoRecordsContent>
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Text" Class="my-4">موردی یافت نشد.</MudAlert>
</NoRecordsContent>
<PagerContent>
<MudDataGridPager T="UserWeeklyBalanceModel"
PageSizeOptions="new int[] { 10, 25, 50, 100 }" />
@@ -183,7 +186,7 @@
</MudGrid>
</MudCardContent>
</MudCard>
</MudContainer>
</div>
@code {
[Inject] public CommissionContract.CommissionContractClient CommissionClient { get; set; }
@@ -6,7 +6,7 @@
@using CMSMicroservice.Protobuf.Protos.NetworkMembership
@using BackOffice.Pages.AutoComplete
<MudContainer MaxWidth="MaxWidth.ExtraExtraLarge" Class="mt-4">
<div>
<MudText Typo="Typo.h4" Class="mb-4">درخت شبکه</MudText>
@* Search & Filter Panel *@
@@ -28,11 +28,11 @@
<MudStack Row="true" Spacing="3" AlignItems="AlignItems.Center">
<MudSelect T="int" @bind-Value="_selectedDepth" Label="عمق نمایش" Variant="Variant.Outlined"
Dense="true" Margin="Margin.Dense" Style="width: 120px;">
<MudSelectItem Value="3">3 سطح</MudSelectItem>
<MudSelectItem Value="5">5 سطح</MudSelectItem>
<MudSelectItem Value="10">10 سطح</MudSelectItem>
<MudSelectItem Value="15">15 سطح</MudSelectItem>
<MudSelectItem Value="100">همه</MudSelectItem>
<MudSelectItem T="int" Value="3">3 سطح</MudSelectItem>
<MudSelectItem T="int" Value="5">5 سطح</MudSelectItem>
<MudSelectItem T="int" Value="10">10 سطح</MudSelectItem>
<MudSelectItem T="int" Value="15">15 سطح</MudSelectItem>
<MudSelectItem T="int" Value="100">همه</MudSelectItem>
</MudSelect>
<MudSelect T="bool?" Label="وضعیت باشگاه" @bind-Value="_clubActiveFilter" Variant="Variant.Outlined"
@@ -145,7 +145,7 @@
<CellTemplate>
@if (context.Item.ClubActivatedAt != null)
{
@context.Item.ClubActivatedAt.ToDateTime().ToLocalTime().ToString("yyyy/MM/dd")
@context.Item.ClubActivatedAt.ToDateTime().ToLocalTime().MiladiToJalali()
}
else
{
@@ -184,8 +184,13 @@
</CellTemplate>
</TemplateColumn>
</Columns>
<NoRecordsContent>
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Text" Class="my-4">موردی یافت نشد.</MudAlert>
</NoRecordsContent>
<PagerContent>
<MudDataGridPager T="NetworkTreeNodeModel" />
<MudDataGridPager T="NetworkTreeNodeModel" PageSizeOptions="@(new int[] { 20, 50, 100 })"
InfoFormat="سطر {first_item} تا {last_item} از {all_items}"
RowsPerPageString="تعداد در صفحه" />
</PagerContent>
</MudDataGrid>
</MudPaper>
@@ -198,7 +203,7 @@
</MudAlert>
</MudPaper>
}
</MudContainer>
</div>
@code {
[Inject] public NetworkMembershipContract.NetworkMembershipContractClient NetworkContract { get; set; }
@@ -332,7 +337,7 @@
isClubActive = n.IsClubActive,
isActivatedInTargetWeek = n.IsActivatedInTargetWeek,
activationWeekDefinitionId = n.ActivationWeekDefinitionId,
clubActivatedAt = n.ClubActivatedAt?.ToDateTime().ToLocalTime().ToString("yyyy/MM/dd") ?? ""
clubActivatedAt = n.ClubActivatedAt?.ToDateTime().ToLocalTime().MiladiToJalali() ?? ""
}).ToArray();
var options = new { filterWeek = _activationWeekFilter };
@@ -341,7 +346,6 @@
}
catch (Exception ex)
{
Console.WriteLine($"Error rendering chart: {ex.Message}");
}
}
@@ -3,7 +3,7 @@
@using CMSMicroservice.Protobuf.Protos.NetworkMembership
@using MudBlazor
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="mt-4">
<div>
<MudText Typo="Typo.h4" GutterBottom="true">آمار شبکه</MudText>
<MudText Typo="Typo.body1" Color="Color.Secondary" Class="mb-4">
نمای کلی از وضعیت شبکه و آمار اعضا
@@ -162,7 +162,7 @@
</MudCardContent>
</MudCard>
}
</MudContainer>
</div>
@code {
[Inject] public NetworkMembershipContract.NetworkMembershipContractClient NetworkClient { get; set; }
@@ -3,7 +3,7 @@
@attribute [Authorize]
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="mt-4">
<div>
<MudBreadcrumbs Items="_breadcrumbs" Class="mb-4"></MudBreadcrumbs>
@if (_isLoading)
@@ -346,6 +346,72 @@
</MudCard>
</MudItem>
<!-- تاریخچه تغییرات عضویت -->
<MudItem xs="12">
<MudCard>
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h6">
<MudIcon Icon="@Icons.Material.Filled.History" Class="ml-1" />
تاریخچه تغییرات شبکه
</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
@if (_networkHistoryLoading)
{
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
}
else if (_networkHistory.Any())
{
<MudTable T="NetworkMembershipHistoryModel" Items="_networkHistory" Dense="true" Hover="true">
<HeaderContent>
<MudTh>عملیات</MudTh>
<MudTh>تاریخ</MudTh>
<MudTh>والد قبلی → جدید</MudTh>
<MudTh>ساق قبلی → جدید</MudTh>
<MudTh>دلیل</MudTh>
<MudTh>توسط</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="عملیات">
<MudChip T="string" Size="Size.Small" Color="Color.Info">@context.Action</MudChip>
</MudTd>
<MudTd DataLabel="تاریخ">@context.Created.ToDateTime().ToLocalTime().MiladiToJalaliWithTime()</MudTd>
<MudTd DataLabel="والد">
@if (context.OldParentId > 0 || context.NewParentId > 0)
{
<span>@context.OldParentId → @context.NewParentId</span>
}
else
{
<span>-</span>
}
</MudTd>
<MudTd DataLabel="ساق">
@{ var oldLeg = context.OldNetworkLeg == 0 ? "چپ" : "راست"; var newLeg = context.NewNetworkLeg == 0 ? "چپ" : "راست"; }
@if (context.OldNetworkLeg != context.NewNetworkLeg)
{
<span>@oldLeg → @newLeg</span>
}
else
{
<span>-</span>
}
</MudTd>
<MudTd DataLabel="دلیل">@(string.IsNullOrEmpty(context.Reason) ? "-" : context.Reason)</MudTd>
<MudTd DataLabel="توسط">@(string.IsNullOrEmpty(context.PerformedBy) ? "-" : context.PerformedBy)</MudTd>
</RowTemplate>
</MudTable>
}
else
{
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Text">تاریخچه‌ای یافت نشد.</MudAlert>
}
</MudCardContent>
</MudCard>
</MudItem>
<!-- عملیات -->
<MudItem xs="12">
<MudCard>
@@ -386,7 +452,7 @@
کاربر یافت نشد یا خطایی رخ داده است.
</MudAlert>
}
</MudContainer>
</div>
@code {
[Parameter] public long UserId { get; set; }
@@ -397,10 +463,13 @@
private GetUserNetworkResponse _userInfo;
private bool _isLoading = true;
private List<BreadcrumbItem> _breadcrumbs = new();
private List<NetworkMembershipHistoryModel> _networkHistory = new();
private bool _networkHistoryLoading;
protected override async Task OnParametersSetAsync()
{
await LoadUserInfo();
await LoadNetworkHistory();
}
private async Task LoadUserInfo()
@@ -433,6 +502,30 @@
NavigationManager.NavigateTo($"/network/user-info/{parentIdValue.Value}");
}
private async Task LoadNetworkHistory()
{
_networkHistoryLoading = true;
try
{
var request = new GetNetworkMembershipHistoryRequest
{
UserId = UserId,
PageIndex = 0,
PageSize = 50
};
var response = await NetworkContract.GetNetworkMembershipHistoryAsync(request);
_networkHistory = response.Models?.ToList() ?? new();
}
catch
{
_networkHistory = new();
}
finally
{
_networkHistoryLoading = false;
}
}
private string GetPackagePurchaseMethodText(int method)
{
return method switch
@@ -16,7 +16,7 @@
Color="Color.Primary"
ButtonType="ButtonType.Button"
StartIcon="@Icons.Material.Filled.Image"
Style="cursor:pointer;">
>
انتخاب تصویر
</MudButton>
</ActivatorContent>
@@ -45,7 +45,7 @@
</MudItem>
</MudStack>
<MudItem xs="12">
<MudHtmlEditor @bind-Html="Model.Description">
<MudHtmlEditor @bind-Html="Model.Description" MinHeight="300px">
<MudHtmlToolbarOptions InsertImage="false" /> <!-- This will exclude the "insert image" toolbar option -->
</MudHtmlEditor>
</MudItem>
@@ -17,7 +17,7 @@
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Image"
ButtonType="ButtonType.Button"
Style="cursor:pointer;">
>
انتخاب تصویر
</MudButton>
</ActivatorContent>
@@ -46,7 +46,7 @@
</MudItem>
</MudStack>
<MudItem xs="12">
<MudHtmlEditor @bind-Html="Model.Description">
<MudHtmlEditor @bind-Html="Model.Description" MinHeight="300px">
<MudHtmlToolbarOptions InsertImage="false" /> <!-- This will exclude the "insert image" toolbar option -->
</MudHtmlEditor>
</MudItem>
@@ -7,11 +7,13 @@
<BackOffice.Common.BaseComponents.BasePageComponent @ref="_basePage" OnClearFilterClick="OnFilterCleared" OnSubmitClick="OnFilterSubmit">
<Filters>
<MudTextField T="string" Clearable="true" Label="عنوان" @bind-Value="@_request.Filter.Title" Variant="Variant.Outlined" Margin="Margin.Dense" />
<MudItem xs="12" sm="6" md="3">
<MudTextField T="string" Clearable="true" Label="عنوان" @bind-Value="@_request.Filter.Title" Variant="Variant.Outlined" Margin="Margin.Dense" />
</MudItem>
</Filters>
<Content>
<MudDataGrid T="DataModel" ServerData="@(new Func<GridState<DataModel>, Task<GridData<DataModel>>>(ServerReload))"
Hover="true" @ref="_gridData" Height="72vh">
Hover="true" @ref="_gridData" Height="calc(100vh - 240px)">
<ColGroup>
<col />
<col />
@@ -22,7 +24,7 @@
<MudText>مدیریت پکیج</MudText>
<MudSpacer />
<MudStack Spacing="2" Row="true" Justify="Justify.Center" AlignItems="AlignItems.Center">
<MudButton Variant="Variant.Filled" Color="Color.Primary" Size="Size.Large" ButtonType="ButtonType.Button" OnClick="CreateNew" Style="cursor:pointer;">افزودن</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Size="Size.Large" ButtonType="ButtonType.Button" OnClick="CreateNew">افزودن</MudButton>
</MudStack>
</ToolBarContent>
<Columns>
@@ -71,18 +73,21 @@
<CellTemplate>
<MudStack Row="true" AlignItems="AlignItems.Center">
<MudTooltip Text="ویرایش">
<MudIconButton Icon="@Icons.Material.Filled.EditNote" Size="Size.Small" ButtonType="ButtonType.Button" OnClick="@(() => Update(context.Item.Adapt<DataModel>()))" Style="cursor:pointer;" />
<MudIconButton Icon="@Icons.Material.Filled.EditNote" Size="Size.Small" ButtonType="ButtonType.Button" OnClick="@(() => Update(context.Item.Adapt<DataModel>()))" />
</MudTooltip>
<MudTooltip Text="آرشیو">
<MudIconButton Icon="@Icons.Material.Filled.DeleteOutline" Size="Size.Small" ButtonType="ButtonType.Button" OnClick="@(() => OnDelete(context.Item))" Style="cursor:pointer;" />
<MudIconButton Icon="@Icons.Material.Filled.DeleteOutline" Size="Size.Small" ButtonType="ButtonType.Button" OnClick="@(() => OnDelete(context.Item))" />
</MudTooltip>
</MudStack>
</CellTemplate>
</TemplateColumn>
</Columns>
<NoRecordsContent>
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Text" Class="my-4">موردی یافت نشد.</MudAlert>
</NoRecordsContent>
<PagerContent>
<MudDataGridPager T="DataModel" PageSizeOptions=@(new int[] { 30, 60, 90 }) InfoFormat="سطر {first_item} تا {last_item} از {all_items}" RowsPerPageString="تعداد سطرهای صفحه" />
<MudDataGridPager T="DataModel" PageSizeOptions=@(new int[] { 20, 50, 100 }) InfoFormat="سطر {first_item} تا {last_item} از {all_items}" RowsPerPageString="تعداد در صفحه" />
</PagerContent>
</MudDataGrid>
</Content>
@@ -49,7 +49,7 @@ public partial class PackageMainPage
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small };
bool? result = await DialogService.ShowMessageBox(
"اخطار",
"آیا از حذف این مورد مطمئن هستید؟",
$"آیا از حذف پکیج «{model.Title}» مطمئن هستید؟",
yesText: "حذف", cancelText: "لغو",
options: options);
if (result != null && result.Value)
@@ -25,13 +25,13 @@
Label="نوع پرداخت"
Variant="Variant.Outlined"
@bind-Value="_selectedType">
<MudSelectItem Value="1">واریز نقدی</MudSelectItem>
<MudSelectItem Value="2">شارژ کیف پول تخفیف</MudSelectItem>
<MudSelectItem Value="3">شارژ کیف پول شبکه</MudSelectItem>
<MudSelectItem Value="4">تسویه حساب</MudSelectItem>
<MudSelectItem Value="5">اصلاح خطا</MudSelectItem>
<MudSelectItem Value="6">بازپرداخت</MudSelectItem>
<MudSelectItem Value="99">سایر</MudSelectItem>
<MudSelectItem T="int" Value="1">واریز نقدی</MudSelectItem>
<MudSelectItem T="int" Value="2">شارژ کیف پول تخفیف</MudSelectItem>
<MudSelectItem T="int" Value="3">شارژ کیف پول شبکه</MudSelectItem>
<MudSelectItem T="int" Value="4">تسویه حساب</MudSelectItem>
<MudSelectItem T="int" Value="5">اصلاح خطا</MudSelectItem>
<MudSelectItem T="int" Value="6">بازپرداخت</MudSelectItem>
<MudSelectItem T="int" Value="99">سایر</MudSelectItem>
</MudSelect>
<MudTextField T="string"
+105 -48
View File
@@ -6,58 +6,52 @@
<BasePageComponent @ref="_basePage" OnSubmitClick="OnFilterSubmit" OnClearFilterClick="OnFilterCleared">
<Filters>
<UserAutoComplete Label="جستجوی کاربر"
@bind-SelectedUserId="_userIdFilter" />
<MudTextField T="string"
Label="شماره مرجع"
Variant="Variant.Outlined"
Margin="Margin.Dense"
@bind-Value="_referenceFilter" />
<MudSelect T="int?"
Clearable="true"
Label="وضعیت"
Variant="Variant.Outlined"
Margin="Margin.Dense"
@bind-Value="_statusFilter">
<MudSelectItem T="int?" Value="@(0)">در انتظار</MudSelectItem>
<MudSelectItem T="int?" Value="@(1)">تایید شده</MudSelectItem>
<MudSelectItem T="int?" Value="@(2)">رد شده</MudSelectItem>
<MudSelectItem T="int?" Value="@(3)">لغو شده</MudSelectItem>
</MudSelect>
<MudSelect T="int?"
Clearable="true"
Label="نوع"
Variant="Variant.Outlined"
Margin="Margin.Dense"
@bind-Value="_typeFilter">
<MudSelectItem T="int?" Value="@(1)">واریز نقدی</MudSelectItem>
<MudSelectItem T="int?" Value="@(2)">شارژ کیف‌پول تخفیف</MudSelectItem>
<MudSelectItem T="int?" Value="@(3)">شارژ کیف‌پول شبکه</MudSelectItem>
<MudSelectItem T="int?" Value="@(4)">تسویه حساب</MudSelectItem>
<MudSelectItem T="int?" Value="@(5)">اصلاح خطا</MudSelectItem>
<MudSelectItem T="int?" Value="@(6)">بازگشت وجه</MudSelectItem>
<MudSelectItem T="int?" Value="@(99)">سایر</MudSelectItem>
</MudSelect>
<MudItem xs="12" sm="6" md="3">
<UserAutoComplete Label="جستجوی کاربر" @bind-SelectedUserId="_userIdFilter" />
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudTextField T="string" Label="شماره مرجع" Variant="Variant.Outlined"
Margin="Margin.Dense" @bind-Value="_referenceFilter" />
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudSelect T="int?" Clearable="true" Label="وضعیت" Variant="Variant.Outlined"
Margin="Margin.Dense" @bind-Value="_statusFilter">
<MudSelectItem T="int?" Value="@(0)">در انتظار</MudSelectItem>
<MudSelectItem T="int?" Value="@(1)">تایید شده</MudSelectItem>
<MudSelectItem T="int?" Value="@(2)">رد شده</MudSelectItem>
<MudSelectItem T="int?" Value="@(3)">لغو شده</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudSelect T="int?" Clearable="true" Label="نوع" Variant="Variant.Outlined"
Margin="Margin.Dense" @bind-Value="_typeFilter">
<MudSelectItem T="int?" Value="@(1)">واریز نقدی</MudSelectItem>
<MudSelectItem T="int?" Value="@(2)">شارژ کیف‌پول تخفیف</MudSelectItem>
<MudSelectItem T="int?" Value="@(3)">شارژ کیف‌پول شبکه</MudSelectItem>
<MudSelectItem T="int?" Value="@(4)">تسویه حساب</MudSelectItem>
<MudSelectItem T="int?" Value="@(5)">اصلاح خطا</MudSelectItem>
<MudSelectItem T="int?" Value="@(6)">بازگشت وجه</MudSelectItem>
<MudSelectItem T="int?" Value="@(99)">سایر</MudSelectItem>
</MudSelect>
</MudItem>
</Filters>
<Content>
<MudPaper Class="pa-2 mb-2">
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.subtitle1">فعالسازی (پرداخت) دستی</MudText>
<MudButton Variant="Variant.Outlined"
Color="Color.Primary"
OnClick="OpenCreateDialog">
ثبت فعالسازی دستی جدید
</MudButton>
</MudStack>
</MudPaper>
<MudDataGrid @ref="_dataGrid" T="ManualPaymentModel"
ServerData="LoadData"
Hover="true"
Dense="true">
Hover="true" Dense="true"
Height="calc(100vh - 240px)">
<ToolBarContent>
<MudText Typo="Typo.subtitle1">فعالسازی (پرداخت) دستی</MudText>
<MudSpacer />
<MudButton Variant="Variant.Outlined" Color="Color.Info" Size="Size.Small"
StartIcon="@Icons.Material.Filled.FileDownload" OnClick="ExportToExcel" Class="mr-2">
خروجی Excel
</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Size="Size.Small"
StartIcon="@Icons.Material.Filled.Add" OnClick="OpenCreateDialog">
ثبت جدید
</MudButton>
</ToolBarContent>
<Columns>
<PropertyColumn Property="x => x.Id" Title="شناسه" />
<PropertyColumn Property="x => x.UserId" Title="شناسه کاربر" />
@@ -94,10 +88,31 @@
Size="Size.Small"
OnClick="@(() => OpenDetails(context.Item))" />
</MudTooltip>
@if ((int)context.Item.Status == 0)
{
<MudTooltip Text="تایید">
<MudIconButton Icon="@Icons.Material.Filled.CheckCircle"
Size="Size.Small" Color="Color.Success"
OnClick="@(() => ApprovePayment(context.Item))" />
</MudTooltip>
<MudTooltip Text="رد">
<MudIconButton Icon="@Icons.Material.Filled.Cancel"
Size="Size.Small" Color="Color.Error"
OnClick="@(() => RejectPayment(context.Item))" />
</MudTooltip>
}
</MudStack>
</CellTemplate>
</TemplateColumn>
</Columns>
<NoRecordsContent>
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Text" Class="my-4">موردی یافت نشد.</MudAlert>
</NoRecordsContent>
<PagerContent>
<MudDataGridPager T="ManualPaymentModel" PageSizeOptions="@(new int[] { 20, 50, 100 })"
InfoFormat="سطر {first_item} تا {last_item} از {all_items}"
RowsPerPageString="تعداد در صفحه" />
</PagerContent>
</MudDataGrid>
</Content>
</BasePageComponent>
@@ -237,4 +252,46 @@
_ => Color.Default
};
}
private async Task ApprovePayment(ManualPaymentModel model)
{
bool? confirm = await DialogService.ShowMessageBox(
"تایید پرداخت",
$"آیا از تایید پرداخت دستی #{model.Id} به مبلغ {model.Amount:N0} ریال مطمئن هستید؟",
yesText: "تایید", cancelText: "لغو");
if (confirm == true)
{
try
{
await ManualPaymentClient.ApproveManualPaymentAsync(new ApproveManualPaymentRequest { ManualPaymentId = model.Id });
Snackbar.Add("پرداخت با موفقیت تایید شد", Severity.Success);
if (_dataGrid != null) await _dataGrid.ReloadServerData();
}
catch (Exception ex)
{
Snackbar.Add($"خطا: {ex.Message}", Severity.Error);
}
}
}
private async Task RejectPayment(ManualPaymentModel model)
{
bool? confirm = await DialogService.ShowMessageBox(
"رد پرداخت",
$"آیا از رد پرداخت دستی #{model.Id} مطمئن هستید؟",
yesText: "رد کردن", cancelText: "لغو");
if (confirm == true)
{
try
{
await ManualPaymentClient.RejectManualPaymentAsync(new RejectManualPaymentRequest { ManualPaymentId = model.Id });
Snackbar.Add("پرداخت رد شد", Severity.Warning);
if (_dataGrid != null) await _dataGrid.ReloadServerData();
}
catch (Exception ex)
{
Snackbar.Add($"خطا: {ex.Message}", Severity.Error);
}
}
}
}
@@ -0,0 +1,74 @@
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using MudBlazor;
using System.Text;
using DateTimeConverterCL;
using CMSMicroservice.Protobuf.Protos.ManualPayment;
namespace BackOffice.Pages.Payment;
public partial class ManualPayments
{
[Inject] public IJSRuntime JsRuntime { get; set; } = default!;
private async Task ExportToExcel()
{
try
{
var request = new GetAllManualPaymentsRequest
{
PageNumber = 1,
PageSize = 1000
};
if (_userIdFilter.HasValue)
request.UserId = _userIdFilter.Value;
if (_statusFilter.HasValue)
request.Status = _statusFilter.Value;
if (_typeFilter.HasValue)
request.Type = _typeFilter.Value;
var response = await ManualPaymentClient.GetAllManualPaymentsAsync(request);
if (response?.Models == null || !response.Models.Any())
{
Snackbar.Add("داده‌ای برای خروجی وجود ندارد.", Severity.Info);
return;
}
var sb = new StringBuilder();
sb.AppendLine("شناسه,شناسه کاربر,نام کاربر,مبلغ,نوع,وضعیت,تاریخ ایجاد");
foreach (var m in response.Models)
{
sb.AppendLine(string.Join(",",
m.Id,
m.UserId,
EscapeCsv(m.UserFullName),
m.Amount.ToString("N0"),
EscapeCsv(GetTypeText((int)m.Type)),
EscapeCsv(GetStatusText((int)m.Status)),
m.Created?.ToDateTime().ToLocalTime().MiladiToJalaliWithTime() ?? "-"));
}
var bytes = Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes(sb.ToString())).ToArray();
var base64 = Convert.ToBase64String(bytes);
var filename = $"manual-payments-{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);
}
}
private static string EscapeCsv(string value)
{
if (string.IsNullOrEmpty(value)) return "";
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
return $"\"{value.Replace("\"", "\"\"")}\"";
return value;
}
}
+32 -32
View File
@@ -3,42 +3,34 @@
<BasePageComponent @ref="_basePage" OnSubmitClick="OnFilterSubmit" OnClearFilterClick="OnFilterCleared">
<Filters>
<BackOffice.Common.BaseComponents.DateRangePicker @bind-From="_fromDate" @bind-To="_toDate" Label="بازه تاریخی" />
<MudSelect T="int?"
Clearable="true"
Label="وضعیت پرداخت"
Variant="Variant.Outlined"
Margin="Margin.Dense"
@bind-Value="_statusFilter">
<MudSelectItem T="int?" Value="@(0)">در انتظار</MudSelectItem>
<MudSelectItem T="int?" Value="@(1)">موفق</MudSelectItem>
<MudSelectItem T="int?" Value="@(2)">ناموفق</MudSelectItem>
<MudSelectItem T="int?" Value="@(3)">بازگشت وجه</MudSelectItem>
</MudSelect>
<MudSelect T="int?"
Clearable="true"
Label="نوع تراکنش"
Variant="Variant.Outlined"
Margin="Margin.Dense"
@bind-Value="_typeFilter">
<MudSelectItem T="int?" Value="@(0)">نامشخص</MudSelectItem>
<MudSelectItem T="int?" Value="@(1)">خرید پکیج</MudSelectItem>
<MudSelectItem T="int?" Value="@(2)">پرداخت دستی</MudSelectItem>
<MudSelectItem T="int?" Value="@(3)">شارژ کیف‌پول</MudSelectItem>
<MudSelectItem T="int?" Value="@(4)">بازگشت وجه</MudSelectItem>
</MudSelect>
<MudItem xs="12" sm="6" md="4">
<BackOffice.Common.BaseComponents.DateRangePicker @bind-From="_fromDate" @bind-To="_toDate" Label="بازه تاریخی" />
</MudItem>
<MudItem xs="12" sm="6" md="4">
<MudSelect T="int?" Clearable="true" Label="وضعیت پرداخت" Variant="Variant.Outlined"
Margin="Margin.Dense" @bind-Value="_statusFilter">
<MudSelectItem T="int?" Value="@(0)">در انتظار</MudSelectItem>
<MudSelectItem T="int?" Value="@(1)">موفق</MudSelectItem>
<MudSelectItem T="int?" Value="@(2)">ناموفق</MudSelectItem>
<MudSelectItem T="int?" Value="@(3)">بازگشت وجه</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12" sm="6" md="4">
<MudSelect T="int?" Clearable="true" Label="نوع تراکنش" Variant="Variant.Outlined"
Margin="Margin.Dense" @bind-Value="_typeFilter">
<MudSelectItem T="int?" Value="@(0)">نامشخص</MudSelectItem>
<MudSelectItem T="int?" Value="@(1)">خرید پکیج</MudSelectItem>
<MudSelectItem T="int?" Value="@(2)">پرداخت دستی</MudSelectItem>
<MudSelectItem T="int?" Value="@(3)">شارژ کیف‌پول</MudSelectItem>
<MudSelectItem T="int?" Value="@(4)">بازگشت وجه</MudSelectItem>
</MudSelect>
</MudItem>
</Filters>
<Content>
<MudPaper Class="pa-2 mb-2">
<MudText Typo="Typo.subtitle1">مدیریت تراکنش‌ها</MudText>
</MudPaper>
<MudDataGrid @ref="_dataGrid" T="TransactionModel"
ServerData="LoadData"
Hover="true"
Dense="true">
Hover="true" Dense="true"
Height="calc(100vh - 240px)">
<Columns>
<PropertyColumn Property="x => x.Id" Title="شناسه" />
<PropertyColumn Property="x => x.RefId" Title="شماره مرجع" />
@@ -76,6 +68,14 @@
</CellTemplate>
</TemplateColumn>
</Columns>
<NoRecordsContent>
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Text" Class="my-4">موردی یافت نشد.</MudAlert>
</NoRecordsContent>
<PagerContent>
<MudDataGridPager T="TransactionModel" PageSizeOptions="@(new int[] { 20, 50, 100 })"
InfoFormat="سطر {first_item} تا {last_item} از {all_items}"
RowsPerPageString="تعداد در صفحه" />
</PagerContent>
</MudDataGrid>
</Content>
</BasePageComponent>
+25 -35
View File
@@ -8,36 +8,22 @@
OnClearFilterClick="OnFilterCleared"
OnSubmitClick="OnFilterSubmit">
<Filters>
<MudTextField T="string"
Clearable="true"
Label="عنوان"
@bind-Value="@_request.Filter.Title"
Variant="Variant.Outlined"
Margin="Margin.Dense" />
<MudNumericField T="long?"
HideSpinButtons="true"
Clearable="true"
Label="شناسه محصول"
@bind-Value="@_request.Filter.Id"
Variant="Variant.Outlined"
Margin="Margin.Dense" />
<MudNumericField T="long?"
HideSpinButtons="true"
Clearable="true"
Label="شناسه دسته‌بندی"
@bind-Value="@_request.Filter.CategoryId"
Variant="Variant.Outlined"
Margin="Margin.Dense" />
<MudNumericField T="long?"
HideSpinButtons="true"
Clearable="true"
Label="قیمت"
@bind-Value="@_request.Filter.Price"
Variant="Variant.Outlined"
Margin="Margin.Dense" />
<MudItem xs="12" sm="6" md="3">
<MudTextField T="string" Clearable="true" Label="عنوان"
@bind-Value="@_request.Filter.Title" Variant="Variant.Outlined" Margin="Margin.Dense" />
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudNumericField T="long?" HideSpinButtons="true" Clearable="true" Label="شناسه محصول"
@bind-Value="@_request.Filter.Id" Variant="Variant.Outlined" Margin="Margin.Dense" />
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudNumericField T="long?" HideSpinButtons="true" Clearable="true" Label="شناسه دسته‌بندی"
@bind-Value="@_request.Filter.CategoryId" Variant="Variant.Outlined" Margin="Margin.Dense" />
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudNumericField T="long?" HideSpinButtons="true" Clearable="true" Label="قیمت"
@bind-Value="@_request.Filter.Price" Variant="Variant.Outlined" Margin="Margin.Dense" />
</MudItem>
</Filters>
<Content>
<MudGrid Spacing="3">
@@ -60,7 +46,7 @@
ServerData="@(new Func<GridState<DataModel>, Task<GridData<DataModel>>>(ServerReload))"
Hover="true"
@ref="_gridData"
Height="72vh">
Height="calc(100vh - 340px)">
<ColGroup>
<col />
<col style="width: 80px;" />
@@ -85,9 +71,13 @@
<PropertyColumn Property="x => x.Discount" Title="تخفیف (%)" />
<PropertyColumn Property="x => x.RemainingCount" Title="موجودی" />
</Columns>
<NoRecordsContent>
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Text" Class="my-4">موردی یافت نشد.</MudAlert>
</NoRecordsContent>
<PagerContent>
<MudDataGridPager T="DataModel"
PageSizeOptions="@(new int[] { 30, 60, 90 })" />
<MudDataGridPager T="DataModel" PageSizeOptions="@(new int[] { 20, 50, 100 })"
InfoFormat="سطر {first_item} تا {last_item} از {all_items}"
RowsPerPageString="تعداد در صفحه" />
</PagerContent>
</MudDataGrid>
</MudStack>
@@ -134,8 +124,8 @@
Variant="Variant.Outlined"
Margin="Margin.Dense"
@bind-Value="_statusEnable">
<MudSelectItem Value="@(true)">فعال‌سازی</MudSelectItem>
<MudSelectItem Value="@(false)">غیرفعال‌سازی</MudSelectItem>
<MudSelectItem T="bool?" Value="@(true)">فعال‌سازی</MudSelectItem>
<MudSelectItem T="bool?" Value="@(false)">غیرفعال‌سازی</MudSelectItem>
</MudSelect>
<MudStack Row="true"
@@ -1,7 +1,6 @@
@using CMSMicroservice.Protobuf.Protos.Products
@using Microsoft.AspNetCore.Components.Forms
@using Tizzani.MudBlazor.HtmlEditor
@using BackOffice.Common.BaseComponents
@using BackOffice.Pages.AutoComplete
<MudDialog>
@@ -10,7 +9,7 @@
<MudStack Justify="Justify.Center" AlignItems="AlignItems.Center">
@if (!string.IsNullOrWhiteSpace(_mainImagePreview))
{
<Image Src="@(_mainImagePreview)" Width="220" Height="140" ObjectPosition="ObjectPosition.Center" ObjectFit="ObjectFit.Cover" />
<img src="@_mainImagePreview" alt="تصویر اصلی" style="width:220px; height:140px; object-fit:cover; border-radius:4px;" />
}
else
{
@@ -26,7 +25,7 @@
Color="Color.Primary"
ButtonType="ButtonType.Button"
StartIcon="@Icons.Material.Filled.Image"
Style="cursor:pointer;">
>
انتخاب تصویر اصلی
</MudButton>
</ActivatorContent>
@@ -52,7 +51,7 @@
<MudTextField T="string" @bind-Value="Model.Title" Disabled="_isLoading" Label="عنوان" Variant="Variant.Outlined" Margin="Margin.Dense" />
</MudItem>
<MudItem xs="6">
<MudNumericField T="long" HideSpinButtons="true" @bind-Value="Model.Price" Format="N0" Immediate="true" Disabled="_isLoading" Label="قیمت" Variant="Variant.Outlined" Margin="Margin.Dense" />
<MudNumericField T="long" HideSpinButtons="true" @bind-Value="Model.Price" Format="N0" Immediate="true" Disabled="_isLoading" Label="قیمت (ریال)" Variant="Variant.Outlined" Margin="Margin.Dense" />
</MudItem>
</MudStack>
@@ -64,7 +63,7 @@
<MudTextField T="string" @bind-Value="Model.ShortInfomation" Disabled="_isLoading" Label="خلاصه" Variant="Variant.Outlined" Margin="Margin.Dense" />
<MudHtmlEditor @bind-Html="Model.FullInformation">
<MudHtmlEditor @bind-Html="Model.FullInformation" MinHeight="300px">
<MudHtmlToolbarOptions InsertImage="false" />
</MudHtmlEditor>
@@ -83,6 +82,10 @@
<MudStack Spacing="1">
<MudText Typo="Typo.subtitle2">تصویر بندانگشتی</MudText>
@if (!string.IsNullOrWhiteSpace(_thumbnailPreview))
{
<img src="@_thumbnailPreview" alt="بندانگشتی" style="width:120px; height:80px; object-fit:cover; border-radius:4px;" />
}
<MudFileUpload T="IBrowserFile" Accept="image/*" FilesChanged="OnThumbnailSelected">
<ActivatorContent>
<MudButton HtmlTag="label"
@@ -90,7 +93,7 @@
Color="Color.Secondary"
ButtonType="ButtonType.Button"
StartIcon="@Icons.Material.Filled.Image"
Style="cursor:pointer;">
>
انتخاب تصویر بندانگشتی (اختیاری)
</MudButton>
</ActivatorContent>
@@ -17,6 +17,9 @@ public partial class CreateDialog
private readonly long _maxAllowedSize = (1024 * 1024) * 5;
private bool _isLoading;
private string? _mainImagePreview;
private string? _thumbnailPreview;
private byte[]? _mainImageBuffer;
private byte[]? _thumbnailImageBuffer;
private List<long> _selectedCategoryIds = new();
private async Task OnMainImageSelected(IBrowserFile? file)
@@ -24,9 +27,10 @@ public partial class CreateDialog
_mainImageFile = file;
if (file != null)
{
var buffer = new byte[file.Size];
await file.OpenReadStream(_maxAllowedSize).ReadAsync(buffer);
_mainImagePreview = $"data:{file.ContentType};base64," + Convert.ToBase64String(buffer);
using var ms = new MemoryStream();
await file.OpenReadStream(_maxAllowedSize).CopyToAsync(ms);
_mainImageBuffer = ms.ToArray();
_mainImagePreview = $"data:{file.ContentType};base64," + Convert.ToBase64String(_mainImageBuffer);
StateHasChanged();
}
}
@@ -34,9 +38,17 @@ public partial class CreateDialog
private async Task OnThumbnailSelected(IBrowserFile? file)
{
_thumbnailImageFile = file;
if (file != null)
{
using var ms = new MemoryStream();
await file.OpenReadStream(_maxAllowedSize).CopyToAsync(ms);
_thumbnailImageBuffer = ms.ToArray();
_thumbnailPreview = $"data:{file.ContentType};base64," + Convert.ToBase64String(_thumbnailImageBuffer);
StateHasChanged();
}
}
public async void CallCreateMethod()
public async Task CallCreateMethod()
{
_isLoading = true;
StateHasChanged();
@@ -49,25 +61,21 @@ public partial class CreateDialog
return;
}
if (_mainImageFile != null)
if (_mainImageFile != null && _mainImageBuffer != null)
{
var buffer = new byte[_mainImageFile.Size];
await _mainImageFile.OpenReadStream(_maxAllowedSize).ReadAsync(buffer);
Model.ImageFile = new ImageFileModel
{
File = ByteString.CopyFrom(buffer),
File = ByteString.CopyFrom(_mainImageBuffer),
Mime = _mainImageFile.ContentType,
FileName = Path.GetFileNameWithoutExtension(_mainImageFile.Name)
};
}
if (_thumbnailImageFile != null)
if (_thumbnailImageFile != null && _thumbnailImageBuffer != null)
{
var buffer = new byte[_thumbnailImageFile.Size];
await _thumbnailImageFile.OpenReadStream(_maxAllowedSize).ReadAsync(buffer);
Model.ThumbnailFile = new ImageFileModel
{
File = ByteString.CopyFrom(buffer),
File = ByteString.CopyFrom(_thumbnailImageBuffer),
Mime = _thumbnailImageFile.ContentType,
FileName = Path.GetFileNameWithoutExtension(_thumbnailImageFile.Name)
};
@@ -30,7 +30,7 @@
Color="Color.Primary"
ButtonType="ButtonType.Button"
StartIcon="@Icons.Material.Filled.Image"
Style="cursor:pointer;">
>
انتخاب تصویر
</MudButton>
</ActivatorContent>
@@ -77,16 +77,24 @@
{
<MudItem xs="6" sm="4" md="3">
<MudPaper Class="pa-2">
<div style="position:relative; cursor:pointer;"
<div style="position:relative;"
@onclick="@(() => OpenPreview(item))">
@* <img src="@item.ImageThumbnailPath" *@
@* alt="@item.Title" *@
@* style="width:100%; height:120px; object-fit:cover; border-radius:4px;" /> *@
<Image Src="@item.ImageThumbnailPath"
Width="25"
Height="25"
ObjectPosition="ObjectPosition.Center"
ObjectFit="ObjectFit.Fill" />
@if (!string.IsNullOrWhiteSpace(item.ImageThumbnailPath))
{
<img src="@GetFullImageUrl(item.ImageThumbnailPath)"
alt="@item.Title"
style="width:100%; height:120px; object-fit:cover; border-radius:4px;" />
}
else if (!string.IsNullOrWhiteSpace(item.ImagePath))
{
<img src="@GetFullImageUrl(item.ImagePath)"
alt="@item.Title"
style="width:100%; height:120px; object-fit:cover; border-radius:4px;" />
}
else
{
<MudSkeleton SkeletonType="SkeletonType.Rectangle" Height="120px" Width="100%" />
}
<MudIconButton Icon="@Icons.Material.Filled.DeleteOutline"
Color="Color.Error"
Size="Size.Small"
@@ -2,6 +2,7 @@ using CMSMicroservice.Protobuf.Protos.Products;
using Google.Protobuf;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Forms;
using Microsoft.Extensions.Configuration;
using MudBlazor;
namespace BackOffice.Pages.Products.Components;
@@ -10,6 +11,7 @@ public partial class GalleryDialog
{
[CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!;
[Inject] public ProductsContract.ProductsContractClient ProductsContract { get; set; } = default!;
[Inject] private IConfiguration Configuration { get; set; } = default!;
[Parameter] public long ProductId { get; set; }
[Parameter] public string ProductTitle { get; set; } = string.Empty;
@@ -58,10 +60,10 @@ public partial class GalleryDialog
if (file != null)
{
var buffer = new byte[file.Size];
await file.OpenReadStream(_maxAllowedSize).ReadAsync(buffer);
_imageBuffer = buffer;
_previewImage = $"data:{file.ContentType};base64," + Convert.ToBase64String(buffer);
using var ms = new MemoryStream();
await file.OpenReadStream(_maxAllowedSize).CopyToAsync(ms);
_imageBuffer = ms.ToArray();
_previewImage = $"data:{file.ContentType};base64," + Convert.ToBase64String(_imageBuffer);
StateHasChanged();
}
}
@@ -76,9 +78,9 @@ public partial class GalleryDialog
if (_imageBuffer == null)
{
var buffer = new byte[_file.Size];
await _file.OpenReadStream(_maxAllowedSize).ReadAsync(buffer);
_imageBuffer = buffer;
using var ms = new MemoryStream();
await _file.OpenReadStream(_maxAllowedSize).CopyToAsync(ms);
_imageBuffer = ms.ToArray();
}
var request = new AddProductImageRequest
@@ -113,7 +115,7 @@ public partial class GalleryDialog
var parameters = new DialogParameters<ImagePreviewDialog>
{
{ x => x.ImageUrl, item.ImagePath },
{ x => x.ImageUrl, GetFullImageUrl(item.ImagePath) },
{ x => x.Title, item.Title }
};
@@ -133,6 +135,14 @@ public partial class GalleryDialog
private void Close() => MudDialog.Close();
private string GetFullImageUrl(string? path)
{
if (string.IsNullOrWhiteSpace(path)) return string.Empty;
if (path.StartsWith("data:") || path.StartsWith("http")) return path;
var baseUrl = Configuration["GwUrl"]?.TrimEnd('/') ?? "";
return baseUrl + path;
}
private class GalleryItemViewModel
{
public long ProductGalleryId { get; set; }
@@ -1,7 +1,6 @@
@using CMSMicroservice.Protobuf.Protos.Products
@using Microsoft.AspNetCore.Components.Forms
@using Tizzani.MudBlazor.HtmlEditor
@using BackOffice.Common.BaseComponents
@using BackOffice.Pages.AutoComplete
<MudDialog>
@@ -10,7 +9,7 @@
<MudStack Justify="Justify.Center" AlignItems="AlignItems.Center">
@if (!string.IsNullOrWhiteSpace(_mainImagePreview))
{
<Image Src="@(_mainImagePreview)" Width="220" Height="140" ObjectPosition="ObjectPosition.Center" ObjectFit="ObjectFit.Cover" />
<img src="@GetFullImageUrl(_mainImagePreview)" alt="تصویر اصلی" style="width:220px; height:140px; object-fit:cover; border-radius:4px;" />
}
else
{
@@ -26,7 +25,7 @@
Color="Color.Primary"
ButtonType="ButtonType.Button"
StartIcon="@Icons.Material.Filled.Image"
Style="cursor:pointer;">
>
تغییر تصویر اصلی
</MudButton>
</ActivatorContent>
@@ -38,7 +37,7 @@
<MudTextField T="string" @bind-Value="Model.Title" Disabled="_isLoading" Label="عنوان" Variant="Variant.Outlined" Margin="Margin.Dense" />
</MudItem>
<MudItem xs="6">
<MudNumericField T="long" HideSpinButtons="true" @bind-Value="Model.Price" Format="N0" Immediate="true" Disabled="_isLoading" Label="قیمت" Variant="Variant.Outlined" Margin="Margin.Dense" />
<MudNumericField T="long" HideSpinButtons="true" @bind-Value="Model.Price" Format="N0" Immediate="true" Disabled="_isLoading" Label="قیمت (ریال)" Variant="Variant.Outlined" Margin="Margin.Dense" />
</MudItem>
</MudStack>
@@ -50,7 +49,7 @@
<MudTextField T="string" @bind-Value="Model.ShortInfomation" Disabled="_isLoading" Label="خلاصه" Variant="Variant.Outlined" Margin="Margin.Dense" />
<MudHtmlEditor @bind-Html="Model.FullInformation">
<MudHtmlEditor @bind-Html="Model.FullInformation" MinHeight="300px">
<MudHtmlToolbarOptions InsertImage="false" />
</MudHtmlEditor>
@@ -69,6 +68,14 @@
<MudStack Spacing="1">
<MudText Typo="Typo.subtitle2">تصویر بندانگشتی</MudText>
@if (!string.IsNullOrWhiteSpace(_thumbnailPreview))
{
<img src="@GetFullImageUrl(_thumbnailPreview)" alt="بندانگشتی" style="width:120px; height:80px; object-fit:cover; border-radius:4px;" />
}
else if (!string.IsNullOrWhiteSpace(Model.ThumbnailPath))
{
<img src="@GetFullImageUrl(Model.ThumbnailPath)" alt="بندانگشتی" style="width:120px; height:80px; object-fit:cover; border-radius:4px;" />
}
<MudFileUpload T="IBrowserFile" Accept="image/*" FilesChanged="OnThumbnailSelected">
<ActivatorContent>
<MudButton HtmlTag="label"
@@ -76,7 +83,7 @@
Color="Color.Secondary"
ButtonType="ButtonType.Button"
StartIcon="@Icons.Material.Filled.Image"
Style="cursor:pointer;">
>
تغییر تصویر بندانگشتی (اختیاری)
</MudButton>
</ActivatorContent>

Some files were not shown because too many files have changed in this diff Show More