Compare commits

..

171 Commits

Author SHA1 Message Date
masoodafar-web 3eff5b6dfa Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 10m5s
2026-08-09 02:33:49 +03:30
masoodafar-web aa97d25302 feat(report): optimize customer package purchases report by removing pagination and enhancing filter application
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m55s
- Simplified the data retrieval process by eliminating pagination, allowing for all matching records to be fetched in a single request.
- Improved filter application logic to streamline the report generation process.
- Updated the handling of the result set to ensure compatibility with the new data retrieval method.
2026-08-09 00:40:36 +03:30
masoodafar-web 24ae4ec15c feat(date-range-picker): enhance date range picker functionality with external parameters and notifications
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 7m20s
- Added support for external From and To parameters to synchronize date range selections.
- Implemented OnParametersSet to handle updates from parent components without triggering unnecessary updates.
- Introduced NotifyRangeChangedAsync to streamline notifications for date range changes.
- Refactored date range application logic to improve clarity and maintainability.
2026-08-09 00:02:54 +03:30
masoodafar-web c3ee46ff62 feat(club): update club reports and navigation with new customer package insights
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 6m14s
- Updated ReportLabels.cs to refine purchase method labels for clarity.
- Enhanced ClubHub.razor by adding a new tab for customer package reports and adjusting tab indices.
- Modified ClubPackagePurchasesReport.razor to reflect updated purchase method options.
- Updated NavMenu.razor to include a link to the new customer packages report.
- Bumped Foursat.CMSMicroservice.Protobuf version to 0.0.208 for compatibility with recent changes.
2026-08-08 21:59:30 +03:30
masoodafar-web e9f8328404 feat(export): enhance CSV and Excel export functionality across multiple pages
- Updated CsvExportHelper to support Excel XML format for exporting data, improving compatibility with Excel.
- Refactored export logic in DiscountOrdersMainPage, SalesReports, OrderSalesReports, and UserOrderMainPage to utilize the new Excel export method.
- Changed file extensions from .csv to .xls for better clarity in exported files.
- Improved header and row data handling for consistency in exported reports.
2026-08-08 21:43:54 +03:30
masoodafar-web 6a9d142dc6 refactor(csv-export): streamline CSV export logic and remove redundant methods
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 6m36s
- Replaced manual CSV string construction with CsvExportHelper for better readability and maintainability across DiscountOrdersMainPage, SalesReports, OrderSalesReports, and UserOrderMainPage.
- Removed obsolete EscapeCsv methods to simplify the codebase.
- Enhanced CSV export functionality to utilize a unified base64 conversion method for file saving.
2026-08-07 18:40:00 +03:30
masoodafar-web 1d78633e17 feat(order-status): add "Ready for Office Pickup" status and update related components
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 6m50s
- Introduced a new order status "Ready for Office Pickup" across various components including DiscountOrdersMainPage, SalesReports, and UserOrder pages.
- Updated the color and text representations for the new status in relevant dialogs and services.
- Enhanced the DiscountOrderService to handle the new status in delivery processing logic.
- Bumped Foursat.CMSMicroservice.Protobuf version to 0.0.207 for compatibility with the new order status features.
2026-08-07 17:35:19 +03:30
masoodafar-web cbc61ad0f5 feat(order-details): add mobile display logic and remove unused status methods
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 6m36s
- Introduced a new property to display the user's mobile number or fallback to the address phone number in OrderDetailsDialog.
- Removed obsolete methods for determining order status color and text, streamlining the component's code.
2026-08-07 17:10:30 +03:30
masoodafar-web 68d0aed468 feat(invoice): add print functionality for order invoices and enhance order details
- Implemented a print button in OrderDetailsDialog and UserOrderDetailsDialog for generating invoices.
- Updated DiscountOrdersMainPage to populate order details from the list.
- Enhanced DiscountOrderDetailsDto to include UserFullName, UserMobile, and VatAmount for invoice generation.
- Integrated JavaScript functionality to convert HTML invoices to PDF format.
2026-08-07 17:08:23 +03:30
masoodafar-web a9aa03d6a8 feat(payment): enhance order details with payment status indicators and update proto version
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 9m37s
- Updated OrderDetailsDialog.razor to display payment status using dynamic color and icon based on the payment status value.
- Added helper methods in OrderDetailsDialog.razor.cs to determine payment status color, text, and icon.
- Enhanced UserOrderDetailsDialog.razor to conditionally display postal code and user mobile information.
- Updated DiscountOrderService to include PaymentStatusValue from the response.
- Bumped Foursat.CMSMicroservice.Protobuf version to 0.0.206 for compatibility with new payment features.
2026-08-07 15:52:55 +03:30
masoodafar-web e63b80fe47 Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Failing after 24m44s
2026-07-26 00:52:14 +03:30
masoodafar-web 4115f64fff feat(payment): add manual credit charge functionality and update related components
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
- Introduced new manual credit charge endpoints in ADMIN-SERVICES.md.
- Updated ReportLabels.cs and LedgerTransactions.razor to include manual credit wallet charge options.
- Enhanced NavMenu.razor with a link to the manual credit charges page.
- Bumped Foursat.CMSMicroservice.Protobuf version to 0.0.205 for compatibility with new features.
2026-07-25 23:54:57 +03:30
masoodafar-web 7d892c2f6a Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Failing after 20m45s
2026-06-26 19:30:31 +03:30
masoodafar-web d757c8220c docs: update ADMIN-SERVICES.md with new ledger transaction reports and additional details
- Added new transaction report paths and clarified their purposes in the documentation.
- Updated the proto version to 0.0.203 and included deployment notes for the new ledger transactions feature.
- Enhanced read-only report descriptions to include CSV export capabilities.
2026-06-26 19:30:20 +03:30
masoodafar-web b53d9facc9 feat(transactions): add ledger transactions feature and update related components
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
- Introduced new ledger transactions page in NavMenu.razor and AppBreadcrumb.razor.
- Enhanced ReportLabels.cs with transaction status and type labels.
- Updated ConfigureService.cs to include TransactionsContractClient for gRPC.
- Bumped Foursat.CMSMicroservice.Protobuf version to 0.0.203 for compatibility with new features.
- Added deployment notes for June 1405 regarding read-only reports and proto updates.
2026-06-26 16:38:58 +03:30
masoodafar-web 5a004e5c88 feat(club): enhance club management with new reports and update navigation
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 19m16s
- Added new report tabs for package purchases, cycles, and history in ClubHub.razor.
- Updated MemberDetailsDialog.razor to improve display of membership details and alert for history access.
- Introduced PaymentTransaction reporting in Transactions.razor with enhanced filtering options and summary statistics.
- Updated BackOffice.csproj to conditionally reference the CMS Protobuf project for local development.
- Modified NavMenu.razor to include a link to the new transaction report page.
2026-06-22 23:38:23 +03:30
masoodafar-web 5d83eb4e05 Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 5m32s
2026-06-20 23:46:06 +03:30
masoodafar-web 6126ecf023 chore: update .gitignore, change imagePullPolicy to Always in backoffice deployment, and add new columns for ChangeDiscountValue and CurrentDiscountBalance in WalletManagementPage. Bump Foursat.CMSMicroservice.Protobuf to 0.0.200.
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
2026-06-20 23:38:14 +03:30
masoodafar-web 31f0fc4860 fix(k8s): change imagePullPolicy to IfNotPresent in backoffice deployment
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 14m45s
2026-06-14 00:08:31 +03:30
masoodafar-web 0a2c5f913b Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 6m49s
2026-06-08 02:09:47 +03:30
masoodafar-web 2f040cab02 refactor: update terminology for team bonuses across multiple components
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 14m2s
- Changed "پاداش تیمی" to "پاداش های دریافتی" in WithdrawalReports, ManualPayments, ManualPaymentDialog, and WalletManagementPage for consistency and clarity.
2026-06-08 02:09:33 +03:30
masoodafar-web 933cdb1094 Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Has been cancelled
Deploy blog slug UI fix to production.
2026-06-08 02:06:52 +03:30
masoodafar-web 4596296d97 fix(blog): add optional slug field with auto-generate hint
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
BackOffice no longer requires manual slug; CMS generates it when empty.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 02:06:31 +03:30
masoodafar-web f2316eafe4 Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 19m22s
2026-05-18 01:57:57 +03:30
masoodafar-web 9c6149bbb9 fix(ci): add --no-restore to publish step; suppress pre-existing MUD0002/CA2022/BL0005 warnings
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 17m42s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 01:19:32 +03:30
masoodafar-web 8afc684098 feat(wallets): show DiscountBalance column in wallet management page; bump proto to 0.0.199
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 01:14:07 +03:30
masoodafar-web c17ad68c30 feat(commission/payouts): add bulk wallet credit button for pending payouts
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 6m1s
- UserPayouts.razor: add 'واریز به کیف پول' button in toolbar, enabled only when a week is selected; shows loading spinner while processing
- UserPayouts.razor.cs: implement BulkCreditWallets — confirms with dialog, calls BulkCreditPayoutsAsync, shows credited count + amount in snackbar
- BackOffice.csproj: bump Foursat.CMSMicroservice.Protobuf to 0.0.198

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 00:36:30 +03:30
masoodafar-web 9378b7474c fix: remove hardcoded GW_URL from staging manifest
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 6m27s
GW_URL in the K8s manifest was injected into all appsettings*.json files
by docker-entrypoint.sh, overriding even appsettings.Production.json.
Now only BLAZOR_ENVIRONMENT is set; GwUrl comes from the correct
appsettings.{Environment}.json per environment.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 01:35:20 +03:30
masoodafar-web 68d7647cb1 Bump Foursat.CMSMicroservice.Protobuf to 0.0.197 for proto parity with CMS.
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 5m43s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 22:24:13 +03:30
masoodafar-web c4712a00ac Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 8m43s
2026-05-04 23:54:56 +03:30
masoodafar-web 3b4562665d fix(commission/dashboard): improve feedback on weekly calculation trigger success and error handling
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 7m40s
2026-05-04 18:44:40 +03:30
masoodafar-web 52c8965704 Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 5m44s
2026-05-01 00:15:07 +03:30
masoodafar-web 7fb2062721 feat(dashboard): enhance dashboard layout and loading indicators, update week selector and package display
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 6m30s
feat(network): add activation type and package name display in network tree viewer
style(admin-org-chart): refine styles for node cards and add new activation/renewal badges
fix(admin-org-chart): adjust node dimensions for better visibility and add tooltip enhancements
2026-05-01 00:06:53 +03:30
masoodafar-web 797bfef964 fix(commission/dashboard): adjust week selector anchor and transform origins for better alignment
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 7m4s
2026-04-29 00:35:27 +03:30
masoodafar-web 6d8d681046 Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 6m29s
2026-04-28 23:30:42 +03:30
masoodafar-web b7d1fe13c7 fix(commission/dashboard): fix PackageId type - use long? directly, add global.json for SDK 9.0
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 12m48s
2026-04-24 16:56:11 +03:30
masoodafar-web 67f57ce99f fix(commission/dashboard): fix TriggerWeeklyCalculationRequest.PackageId assignment - use long? directly
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 12m49s
2026-04-24 16:39:22 +03:30
masoodafar-web c4cbe94ed7 fix(commission/dashboard): fix Int64Value construction - use constructor instead of non-existent FromLong
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 11m29s
2026-04-24 05:20:31 +03:30
masoodafar-web ed24f505d3 chore(proto): update CMSMicroservice.Protobuf to 0.0.193
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 11m30s
2026-04-24 04:20:25 +03:30
masoodafar-web 3c2a0edca8 feat(commission/dashboard): add package selector for per-package calculation
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 11m26s
- Dashboard: added PackageSelect component to filter/trigger calculation per package
- LoadPoolData: now sends PackageId in GetWeeklyCommissionPoolRequest
- TriggerCalculation: now sends PackageId in TriggerWeeklyCalculationRequest
- Pool Details: shows PackageTitle in table
2026-04-24 03:43:26 +03:30
masoodafar-web aed6258fb3 fix: update GW_URL in Dockerfile to correct endpoint
Build and Deploy to Production / build-and-deploy (push) Successful in 15m55s
2026-03-15 13:27:48 +03:30
masoodafar-web e6d1d833ec fix: update GW_URL in backoffice deployment to correct endpoint
Build and Deploy to Production / build-and-deploy (push) Successful in 15m40s
2026-03-15 13:04:13 +03:30
masoodafar-web a8c52db544 fix: resolve MudBlazor v8 breaking changes (MUD0002 warnings)
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 15m20s
- MudListItem: Selected → Class with conditional CSS
- MudTextField: Dense → Margin="Margin.Dense"
- MudSwitch: remove HelperText (merged into Label)
- MudDatePicker: remove T parameter (no longer generic in v8)
2026-03-15 12:00:02 +03:30
masoodafar-web c0c7bb3ac5 fix: resolve MudBlazor v8 breaking changes (MUD0002 warnings)
Build and Deploy to Production / build-and-deploy (push) Successful in 16m3s
- MudListItem: Selected → Class with conditional CSS
- MudTextField: Dense → Margin="Margin.Dense"
- MudSwitch: remove HelperText (merged into Label)
- MudDatePicker: remove T parameter (no longer generic in v8)
2026-03-15 11:58:37 +03:30
masoodafar-web d0fe99492d fix: update proto to 0.0.192 on production (merge conflict chose wrong version) 2026-03-15 05:29:46 +03:30
masoodafar-web d38f50584c fix: resolve version conflict for Foursat.CMSMicroservice.Protobuf package
Build and Deploy to Production / build-and-deploy (push) Failing after 13m13s
2026-03-15 03:21:52 +03:30
masoodafar-web 1c67d7798a Merge branch 'kub-stage' into production 2026-03-15 03:21:44 +03:30
masoodafar-web 2a7bb0592d feat: add K8s deployment manifest with GW_URL and BLAZOR_ENVIRONMENT
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 15m26s
- Create k8s/staging/backoffice-deployment.yaml with env vars:
  GW_URL=https://cms.se.kbs1.ir (injected into appsettings at startup)
  BLAZOR_ENVIRONMENT=Staging
- Update CI/CD to apply K8s manifests on deploy (like CMS does)
- This makes configuration persistent across CI/CD deployments
2026-03-15 03:11:38 +03:30
masoodafar-web 9699833ac9 fix: exclude appsettings from service worker cache
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
appsettings*.json files were cached by the service worker, causing
stale GwUrl (cms.kbs1.ir) to persist in the browser even after the
server files were updated. Now appsettings files are always fetched
fresh from the server, where the entrypoint injects the correct GW_URL.
2026-03-15 03:07:14 +03:30
masoodafar-web b3e6066c90 fix: don't send expired JWT tokens with gRPC requests
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 15m19s
AppTokenProvider now checks token expiry before returning it.
Expired tokens are removed from localStorage and not sent.

This fixes 401 errors on anonymous endpoints (login/OTP) caused by
the gRPC channel attaching an old expired Bearer token to every request.
CMS validates the token even on unauthenticated endpoints and rejects
expired ones with 401.
2026-03-15 01:39:27 +03:30
masoodafar-web 148b8e4011 fix: inject GwUrl into appsettings at container startup
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 15m1s
The blazor-environment header is stripped by the Blazor WASM service
worker cache, causing Blazor to fall back to Production environment
and load appsettings.Production.json (GwUrl=https://cms.kbs1.ir).

Fix: use sed in docker-entrypoint.sh to replace GwUrl in ALL
appsettings*.json files at container startup with the GW_URL env var.
Default GW_URL=https://cms.se.kbs1.ir for staging deployment.
2026-03-15 00:42:38 +03:30
masoodafar-web 1ef5edf939 fix: add blazor-environment header to nginx for staging config
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 14m30s
BackOffice WASM app defaulted to Production environment because
nginx didn't set the blazor-environment response header. This caused
the app to load appsettings.Production.json (GwUrl=https://cms.kbs1.ir)
instead of appsettings.Staging.json (GwUrl=https://cms.se.kbs1.ir),
resulting in 401 gRPC errors on the login page.

- Add docker-entrypoint.sh that generates nginx config with blazor-environment header
- Default BLAZOR_ENVIRONMENT=Staging (configurable via K8s env var)
- This ensures the WASM app loads the correct staging configuration
2026-03-15 00:19:04 +03:30
masoodafar-web 76dbee7d41 ci: retry - pushed missing 9.0.12 SDK packages to Nexus
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 14m27s
2026-03-12 07:01:46 +03:30
masoodafar-web 148a9e0493 fix: remove local nupkg source from NuGet.config (breaks Docker builds)
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 11m28s
2026-03-12 02:28:07 +03:30
masoodafar-web 851a1bbc4f ci: retry build - all NuGet packages pushed to Nexus
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 1m21s
2026-03-12 01:14:19 +03:30
masoodafar-web aa1d21a616 Revert "fix: bypass broken Nexus, use nuget.org + local proto build"
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 1m28s
This reverts commit 7147e9930f.
2026-03-12 00:06:24 +03:30
masoodafar-web 7147e9930f fix: bypass broken Nexus, use nuget.org + local proto build
- NuGet.config: nuget.org for standard packages, local nupkg for proto
- Dockerfile: COPY nupkg/ before restore
- CI: build proto from CMS source before Docker build
2026-03-12 00:03:49 +03:30
masoodafar-web 85208da1b5 feat: manual payment - replace amount with package selector
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 1m39s
- Remove manual amount input field
- Add package selector dropdown (shows name + price)
- Pre-select base package, show price in alert
- Validate package selection before submit
- Send package_id to CMS, proto NuGet 0.0.192
2026-03-11 23:44:36 +03:30
masoodafar-web ea7414dc7f fix: update Foursat.CMSMicroservice.Protobuf package version to 0.0.191
Build and Deploy to Production / build-and-deploy (push) Successful in 8m40s
2026-02-28 05:07:46 +03:30
masoodafar-web a450d9a001 Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Failing after 2m41s
2026-02-28 04:46:38 +03:30
masoodafar-web 92c992249e F4: ستون پکیج در CSV، اصلاح rename UserWalletChangeLog→UserWalletHistory
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 3m8s
- WithdrawalRequests: اضافه شدن PackageName به مدل و CSV
- ManualPayments: اضافه شدن ستون پکیج به CSV
- ConfigureService: اصلاح using و DI برای UserWalletHistory
- WalletManagementPage: اصلاح تمام رفرنس‌های UserWalletChangeLog
- NuGet: v0.0.189
2026-02-27 08:58:44 +03:30
masoodafar-web 6939780cd1 feat(Q28): UI Guidance alerts across BackOffice (G8-G13)
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 2m38s
Q28: Added informational/warning alerts to 6 BO pages:
G8: PackageCRUD — price change impact warning
G9: ClubFeatures — feature matrix explanation
G10: ManualPayments — package price matching warning
G11: Commission Dashboard+Payouts — independent pool info
G13: ClubMembers — package filter guidance
2026-02-27 05:41:03 +03:30
masoodafar-web e6cf90e3fa fix: ManualPaymentDialog — remove hardcoded 56M, enable Amount field
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 2m44s
- Default amount set to 0 (admin must enter)
- Amount field now editable with Required validation
2026-02-27 02:45:11 +03:30
masoodafar-web e0203541bf feat(F2+T4.13): ChangeNetworkParent dialog + PackageFeature checkbox matrix
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 2m32s
F2: ChangeNetworkParent UI
- Add ChangeParentDialog with parent ID, leg, reason fields
- Add 'تغییر والد' button to UserNetworkInfo operations

T4.13: PackageFeature checkbox matrix
- Update NuGet to v0.0.188
- Add ClubFeature checkbox list to CreateDialog and UpdateDialog
- Load features via ConfigClient.GetClubFeaturesAsync
- Sync selected FeatureIds to proto request on save
2026-02-26 22:00:20 +03:30
masoodafar-web 8be98ae33e feat(commission): add per-package filtering to commission pages
- New PackageSelect component: reusable dropdown for package filtering
- UserPayouts: add package filter + package column with MudChip
- BalancesReport: add package filter + package column with MudChip
- Update NuGet Foursat.CMSMicroservice.Protobuf to v0.0.187
2026-02-26 21:08:16 +03:30
masoodafar-web c96377a674 Phase 8d: NuGet bump 0.0.185 → 0.0.186 (proto cleanup) 2026-02-26 18:00:47 +03:30
masoodafar-web 89f5241680 Phase 8b: Expand Package CRUD with all package-based fields
- CreateDialog: Add 12 new fields (SortOrder, ActivationFee, DiscountMultiplier,
  MagicWalletMultiplier, MagicWalletMaxDeposit, MagicWalletMaxCredit,
  MaxBalancesPerLeg, MaxNetworkLevel, IsActive, IsBasePackage,
  SupportsDirectPurchase, SupportsDayaPurchase)
- UpdateDialog: Same 12 new fields added
- PackageMainPage grid: Add Price, SortOrder, IsActive, IsBasePackage columns
- Dialog size: Small → Medium for expanded forms
- CreateNew defaults: IsActive=true, DiscountMultiplier=2.0, etc.
- Fix HasPurchasedGoldenPackage → HasPurchasedPackage in UserNetworkInfo
- NuGet: 0.0.184 → 0.0.185 + local feed source
2026-02-26 17:44:53 +03:30
masoodafar-web f1b0085181 Cleanup: Rename 'پکیج طلایی' → 'خرید پکیج' in UserNetworkInfo 2026-02-26 02:03:26 +03:30
masoodafar-web 60c91438ec fix: rename wallet labels — اصلی، اعتباری، پاداش تیمی
Build and Deploy to Production / build-and-deploy (push) Successful in 11m53s
- شبکه → پاداش تیمی (WalletManagement, ManualPayments)
- تخفیف → اعتباری (ManualPayments, OrderDetails, DiscountOrders)
- طلایی → پاداش تیمی (WithdrawalReports)

Files: WalletManagementPage, ManualPayments, ManualPaymentDialog,
       WithdrawalReports, DiscountOrdersMainPage, OrderDetailsDialog
2026-02-23 23:26:59 +03:30
masoodafar-web 172101d0ab fix: rename wallet labels — اصلی، اعتباری، پاداش تیمی
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 9m58s
- شبکه → پاداش تیمی (WalletManagement, ManualPayments)
- تخفیف → اعتباری (ManualPayments, OrderDetails, DiscountOrders)
- طلایی → پاداش تیمی (WithdrawalReports)

Files: WalletManagementPage, ManualPayments, ManualPaymentDialog,
       WithdrawalReports, DiscountOrdersMainPage, OrderDetailsDialog
2026-02-23 23:24:41 +03:30
masoodafar-web 1d8f34ae6c chore: prevent appsettings.Production.json from merging — keep ours strategy
Build and Deploy to Production / build-and-deploy (push) Successful in 8m7s
2026-02-23 00:03:28 +03:30
masoodafar-web 76b7211a5c chore: prevent appsettings.Production.json from merging — keep ours strategy
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 7m43s
2026-02-23 00:03:14 +03:30
masoodafar-web fe72774d08 feat: ActivateClubDialog — dropdown پکیج + حذف مدت زمان + ForceActivation
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
- جایگزینی MudNumericField پکیج با MudSelect dropdown (لود از سرور)
- حذف فیلد مدت زمان (DurationMonths) — عضویت باشگاه زمانی نیست
- ارسال ForceActivation=true در request (bypass order validation)
- اضافه کردن PackageContract client injection
- Proto NuGet بروزرسانی به 0.0.184
2026-02-22 23:51:55 +03:30
masoodafar-web bdea2e8aef Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 7m46s
2026-02-22 22:23:35 +03:30
masoodafar-web 0386a2b1aa feat: club UserAutoComplete + member filter + wallet user name display
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 7m59s
- ActivateClubDialog: replaced numeric UserId input with UserAutoComplete
- ClubMembers: added UserAutoComplete filter in toolbar, wired to proto user_id
- WalletManagementPage: display user name + ID instead of just UserId
- Updated proto NuGet to 0.0.183 (user_name field)
2026-02-22 22:03:27 +03:30
masoodafar-web 8f29fbaf71 fix: replace ریال with تومان across all 30 admin UI files
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 7m55s
2026-02-22 21:30:47 +03:30
masoodafar-web 7a62e3caac docs: move docs to totalDoc, replace with placeholders
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 10m16s
2026-02-18 21:44:14 +03:30
masoodafar-web 4eaca9bc4c docs: update changelog with phase 16 (site pages) and phase 17 (inventory UX)
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
2026-02-18 01:05:51 +03:30
masoodafar-web e541aec115 merge: kub-stage → production (discount autocomplete + smart AddStockDialog + site pages admin)
Build and Deploy to Production / build-and-deploy (push) Successful in 8m9s
2026-02-18 01:01:29 +03:30
masoodafar-web c9affe62bd feat: smart product autocomplete for inventory dialogs
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m14s
- DiscountProductsAutoComplete: new autocomplete component for discount
  products with search/debounce (mirrors ProductsAutoComplete pattern)
- AddStockDialog: replace manual product ID input with smart autocomplete
  - When 'regular product' selected → shows ProductsAutoComplete
  - When 'discount product' selected → shows DiscountProductsAutoComplete
  - Admin never has to type product IDs manually
  - Product type selector controls which autocomplete is shown
- Validates product selection before submit
2026-02-18 00:43:12 +03:30
masoodafar-web 6147499f9c feat: replace raw JSON editor with Shopify-style typed settings editors
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m7s
- Create PageSettingsModels.cs: typed DTOs for landing, about, contact, licenses
- LandingSettingsEditor: accordion sections for hero buttons, trust badges, steps,
  features, stats, testimonials, FAQs, CTA — all with add/remove list management
- AboutSettingsEditor: vision/mission text + icon fields + section titles
- ContactSettingsEditor: address, phone, email, working hours + social media links
- LicensesSettingsEditor: page description + display style selector
- PageSettingsEditDialog: auto-switch editor by PageKey, JSON↔Model serialize/deserialize
- Fallback to raw JSON textarea for unknown page keys
2026-02-17 23:50:34 +03:30
masoodafar-web 3409133757 feat: Add SitePageSettings management UI
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m21s
- ISitePageSettingsService + SitePageSettingsService (gRPC client)
- PageSettingsManagementPage: 4 cards for fixed pages
- PageSettingsEditDialog: edit title, hero, meta, settingsJson
- PageImagesDialog: CRUD for page images (licenses, team, values)
- PageImageEditDialog: add/edit single image with upload
- Proto NuGet updated to 0.0.180
- NavMenu: added link to page settings
2026-02-17 22:34:59 +03:30
masoodafar-web af51c02d3d chore: clean up commented-out GwUrl lines in appsettings
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 7m16s
2026-02-17 02:56:07 +03:30
masoodafar-web b9171d548b docs: فاز ۱۵.۴-۱۵.۶ — K8S_SERVER, appsettings.Production, nginx image fix
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 7m42s
2026-02-17 02:11:49 +03:30
masoodafar-web 743403e4d4 fix: nginx image path library/nginx:alpine → nginx:alpine on production
Build and Deploy to Production / build-and-deploy (push) Successful in 7m20s
2026-02-17 02:09:03 +03:30
masoodafar-web 44fd5f29d9 merge: kub-stage into production — fix prod workflow + appsettings GwUrl=cms.kbs2.ir
Build and Deploy to Production / build-and-deploy (push) Has been cancelled
2026-02-17 02:00:29 +03:30
masoodafar-web e7bed65149 fix: production deployment — appsettings.Production.json + fix K8S_SERVER + set image
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
- appsettings.Production.json: GwUrl=cms.kbs1.ir
- prod-deploy.yml: K8S_SERVER 194.5.195.53 → 45.149.79.127
- prod-deploy.yml: kubectl set image instead of rollout restart
2026-02-17 01:58:36 +03:30
masoodafar-web 6fc00e134f docs: اضافه شدن فاز ۱۵ به CHANGELOG — Production Deploy, فیکس cross-deploy, Proto v0.0.179 2026-02-17 01:44:25 +03:30
masoodafar-web fac3d2c85e Fix production workflow: use SERVER_PASSWORD, add docker login, use set image for deploy
Build and Deploy to Production / build-and-deploy (push) Failing after 6m25s
2026-02-17 01:24:32 +03:30
masoodafar-web 51c20116a7 Remove staging workflow files from production branch to prevent cross-deployment 2026-02-17 01:16:13 +03:30
masoodafar-web 4454b8dd3a Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Failing after 6s
Features merged:
- Discount shop management (CRUD, inventory, bulk edit)
- Blog and content management improvements
- Docker build fixes (NuGet package reference)
- UI improvements across multiple components

Production workflow and Dockerfile preserved.
2026-02-17 00:57:14 +03:30
masoodafar-web aea614e095 docs: add Phase 14 — Docker build + deployment fixes
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 7m36s
2026-02-16 02:23:42 +03:30
masoodafar-web 6ec221cb41 fix: use local registry for nginx:alpine image in Dockerfile
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 7m16s
- Changed FROM nginx:alpine to 194.5.195.53:32082/nginx:alpine
- Fixes TLS handshake timeout when pulling from Docker Hub
2026-02-16 02:02:47 +03:30
masoodafar-web 05af68bae1 ci: re-trigger deploy (added SERVER_PASSWORD secret)
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 7m9s
2026-02-16 01:46:04 +03:30
masoodafar-web 4fd039b250 fix: switch to NuGet package reference for CMS Protobuf (Docker build fix)
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
- Changed from ProjectReference to PackageReference (v0.0.178)
- Removed COPY libs/ from Dockerfile (no longer needed)
- Docker build context (src/) cannot resolve ../../../CMS path
- NuGet package restored from Nexus registry
2026-02-16 01:42:25 +03:30
masoodafar-web 49017027d0 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
2026-02-16 00:56:39 +03:30
masoodafar-web d84dfe3bc5 refactor: enhance Docker daemon startup and registry login in deployment workflows
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 2m19s
2026-02-11 00:41:32 +03:30
masoodafar-web 8d9942ddc2 Refactor: Update Protobuf references to CMSMicroservice across User, UserAddress, UserOrder, UserRole, and related services
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 3m9s
- Changed Protobuf imports from BackOffice.BFF to CMSMicroservice for User, UserAddress, UserOrder, UserRole components and services.
- Updated appsettings for direct connection to CMS, removing BFF references.
- Adjusted logic in services to accommodate new Protobuf structure and fields.
- Moved documentation to totalDoc directory.
2026-02-10 22:05:13 +03:30
masoodafar-web 2bbb1ba4c7 chore: Update production workflow to use Nexus registry (no proxy needed)
Build and Deploy to Production / build-and-deploy (push) Failing after 0s
- Changed Dockerfile base images to use 194.5.195.53:32082 (Nexus)
- Updated workflow to use docker-sshpass image from Nexus
- Removed HTTP_PROXY/HTTPS_PROXY dependencies
- Changed deploy method to SSH instead of kubeconfig
- Registry: 194.5.195.53:30080 (Gitea internal)
- K8S Server: 45.149.79.127 (Production)
2026-01-29 22:04:31 +03:30
masoodafar-web 7a27347142 fix: increase Docker daemon startup timeout to 3 minutes
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 0s
2026-01-29 17:13:49 +03:30
masoodafar-web 5d563aa405 trigger: test offline CI/CD pipeline
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
2026-01-29 07:03:31 +03:30
masoodafar-web b05f92195a refactor: use docker-sshpass image (fully offline)
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
2026-01-29 06:49:39 +03:30
masoodafar-web fc5d81425a refactor: simplified pipeline with offline Nexus + auto deploy
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
2026-01-29 06:12:15 +03:30
masoodafar-web 66884d8216 Revert: use only Nexus (no fallback)
Build and Deploy / build (push) Failing after 39s
2026-01-29 03:44:39 +03:30
masoodafar-web ad47f1f495 Add nuget.org as fallback source
Build and Deploy / build (push) Has been cancelled
2026-01-29 03:41:17 +03:30
masoodafar-web 1a5a71a27f Remove afrino NuGet sources - use only Nexus
Build and Deploy / build (push) Failing after 32s
2026-01-29 03:14:39 +03:30
masoodafar-web 8a285e485b chore: تنظیم Nexus برای NuGet و Docker offline
Build and Deploy / build (push) Failing after 49s
- Dockerfile: استفاده از Nexus Docker (194.5.195.53:32082)
- NuGet.config: استفاده از Nexus NuGet با HTTP و allowInsecureConnections
- workflow: حذف kubectl dependency، اضافه کردن 32082 به insecure-registries
2026-01-29 02:56:20 +03:30
masoodafar-web 21ee004869 feat: enhance category selection in ProductFormDialog with multi-selection text and improved error handling
Build and Deploy / build (push) Failing after 16s
2026-01-05 21:51:39 +03:30
masoodafar-web 31226399ae feat: replace CategoryMultiSelectCombo with MudSelect for category selection; implement category loading and flattening logic
Build and Deploy / build (push) Failing after 16s
2026-01-05 02:01:05 +03:30
masoodafar-web 53f56636b3 feat: add image and thumbnail upload support in DiscountProduct forms and services
Build and Deploy / build (push) Failing after 17s
2026-01-05 01:23:49 +03:30
masoodafar-web a2c496c16f refactor: remove stock management fields from BulkEdit and related logic; adjust layout in ProductFormDialog and CreateDialog
Build and Deploy / build (push) Failing after 15s
2026-01-04 23:51:42 +03:30
masoodafar-web d28d826a4b Merge branch 'kub-stage' of https://git.se.kbs1.ir/admin/BackOffice into kub-stage
Build and Deploy / build (push) Failing after 27s
2026-01-04 23:05:54 +03:30
masoodafar-web b7543fc10c feat: implement Proto DLL migration and update domain references 2026-01-04 23:05:46 +03:30
Admin 3e9f40bd41 fix: Remove domain from insecure-registries, use IP only
Build and Deploy / build (push) Failing after 15s
2026-01-03 15:15:27 +00:00
Admin 229aa6aec7 fix: Update registry to IP address for CI/CD
Build and Deploy / build (push) Failing after 16s
2026-01-03 15:03:23 +00:00
masoodafar-web 910a1ebfcc feat: update external registry URLs to new domain
Build and Deploy / build (push) Failing after 23s
2026-01-03 17:30:30 +03:30
masoodafar-web c062bd358c Refactor code structure for improved readability and maintainability
Build and Deploy / build (push) Failing after 16s
2026-01-03 15:38:50 +03:30
masoodafar-web 2d833605b4 feat: Implement Inventory Management Pages
Build and Deploy / build (push) Successful in 2m18s
- Added InventoryMainPage for managing inventory items with filtering options.
- Created LowStockPage to display low stock items with filtering capabilities.
- Developed MovementsPage to track inventory movements with detailed filtering.
- Introduced WarehousesPage for managing warehouses, including creation and viewing inventory.
- Enhanced backend integration with InventoryBFFContract for data retrieval.
- Implemented user-friendly UI components using MudBlazor for better user experience.
- Added necessary dialog components for adding stock, adjusting inventory, and viewing movements.
2026-01-03 08:18:55 +03:30
masoodafar-web 978475360c Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 2m23s
2025-12-29 00:43:04 +03:30
masoodafar-web 81ee23e313 feat(BalancesReport): enhance balance display with new member counts and totals
Build and Deploy / build (push) Successful in 2m9s
2025-12-28 21:20:43 +03:30
masoodafar-web 230453e491 feat(config): add production configuration for gateway URL and authentication
Build and Deploy to Production / build-and-deploy (push) Successful in 4m5s
2025-12-27 07:46:54 +03:30
masoodafar-web 1a65babb9d feat(UserPayouts): update status options and colors for user payouts
Build and Deploy / build (push) Successful in 2m8s
2025-12-27 07:34:38 +03:30
masoodafar-web bbe783dc83 feat(UserPayouts): implement withdrawal approval and rejection functionality
Build and Deploy / build (push) Successful in 2m6s
2025-12-27 06:22:11 +03:30
masoodafar-web 6f5bfba0a5 feat(AppVersionEditDialog): replace ReleaseNotes text field with HTML editor for enhanced formatting
Build and Deploy / build (push) Successful in 2m9s
2025-12-27 05:08:14 +03:30
masoodafar-web 98a4ba9e99 feat(config): Add staging configuration for backend URL and authentication
Build and Deploy / build (push) Has been cancelled
2025-12-27 05:07:50 +03:30
masoodafar-web ece29956b2 feat(app-versions): Add functionality to create new app versions and update UI components
Build and Deploy / build (push) Successful in 2m26s
2025-12-27 02:45:47 +03:30
masoodafar-web d69b838a72 fix(dashboard): Disable DiscountShopWidget temporarily
Build and Deploy to Production / build-and-deploy (push) Successful in 3m51s
- Comment out DiscountShopWidget that requires unregistered IDiscountOrderService
- Add placeholder card with 'coming soon' message
- Fix null reference warnings with null! attributes
2025-12-26 06:19:57 +03:30
masoodafar-web 6b140c3bd8 feat(backoffice): Add App Version management UI
Build and Deploy to Production / build-and-deploy (push) Successful in 5m47s
- Add IAppVersionService interface and implementation
- Add AppVersions.razor page for managing app versions
- Add AppVersionEditDialog component for editing versions
- Register AppVersion gRPC client and service in DI
- Update Foursat.BackOffice.BFF.Configuration.Protobuf to 1.0.20
2025-12-26 06:07:02 +03:30
masoodafar-web 332fe0112c fix: update NetworkTreeViewer to change select item value from 20 to 100 2025-12-26 02:05:20 +03:30
masoodafar-web f846a927a7 Merge branch 'stage-new' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 5m49s
2025-12-25 02:04:20 +03:30
masoodafar-web 6dec03cf4d fix: update BackOffice gateway URL to secure production endpoint 2025-12-25 02:04:12 +03:30
masoodafar-web e808cf827e feat: Enhance week filter functionality with disabled state and target week highlight 2025-12-25 02:03:01 +03:30
masoodafar-web 8e98b1f3c7 Refactor code structure for improved readability and maintainability 2025-12-25 00:14:57 +03:30
admin 0ff1a17419 fix: correct dockerfile path and registry
Build and Deploy to Production / build-and-deploy (push) Successful in 2m22s
2025-12-23 22:34:17 +00:00
admin 0c5c30ca42 fix: correct dockerfile path and registry
Build and Deploy to Production / build-and-deploy (push) Has been cancelled
2025-12-23 22:34:09 +00:00
admin 0f99d4c80c feat: add production deployment workflow
Build and Deploy to Production / build-and-deploy (push) Failing after 15s
2025-12-23 22:24:58 +00:00
admin 33d4f4c543 fix: update appsettings for production K8s 2025-12-23 22:17:52 +00:00
admin 787fe6def6 fix: update appsettings for production K8s 2025-12-23 22:17:28 +00:00
masoodafar-web 51c5edc0ae feat: Fix multiple pages in BackOffice and enable product features 2025-12-24 01:06:55 +03:30
masoodafar-web ec923dc22e fix: Switch BackOffice gateway URL to production environment 2025-12-20 05:24:59 +03:30
masoodafar-web be0cb98222 feat: Update deployment branch to stage-new in workflow configuration 2025-12-20 05:11:45 +03:30
masoodafar-web 869cf7e516 feat: Update week number handling to use WeekDefinitionId and improve UI components
Build and Deploy / build (push) Successful in 2m25s
2025-12-20 03:17:04 +03:30
masoodafar-web d1940033db feat: Add club status and activation week filters to network tree
Build and Deploy / build (push) Successful in 2m33s
2025-12-14 04:16:13 +03:30
masoodafar-web cc721da65f feat: Add manual membership payment page and week picker component
Build and Deploy / build (push) Successful in 2m36s
2025-12-12 10:24:43 +03:30
masoodafar-web 2fc7733c84 feat: Add network tree visualization and Persian date service
Build and Deploy / build (push) Successful in 2m39s
2025-12-12 07:54:05 +03:30
masoud 82068bf8f8 fix: Remove default nginx files before copying Blazor WASM output
Build and Deploy / build (push) Successful in 2m22s
- nginx:alpine has default index.html that prevents Blazor from loading
- Added RUN rm -rf /usr/share/nginx/html/* before COPY
2025-12-07 23:30:31 +00:00
masoodafar-web f00ade566c feat: enhance JWT token parsing to support multiple role claims
Build and Deploy / build (push) Successful in 2m20s
2025-12-08 02:57:14 +03:30
masoodafar-web 5a4774b1f3 fix: rename SuperAdmin role to Administrator in authorization
Build and Deploy / build (push) Successful in 2m23s
2025-12-08 02:33:58 +03:30
masoodafar-web 2bfac7afaa fix: Update NavMenu to add Href for discount categories link
Build and Deploy / build (push) Successful in 2m28s
2025-12-08 02:22:14 +03:30
masoud 00290e91fd fix: Change BackOffice Dockerfile to nginx runtime for Blazor WASM
Build and Deploy / build (push) Successful in 2m26s
- Changed from aspnet to nginx:alpine
- Copy wwwroot output instead of BackOffice.dll
- Configure nginx SPA routing (try_files for client-side routing)
- Remove ENTRYPOINT dotnet command
2025-12-07 22:42:39 +00:00
masoud 5c8c0418ae remove: Delete incorrect Dockerfile from root
Build and Deploy / build (push) Has been cancelled
Workflow uses src/BackOffice/Dockerfile which is correct.
Root Dockerfile was using wrong runtime (aspnet instead of nginx).
2025-12-07 22:41:25 +00:00
masoud 6c2d011361 fix: Correct Dockerfile for Blazor WASM with nginx runtime
Build and Deploy / build (push) Has been cancelled
- Changed from aspnet to nginx:alpine
- Copy wwwroot instead of BackOffice.dll
- Configure nginx SPA routing
- Remove incorrect ENTRYPOINT
2025-12-07 22:39:51 +00:00
admin 200825064a Update src/BackOffice/Dockerfile
Build and Deploy / build (push) Successful in 4m8s
2025-12-07 22:27:45 +00:00
masoodafar-web 59d9a0644a feat: enhance product management section in NavMenu with new links and grouping
Build and Deploy / build (push) Successful in 2m33s
2025-12-08 01:41:40 +03:30
masoud dea8de348e fix: update kubeconfig for deployment (127.0.0.1 → 194.5.195.53)
Build and Deploy / build (push) Successful in 7m26s
2025-12-07 19:17:08 +00:00
masoud 2c899f6029 chore: test multi-remote push to git.afrino.co
Build and Deploy / build (push) Successful in 2m22s
2025-12-07 18:52:33 +00:00
masoud 0262ae68c4 chore: trigger rebuild with new registry domain
Build and Deploy / build (push) Successful in 2m56s
2025-12-07 17:32:22 +00:00
masoud a5904cd077 chore: migrate from IP to domain (git.foursat.afrino.co)
Build and Deploy / build (push) Has been cancelled
2025-12-07 17:01:55 +00:00
masoud c7df3db502 Update GwUrl to HTTPS domain with Let's Encrypt
Build and Deploy / build (push) Has been cancelled
2025-12-07 15:05:10 +00:00
masoud 2b460e4e82 Update GwUrl to external NodePort endpoint 2025-12-07 14:48:57 +00:00
masoodafar-web 419bcbdf7a Fix: Support both HTTP and HTTPS channel credentials 2025-12-07 17:04:02 +03:30
masoud 17f8346a87 Revert: Back to nginx for Blazor WASM 2025-12-06 23:11:01 +00:00
masoud f689a95ddd Fix: Revert to aspnet runtime for Blazor Server with gRPC 2025-12-06 23:08:08 +00:00
masoodafar-web ef4e2b5964 feat: Add kubectl installation and Kubernetes deployment steps 2025-12-06 23:01:23 +03:30
masoud 10607eb5fa Fix: Use nginx for Blazor WASM instead of aspnet runtime 2025-12-06 18:35:22 +00:00
masoud 22b5ce3927 Fix docker build command line continuation 2025-12-06 18:27:33 +00:00
masoodafar-web 9146391928 feat: Add Docker configuration and update deployment workflow 2025-12-06 21:54:09 +03:30
masoodafar-web f5b8052245 Merge branch 'main' into kub-stage 2025-12-06 21:50:27 +03:30
masoodafar-web 523754af2c feat: Update proto namespaces and enable product image management 2025-12-06 20:57:43 +03:30
masoodafar-web 88c691c3fb feat: Add build status docs and fix proto dependencies 2025-12-06 01:33:01 +03:30
masoodafar-web 5cec4e9313 feat: Implement role-based access control and manual payment management
- Added role-based permission checks in NavMenu for dashboard and management links.
- Created ManualPaymentDialog for creating and managing manual payments.
- Implemented TransactionDetailsDialog for displaying transaction details.
- Developed Transactions page for viewing and filtering transactions.
- Introduced RolePermissionsDialog for managing role permissions.
- Established AuthorizationService to handle permission checks and user roles.
- Enhanced UI components with MudBlazor for better user experience.
2025-12-05 17:27:23 +03:30
315 changed files with 22901 additions and 5798 deletions
+2
View File
@@ -0,0 +1,2 @@
# Keep production config from current branch during merges — never overwrite
src/BackOffice/wwwroot/appsettings.Production.json merge=ours
-46
View File
@@ -1,46 +0,0 @@
name: Push nuget and docker image Actions Workflow
on:
push:
branches:
- stage
jobs:
Deploy:
runs-on: windows
steps:
- name: Checkout
uses: https://git.afrino.co/actions/checkout@v3
- name: Setup dotnet
uses: https://git.afrino.co/actions/setup-dotnet@v3
with:
dotnet-version: 7.0.x
- name: Remove Package Source
run: dotnet nuget remove source FourSat
continue-on-error: true
- name: Add Package Source
run: dotnet nuget add source --name FourSat --username systemuser --password sZSA7PTiv3pUSQZ https://git.afrino.co/api/packages/FourSat/nuget/index.json --store-password-in-clear-text
- name: Install dependencies
run: dotnet restore ".\src\BackOffice\BackOffice.csproj"
- name: Build
run: dotnet build ".\src\BackOffice\BackOffice.csproj" --configuration Release --no-restore
- name: Test
run: dotnet test ".\src\BackOffice\BackOffice.csproj" --no-restore --verbosity normal
- name: Recycle Apppool
run: |
& "C:\Windows\System32\inetsrv\appcmd.exe" recycle apppool /apppool.name:admin.kbs1.ir
shell: powershell
- name: Stop Website
run: |
& "C:\Windows\System32\inetsrv\appcmd.exe" stop site /site.name:admin.kbs1.ir
shell: powershell
- name: Publish
run: dotnet publish ".\src\BackOffice\BackOffice.csproj" -c Release -o publish
- name: Copy Publish To IIS Directory
run: Get-ChildItem -Path "publish\*" | Copy-Item -Destination "E:\kbs1.ir\admin.kbs1.ir\" -Recurse -Force
- name: Start Website
run: |
& "C:\Windows\System32\inetsrv\appcmd.exe" start site /site.name:admin.kbs1.ir
shell: powershell
+53 -30
View File
@@ -1,4 +1,4 @@
name: Build and Deploy name: Build and Deploy to Kubernetes
on: on:
push: push:
@@ -6,53 +6,76 @@ on:
- kub-stage - kub-stage
env: env:
REGISTRY: gitea-svc:3000 REGISTRY: 194.5.195.53:30080
IMAGE_NAME: admin/backoffice IMAGE_NAME: admin/backoffice
K8S_SERVER: 194.5.195.53
jobs: jobs:
build: build-and-deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: docker:latest image: 194.5.195.53:32082/docker-sshpass:latest
options: --privileged options: --privileged
steps: steps:
- name: Install git - name: Start Docker daemon
run: | run: |
export http_proxy=http://proxyuser:87zH26nbqT2@46.249.98.211:3128 mkdir -p /etc/docker
export https_proxy=http://proxyuser:87zH26nbqT2@46.249.98.211:3128 cat > /etc/docker/daemon.json << 'DAEMON'
apk add --no-cache git {
"insecure-registries": ["194.5.195.53:30080", "194.5.195.53:32500", "194.5.195.53:32082"]
}
DAEMON
echo "🚀 Starting Docker daemon..."
dockerd --iptables=false --ip6tables=false --bridge=none --storage-driver=vfs &
# Wait up to 3 minutes for Docker to be ready
for i in $(seq 1 90); do
if docker info >/dev/null 2>&1; then
echo "✅ Docker daemon is ready (attempt $i)"
docker version
break
else
echo "⏳ Waiting for Docker daemon... (attempt $i/90)"
sleep 2
fi
done
# Final check
if ! docker info >/dev/null 2>&1; then
echo "❌ Docker daemon failed to start after 3 minutes"
exit 1
fi
- name: Checkout code - name: Checkout code
run: | run: |
git clone --depth 1 --branch kub-stage http://gitea-svc:3000/admin/BackOffice.git . git clone --depth 1 --branch kub-stage http://gitea-svc:3000/admin/BackOffice.git .
git log -1 --format="%H %s"
- name: Start Docker daemon with insecure registry - name: Login to Docker registries
run: | run: |
mkdir -p /etc/docker echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login 194.5.195.53:32082 -u admin --password-stdin
cat > /etc/docker/daemon.json << 'DOCKER_EOF' echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u admin --password-stdin
{
"insecure-registries": ["gitea-svc:3000"]
}
DOCKER_EOF
dockerd &
for i in $(seq 1 30); do
docker info >/dev/null 2>&1 && break || sleep 2
done
docker info
- name: Build Docker Image - name: Build Docker Image
run: | run: |
cd src/BackOffice cd src
docker build -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \ DOCKER_BUILDKIT=0 docker build --network host -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest -f BackOffice/Dockerfile .
-t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest \
--build-arg HTTP_PROXY=http://proxyuser:87zH26nbqT2@46.249.98.211:3128 \
--build-arg HTTPS_PROXY=http://proxyuser:87zH26nbqT2@46.249.98.211:3128 \
.
- name: Push to Registry - name: Push to Registry
run: | run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u admin --password-stdin
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- name: Deploy to Kubernetes
run: |
export SSHPASS="${{ secrets.SERVER_PASSWORD }}"
# Copy K8s manifests to server
sshpass -e scp -o StrictHostKeyChecking=no k8s/staging/backoffice-deployment.yaml root@${{ env.K8S_SERVER }}:/tmp/backoffice-deployment.yaml
# Apply manifests and restart
sshpass -e ssh -o StrictHostKeyChecking=no root@${{ env.K8S_SERVER }} "
kubectl apply -f /tmp/backoffice-deployment.yaml &&
kubectl rollout restart deployment/backoffice &&
kubectl rollout status deployment/backoffice --timeout=180s &&
rm -f /tmp/backoffice-deployment.yaml
"
echo "✅ Deployed!"
+76
View File
@@ -0,0 +1,76 @@
name: Build and Deploy to Production
on:
push:
branches:
- production
env:
REGISTRY: 194.5.195.53:30080
IMAGE_NAME: admin/backoffice
K8S_SERVER: 45.149.79.127
jobs:
build-and-deploy:
runs-on: ubuntu-latest
container:
image: 194.5.195.53:32082/docker-sshpass:latest
options: --privileged
steps:
- name: Start Docker daemon
run: |
mkdir -p /etc/docker
cat > /etc/docker/daemon.json << 'DAEMON'
{
"insecure-registries": ["194.5.195.53:30080", "194.5.195.53:32500", "194.5.195.53:32082"]
}
DAEMON
echo "🚀 Starting Docker daemon..."
dockerd --iptables=false --ip6tables=false --bridge=none --storage-driver=vfs &
for i in $(seq 1 90); do
if docker info >/dev/null 2>&1; then
echo "✅ Docker daemon is ready (attempt $i)"
docker version
break
else
echo "⏳ Waiting for Docker daemon... (attempt $i/90)"
sleep 2
fi
done
if ! docker info >/dev/null 2>&1; then
echo "❌ Docker daemon failed to start"
exit 1
fi
- name: Checkout code
run: |
git clone --depth 1 --branch production http://gitea-svc:3000/admin/BackOffice.git .
- name: Login to Docker registries
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login 194.5.195.53:32082 -u admin --password-stdin
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u admin --password-stdin
- name: Build Docker Image
run: |
cd src
DOCKER_BUILDKIT=0 docker build --network host -f BackOffice/Dockerfile \
-t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \
-t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:prod \
.
- name: Push to Registry
run: |
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:prod
- name: Deploy to Production
run: |
export SSHPASS="${{ secrets.SERVER_PASSWORD }}"
sshpass -e ssh -o StrictHostKeyChecking=no root@${{ env.K8S_SERVER }} "
kubectl set image deployment/backoffice backoffice=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
kubectl rollout status deployment/backoffice --timeout=300s
"
echo "✅ Deployed to Production!"
-15
View File
@@ -1,15 +0,0 @@
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY src/*.sln ./ 2>/dev/null || true
COPY src/*/*.csproj ./
RUN for file in *.csproj; do mkdir -p "${file%.*}" && mv "$file" "${file%.*}/"; done 2>/dev/null || true
RUN dotnet restore "BackOffice/BackOffice.csproj" || dotnet restore
COPY src/ ./
RUN dotnet publish "BackOffice/BackOffice.csproj" -c Release -o /app/publish
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish .
ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080
ENTRYPOINT ["dotnet", "BackOffice.dll"]
Executable
+79
View File
@@ -0,0 +1,79 @@
#!/bin/bash
# Build all BFF proto dependencies and copy DLLs to libs folder
# This script is automatically run before build via Directory.Build.targets
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LIBS_DIR="$SCRIPT_DIR/libs"
BFF_PROTOS_DIR="$SCRIPT_DIR/../BackOffice.BFF/src/Protobufs"
echo "🔧 Building all proto dependencies..."
# Create libs directory
mkdir -p "$LIBS_DIR"
# All proto projects
PROTO_PROJECTS=(
"BackOffice.BFF.Common.Protobuf"
"BackOffice.BFF.Category.Protobuf"
"BackOffice.BFF.ClubMembership.Protobuf"
"BackOffice.BFF.Commission.Protobuf"
"BackOffice.BFF.Configuration.Protobuf"
"BackOffice.BFF.DiscountCategory.Protobuf"
"BackOffice.BFF.DiscountOrder.Protobuf"
"BackOffice.BFF.DiscountProduct.Protobuf"
"BackOffice.BFF.DiscountShoppingCart.Protobuf"
"BackOffice.BFF.Health.Protobuf"
"BackOffice.BFF.Inventory.Protobuf"
"BackOffice.BFF.ManualPayment.Protobuf"
"BackOffice.BFF.NetworkMembership.Protobuf"
"BackOffice.BFF.Otp.Protobuf"
"BackOffice.BFF.Package.Protobuf"
"BackOffice.BFF.Products.Protobuf"
"BackOffice.BFF.ProductTag.Protobuf"
"BackOffice.BFF.PublicMessage.Protobuf"
"BackOffice.BFF.Role.Protobuf"
"BackOffice.BFF.Tag.Protobuf"
"BackOffice.BFF.User.Protobuf"
"BackOffice.BFF.UserAddress.Protobuf"
"BackOffice.BFF.UserOrder.Protobuf"
"BackOffice.BFF.UserRole.Protobuf"
)
BUILT_COUNT=0
FAILED_COUNT=0
for PROJECT in "${PROTO_PROJECTS[@]}"; do
PROJECT_DIR="$BFF_PROTOS_DIR/$PROJECT"
if [ -d "$PROJECT_DIR" ]; then
echo "📦 Building $PROJECT..."
if dotnet build "$PROJECT_DIR" -c Release --verbosity quiet 2>/dev/null; then
# Copy DLL and PDB - check both net9.0 and net8.0
if [ -f "$PROJECT_DIR/bin/Release/net9.0/$PROJECT.dll" ]; then
cp "$PROJECT_DIR/bin/Release/net9.0/$PROJECT.dll" "$LIBS_DIR/" && ((BUILT_COUNT++))
cp "$PROJECT_DIR/bin/Release/net9.0/$PROJECT.pdb" "$LIBS_DIR/" 2>/dev/null || true
elif [ -f "$PROJECT_DIR/bin/Release/net8.0/$PROJECT.dll" ]; then
cp "$PROJECT_DIR/bin/Release/net8.0/$PROJECT.dll" "$LIBS_DIR/" && ((BUILT_COUNT++))
cp "$PROJECT_DIR/bin/Release/net8.0/$PROJECT.pdb" "$LIBS_DIR/" 2>/dev/null || true
else
echo "⚠️ DLL not found for: $PROJECT"
((FAILED_COUNT++))
fi
else
echo "⚠️ Failed to build: $PROJECT"
((FAILED_COUNT++))
fi
else
echo "⚠️ Project not found: $PROJECT"
((FAILED_COUNT++))
fi
done
echo ""
echo "✅ Build complete!"
echo " Built: $BUILT_COUNT projects"
echo " Failed: $FAILED_COUNT projects"
echo " Output: $LIBS_DIR"
echo ""
ls -la "$LIBS_DIR"/*.dll 2>/dev/null | wc -l | xargs -I {} echo " Total DLLs: {}"
+421
View File
@@ -0,0 +1,421 @@
# سرویس‌های BackOffice (پنل ادمین)
این سند تمام سرویس‌هایی را که **BackOffice** (فرانت ادمین) به **CMS** از طریق gRPC Gateway (`GwUrl`) فراخوانی می‌کند، به ترتیب **Use Case** فهرست می‌کند.
- **مسیر wrapper سرویس‌ها:** `BackOffice/src/BackOffice/Services/`
- **ثبت gRPC:** `BackOffice/src/BackOffice/Common/Configure/ConfigureService.cs`
- **تاریخ:** ۱۴۰۵/۰۳/۱۷ (بروز: خرداد ۱۴۰۵ — گزارش‌های read-only + proto 0.0.201؛ **بروز ۱۴۰۵/۰۳/۱۸ — دفتر تراکنش‌های مالی proto 0.0.203**)
---
## ۱. ورود و احراز هویت ادمین
| # | سرویس / متد | gRPC Contract | صفحه / کامپوننت | کاربرد |
|---|-------------|---------------|-----------------|--------|
| 1.1 | `OtpTokenContract.CreateNewOtpToken` | `OtpTokenContract` | `/Login/` (`LoginPage`) | ارسال OTP ورود ادمین |
| 1.2 | `UserContract.VerifyOtpToken` | `UserContract` | `/VerifyCodePage/{Mobile}` | تأیید OTP و دریافت JWT |
| 1.3 | `OtpTokenContract.CreateNewOtpToken` (resend) | `OtpTokenContract` | `/VerifyCodePage/{Mobile}` | ارسال مجدد OTP |
| 1.4 | `AuthorizationService.HasPermissionAsync` | — (JWT Role/Claim) | `NavMenu`, صفحات محافظت‌شده | کنترل نمایش منو بر اساس نقش (فعلاً role-based، نه API) |
| 1.5 | `AppTokenProvider` / `TokenHandler` | — (localStorage) | سراسری | attach کردن Bearer token به همه gRPC callها |
---
## ۲. داشبورد
| # | سرویس / متد | gRPC Contract | صفحه / کامپوننت | کاربرد |
|---|-------------|---------------|-----------------|--------|
| 2.1 | `UserContract.GetAllUserByFilter` | `UserContract` | `/` (Index) | شمارش کاربران |
| 2.2 | `ProductsContract.GetAllProductsByFilter` | `ProductsContract` | `/` | شمارش محصولات |
| 2.3 | `PackageContract.GetAllPackageByFilter` | `PackageContract` | `/` | شمارش پکیج‌ها |
| 2.4 | `CategoryContract.GetAllCategoryByFilter` | `CategoryContract` | `/` | شمارش دسته‌بندی‌ها |
| 2.5 | `CommissionContract.GetAvailableWeeks` | `CommissionContract` | `/` | هفته جاری کمیسیون |
| 2.6 | `CommissionContract.GetWeeklyCommissionPool` | `CommissionContract` | `/` | وضعیت pool هفته جاری |
| 2.7 | `ClubMembershipContract.GetAllClubMemberships` | `ClubMembershipContract` | `/` | آمار اعضای باشگاه |
| 2.8 | `UserOrderContract.GetAllUserOrderByFilter` | `UserOrderContract` | `/` | سفارش‌های اخیر |
| 2.9 | `IDiscountOrderService.GetOrdersAsync` | `DiscountOrderContract` | `Dashboard/DiscountShopWidget` | ویجت سفارش‌های فروشگاه تخفیفی |
| 2.10 | `CommissionContract.GetWithdrawalRequests` | `CommissionContract` | `NavMenu` | badge درخواست‌های برداشت معلق |
| 2.11 | `GlobalSearch``UserContract.GetAllUserByFilter` | `UserContract` | `MainLayout/GlobalSearch` | جستجوی سریع کاربر + ناوبری صفحات |
---
## ۳. مدیریت کاربران
| # | سرویس / متد | gRPC Contract | صفحه / کامپوننت | کاربرد |
|---|-------------|---------------|-----------------|--------|
| 3.1 | `UserContract.GetAllUserByFilter` | `UserContract` | `/UserPage/`, `UserAutoComplete`, GlobalSearch | لیست/جستجوی کاربران |
| 3.2 | `UserContract.DeleteUser` | `UserContract` | `/UserPage/` | حذف کاربر |
| 3.3 | Export users | `UserContract.GetAllUserByFilter` | `/UserPage/` | خروجی CSV کاربران |
---
## ۴. نقش‌ها و دسترسی‌ها
| # | سرویس / متد | gRPC Contract | صفحه / کامپوننت | کاربرد |
|---|-------------|---------------|-----------------|--------|
| 4.1 | `RoleContract.GetAllRoleByFilter` | `RoleContract` | `/RolePage/`, `RoleAutoComplete` | لیست نقش‌ها |
| 4.2 | `RoleContract.CreateNewRole` | `RoleContract` | `Role/Components/CreateDialog` | ایجاد نقش |
| 4.3 | `RoleContract.UpdateRole` | `RoleContract` | `Role/Components/UpdateDialog` | ویرایش نقش |
| 4.4 | `RoleContract.DeleteRole` | `RoleContract` | `/RolePage/` | حذف نقش |
| 4.5 | `RolePermissionsDialog` | — (UI فقط) | `/RolePage/` | ویرایش permissionها — **هنوز به API وصل نشده** |
---
## ۵. نقش کاربر و آدرس
| # | سرویس / متد | gRPC Contract | صفحه / کامپوننت | کاربرد |
|---|-------------|---------------|-----------------|--------|
| 5.1 | `UserRoleContract.GetAllUserRoleByFilter` | `UserRoleContract` | `/UserRolePage/{UserId}` | نقش‌های یک کاربر |
| 5.2 | `UserRoleContract.CreateNewUserRole` | `UserRoleContract` | CreateDialog, `UserRoleDialog` | تخصیص نقش |
| 5.3 | `UserRoleContract.UpdateUserRole` | `UserRoleContract` | UpdateDialog | ویرایش نقش کاربر |
| 5.4 | `UserRoleContract.DeleteUserRole` | `UserRoleContract` | `/UserRolePage/{UserId}` | حذف نقش کاربر |
| 5.5 | `RoleContract.GetAllRoleByFilter` | `RoleContract` | `UserRoleDialog`, `RoleTitleColumn` | نمایش عنوان نقش |
| 5.6 | `UserAddressContract.GetAllUserAddressByFilter` | `UserAddressContract` | `/UserAddressPage/{UserId}` | آدرس‌های کاربر |
| 5.7 | `UserAddressContract.CreateNewUserAddress` | `UserAddressContract` | CreateDialog | ثبت آدرس |
| 5.8 | `UserAddressContract.UpdateUserAddress` | `UserAddressContract` | UpdateDialog | ویرایش آدرس |
| 5.9 | `UserAddressContract.DeleteUserAddress` | `UserAddressContract` | `/UserAddressPage/{UserId}` | حذف آدرس |
---
## ۶. پکیج‌ها
| # | سرویس / متد | gRPC Contract | صفحه / کامپوننت | کاربرد |
|---|-------------|---------------|-----------------|--------|
| 6.1 | `PackageContract.GetAllPackageByFilter` | `PackageContract` | `/PackagePage/`, `PackageSelect`, ManualPaymentDialog | لیست پکیج‌ها |
| 6.2 | `PackageContract.CreateNewPackage` | `PackageContract` | `Package/Components/CreateDialog` | ایجاد پکیج |
| 6.3 | `PackageContract.UpdatePackage` | `PackageContract` | `Package/Components/UpdateDialog` | ویرایش پکیج |
| 6.4 | `PackageContract.DeletePackage` | `PackageContract` | `/PackagePage/` | حذف پکیج |
| 6.5 | `ConfigurationContract.GetClubFeatures` | `ConfigurationContract` | Create/Update Dialog | انتخاب featureهای باشگاه مرتبط با پکیج |
---
## ۷. فروشگاه اصلی — محصولات و دسته‌بندی
| # | سرویس / متد | gRPC Contract | صفحه / کامپوننت | کاربرد |
|---|-------------|---------------|-----------------|--------|
| 7.1 | `ProductsContract.GetAllProductsByFilter` | `ProductsContract` | `/ProductsPage/`, BulkEdit, AutoComplete | لیست محصولات |
| 7.2 | `ProductsContract.CreateNewProducts` | `ProductsContract` | `Products/Components/CreateDialog` | ایجاد محصول |
| 7.3 | `ProductsContract.UpdateProducts` | `ProductsContract` | UpdateDialog | ویرایش محصول |
| 7.4 | `ProductsContract.DeleteProducts` | `ProductsContract` | `/ProductsPage/` | حذف محصول |
| 7.5 | `ProductsContract.ToggleProductStatus` | `ProductsContract` | `/ProductsPage/`, BulkEdit | فعال/غیرفعال |
| 7.6 | `ProductsContract.BulkUpdateProductPrices` | `ProductsContract` | `/ProductsBulkEditPage/` | ویرایش گروهی قیمت |
| 7.7 | `ProductsContract.GetProductGallery` / Add/Remove Image | `ProductsContract` | `GalleryDialog` | گالری تصاویر محصول |
| 7.8 | `ProductsContract.GetProducts` / `UpdateProductCategories` | `ProductsContract` | `/ProductCategoriesPage/{ProductId}` | drag-drop دسته‌بندی محصول |
| 7.9 | `CategoryContract.GetAllCategoryByFilter` | `CategoryContract` | `/CategoryPage/`, AutoComplete | لیست دسته‌ها |
| 7.10 | `CategoryContract.GetCategory` / Create / Update / Delete | `CategoryContract` | CategoryMainPage, Dialogs | CRUD دسته‌بندی |
| 7.11 | `ProductsContract.GetProductsForCategory` / `UpdateCategoryProducts` | `ProductsContract` | `/CategoryProductsPage/{CategoryId}` | drag-drop محصولات یک دسته |
---
## ۸. انبارداری (Inventory)
| # | سرویس / متد | gRPC Contract | صفحه / کامپوننت | کاربرد |
|---|-------------|---------------|-----------------|--------|
| 8.1 | `InventoryContract.GetAllWarehouses` | `InventoryContract` | `/InventoryPage/`, Warehouses, LowStock | لیست انبارها |
| 8.2 | `InventoryContract.GetAllInventoryItems` | `InventoryContract` | `/InventoryPage/` | موجودی کالا |
| 8.3 | `InventoryContract.GetLowStockItems` | `InventoryContract` | `/InventoryPage/`, `/InventoryLowStockPage/` | اقلام کم‌موجود |
| 8.4 | `InventoryContract.AddStock` | `InventoryContract` | `AddStockDialog` | ورود کالا به انبار |
| 8.5 | `InventoryContract.AdjustStock` | `InventoryContract` | `AdjustStockDialog` | تعدیل موجودی |
| 8.6 | `InventoryContract.RecordLoss` | `InventoryContract` | `RecordLossDialog` | ثبت ضایعات |
| 8.7 | `InventoryContract.CreateWarehouse` | `InventoryContract` | `CreateWarehouseDialog` | ایجاد انبار |
| 8.8 | `InventoryContract.UpdateInventorySettings` | `InventoryContract` | `InventorySettingsDialog` | تنظیمات انبار |
| 8.9 | `InventoryContract.GetStockMovements` | `InventoryContract` | `/InventoryMovementsPage/` | تاریخچه جابجایی موجودی |
---
## ۹. سفارش‌های فروشگاه اصلی
| # | سرویس / متد | gRPC Contract | صفحه / کامپوننت | کاربرد |
|---|-------------|---------------|-----------------|--------|
| 9.1 | `UserOrderContract.GetAllUserOrderByFilter` | `UserOrderContract` | `/OrdersPage/`, `/UserOrderPage/{UserId}`, SalesReports | لیست سفارش‌ها |
| 9.2 | `UserOrderContract.GetUserOrder` | `UserOrderContract` | OrderDetailsDialog, Index | جزئیات سفارش |
| 9.3 | `UserOrderContract.UpdateUserOrder` | `UserOrderContract` | UserOrderDetailsDialog, ChangeOrderStatus | ویرایش سفارش / tracking |
| 9.4 | `UserOrderContract.UpdateOrderStatus` | `UserOrderContract` | ChangeOrderStatusDialog | تغییر وضعیت تحویل |
| 9.5 | `UserOrderContract.CancelOrder` | `UserOrderContract` | CancelOrderDialog | لغو سفارش |
| 9.6 | `UserOrderContract.DeleteUserOrder` | `UserOrderContract` | `/UserOrderPage/{UserId}` | حذف سفارش |
| 9.7 | `UserOrderContract.ApplyDiscountToOrder` | `UserOrderContract` | ApplyDiscountDialog | اعمال تخفیف روی سفارش |
---
## ۱۰. پرداخت دستی و تراکنش‌ها
> **دو گزارش تراکنش — اشتباه نگیرید:**
>
> | مسیر | جدول DB | کاربرد |
> |------|---------|--------|
> | `/payment/transactions` | `PaymentTransactions` | ممیزی درگاه (Authority، ZarinPal، کارت) |
> | `/payment/ledger-transactions` | `Transactions` | دفتر مالی داخلی (نوع تراکنش، وضعیت، RefId) |
| # | سرویس / متد | gRPC Contract | صفحه / کامپوننت | کاربرد |
|---|-------------|---------------|-----------------|--------|
| 10.1 | `ManualPaymentContract.GetAllManualPayments` | `ManualPaymentContract` | `/payment/manual-payments` | لیست پرداخت‌های دستی |
| 10.2 | `ManualPaymentContract.CreateManualPayment` | `ManualPaymentContract` | `ManualPaymentDialog` | ثبت پرداخت دستی (مثلاً Daya) |
| 10.3 | `ManualPaymentContract.ApproveManualPayment` | `ManualPaymentContract` | ManualPayments, Dialog | تأیید پرداخت |
| 10.4 | `ManualPaymentContract.RejectManualPayment` | `ManualPaymentContract` | ManualPayments, Dialog | رد پرداخت |
| 10.5 | `PaymentTransactionContract.GetAllPaymentTransactionByFilter` | `PaymentTransactionContract` | `/payment/transactions` | گزارش read-only تراکنش **درگاه** (`PaymentTransactions`) |
| 10.5b | `PaymentTransactionContract.GetPaymentTransactionSummary` | `PaymentTransactionContract` | `/payment/transactions` | کارت خلاصه گزارش درگاه |
| 10.6 | `TransactionsContract.GetAllTransactionsByFilter` | `TransactionsContract` | `/payment/ledger-transactions` | گزارش read-only **دفتر مالی** (`Transactions`) |
| 10.7 | `TransactionsContract.GetTransactionSummary` | `TransactionsContract` | `/payment/ledger-transactions` | کارت خلاصه دفتر تراکنش |
| 10.8 | `UserPackagePurchaseContract.GetAllUserPackagePurchaseByFilter` | `UserPackagePurchaseContract` | `/club/purchases` | گزارش خرید پکیج |
| 10.9 | `ClubMembershipCycleContract.GetAllClubMembershipCycleByFilter` | `ClubMembershipCycleContract` | `/club/cycles` | گزارش دوره عضویت |
| 10.10 | `ClubMembershipCycleHistoryContract.GetAllClubMembershipCycleHistoryByFilter` | `ClubMembershipCycleHistoryContract` | `/club/history` (زیرتب دوره) | تاریخچه تغییرات دوره |
---
## ۱۱. کیف پول و تاریخچه
| # | سرویس / متد | gRPC Contract | صفحه / کامپوننت | کاربرد |
|---|-------------|---------------|-----------------|--------|
| 11.1 | `UserWalletContract.GetAllUserWalletByFilter` | `UserWalletContract` | `/wallets` | لیست کیف پول کاربران |
| 11.2 | `UserWalletHistoryContract.GetAllUserWalletHistoryByFilter` | `UserWalletHistoryContract` | `/wallets` (تب تاریخچه) | لاگ تغییرات کیف پول |
| 11.3 | `UserWalletContract.AdminManualCreditCharge` | `UserWalletContract` | `/payment/manual-credit-charges` | شارژ دستی کیف پول اصلی (Balance) |
| 11.4 | `UserWalletContract.GetManualCreditCharges` | `UserWalletContract` | `/payment/manual-credit-charges` | لیست شارژهای دستی (Type=17) |
---
## ۱۲. قراردادهای کاربران
| # | سرویس / متد | gRPC Contract | صفحه / کامپوننت | کاربرد |
|---|-------------|---------------|-----------------|--------|
| 12.1 | `UserContractContract.GetAllUserContractByFilter` | `UserContractContract` | `/contracts` | لیست قراردادهای امضاشده (اصلی / باشگاه) |
| 12.2 | Download PDF | — (local JS) | `/contracts` | دانلود PDF قرارداد ذخیره‌شده |
---
## ۱۳. کمیسیون و پاداش
| # | سرویس / متد | gRPC Contract | صفحه / کامپوننت | کاربرد |
|---|-------------|---------------|-----------------|--------|
| 13.1 | `CommissionContract.GetAvailableWeeks` | `CommissionContract` | `/commission/dashboard`, WeekNumberPicker | انتخاب هفته |
| 13.2 | `CommissionContract.GetWeeklyCommissionPool` | `CommissionContract` | `/commission/dashboard` | جزئیات pool هفتگی |
| 13.3 | `CommissionContract.TriggerWeeklyCalculation` | `CommissionContract` | Dashboard, WorkerControl | اجرای دستی محاسبه هفتگی |
| 13.4 | `CommissionContract.GetAllWeeklyPools` | `CommissionContract` | `/commission/reports` | گزارش poolهای هفتگی |
| 13.5 | `CommissionContract.GetUserCommissionPayouts` | `CommissionContract` | `/commission/payouts` | payoutهای کاربران |
| 13.6 | `CommissionContract.ProcessWithdrawal` | `CommissionContract` | UserPayouts, WithdrawalRequests | پردازش/واریز برداشت |
| 13.7 | `CommissionContract.BulkCreditPayouts` | `CommissionContract` | `/commission/payouts` | واریز گروهی payoutها |
| 13.8 | `CommissionContract.GetWithdrawalRequests` | `CommissionContract` | `/commission/withdrawals`, NavMenu | درخواست‌های برداشت کاربران |
| 13.9 | `CommissionContract.ApproveWithdrawal` | `CommissionContract` | WithdrawalRequests | تأیید درخواست برداشت |
| 13.10 | `CommissionContract.RejectWithdrawal` | `CommissionContract` | WithdrawalRequests | رد درخواست برداشت |
| 13.11 | `CommissionContract.GetWithdrawalReports` | `CommissionContract` | `/commission/withdrawal-reports` | گزارش برداشت‌ها |
| 13.12 | `CommissionContract.GetUserWeeklyBalances` | `CommissionContract` | `/network/balances` | گزارش بالانس هفتگی کاربران |
| 13.13 | `CommissionContract.GetWorkerStatus` | `CommissionContract` | `/system/worker-control` | وضعیت worker محاسبه |
| 13.14 | `CommissionContract.GetWorkerExecutionLogs` | `CommissionContract` | `/system/worker-control` | لاگ اجرای worker |
---
## ۱۴. شبکه فروش (MLM)
| # | سرویس / متد | gRPC Contract | صفحه / کامپوننت | کاربرد |
|---|-------------|---------------|-----------------|--------|
| 14.1 | `NetworkMembershipContract.GetNetworkTree` | `NetworkMembershipContract` | `/network/tree` | درخت شبکه (admin view) |
| 14.2 | `NetworkMembershipContract.GetUserNetwork` | `NetworkMembershipContract` | `/network/user-info/{UserId}` | اطلاعات شبکه یک کاربر |
| 14.3 | `NetworkMembershipContract.GetNetworkMembershipHistory` | `NetworkMembershipContract` | UserNetworkInfo | تاریخچه عضویت شبکه |
| 14.4 | `NetworkMembershipContract.ChangeNetworkParent` | `NetworkMembershipContract` | `ChangeParentDialog` | تغییر والد در درخت |
| 14.5 | `NetworkMembershipContract.GetNetworkStatistics` | `NetworkMembershipContract` | `/network/statistics` | آمار کلی شبکه (admin) |
---
## ۱۵. باشگاه مشتریان
| # | سرویس / متد | gRPC Contract | صفحه / کامپوننت | کاربرد |
|---|-------------|---------------|-----------------|--------|
| 15.1 | `ClubMembershipContract.GetAllClubMemberships` | `ClubMembershipContract` | `/club/members`, Index | لیست اعضا |
| 15.2 | `ClubMembershipContract.ActivateClubMembership` | `ClubMembershipContract` | `ActivateClubDialog` | فعال‌سازی دستی عضویت |
| 15.3 | `ClubMembershipContract.DeactivateClubMembership` | `ClubMembershipContract` | `DeactivateClubDialog` | غیرفعال‌سازی |
| 15.4 | `ClubMembershipContract.GetClubMembershipHistory` | `ClubMembershipContract` | `/club/history` (زیرتب عضویت) | گزارش تاریخچه عضویت |
| 15.5 | `ClubMembershipContract.GetClubStatistics` | `ClubMembershipContract` | `/club/statistics` | آمار باشگاه |
| 15.6 | `ConfigurationContract.GetClubFeatures` | `ConfigurationContract` | `/club/features` | لیست featureهای باشگاه |
| 15.7 | `ClubMembershipContract.GetUserClubFeatures` | `ClubMembershipContract` | `/club/features` | featureهای یک کاربر |
| 15.8 | `ClubMembershipContract.ToggleUserClubFeature` | `ClubMembershipContract` | `/club/features` | فعال/غیرفعال feature برای کاربر |
---
## ۱۶. فروشگاه تخفیفی (Discount Shop)
| # | سرویس / متد | gRPC Contract | صفحه / کامپوننت | کاربرد |
|---|-------------|---------------|-----------------|--------|
| 16.1 | `IDiscountProductService` — CRUD | `DiscountProductContract` | `/discount-products` | مدیریت محصولات تخفیفی |
| 16.2 | `IDiscountCategoryService` — CRUD | `DiscountCategoryContract` | `/discount-categories` | مدیریت دسته‌بندی تخفیفی |
| 16.3 | `IDiscountOrderService.GetOrdersAsync` | `DiscountOrderContract.GetAllDiscountOrders` | `/discount-orders`, Dashboard widget | لیست سفارش‌ها (admin) |
| 16.4 | `IDiscountOrderService.GetByIdAsync` | `DiscountOrderContract` | OrderDetailsDialog | جزئیات سفارش |
| 16.5 | `IDiscountOrderService.UpdateStatusAsync` | `DiscountOrderContract` | Orders page, OrderDetailsDialog | تغییر وضعیت پرداخت/ارسال |
| 16.6 | `IDiscountOrderService.GetSalesReportAsync` | `DiscountOrderContract` | `/discount-sales-reports` | گزارش فروش |
| 16.7 | `DiscountProductContract.GetDiscountProducts` | `DiscountProductContract` | `DiscountProductsAutoComplete` | autocomplete محصول |
---
## ۱۷. تگ محصولات
| # | سرویس / متد | gRPC Contract | صفحه / کامپوننت | کاربرد |
|---|-------------|---------------|-----------------|--------|
| 17.1 | `ITagService.GetTagsAsync` | `TagContract` | `/tags` | لیست تگ‌ها |
| 17.2 | `ITagService.CreateAsync` / `UpdateAsync` / `DeleteAsync` | `TagContract` | TagEditDialog, TagManagement | CRUD تگ |
| 17.3 | `ITagService.GetProductTagsAsync` | `ProductTagContract` | AssignTagsDialog | تگ‌های یک محصول |
| 17.4 | `ITagService.AssignToProductAsync` / `RemoveProductTagAsync` | `ProductTagContract` | AssignTagsDialog, Discount products | تخصیص/حذف تگ |
---
## ۱۸. پیام‌های عمومی
| # | سرویس / متد | gRPC Contract | صفحه / کامپوننت | کاربرد |
|---|-------------|---------------|-----------------|--------|
| 18.1 | `IPublicMessageService.GetMessagesAsync` | `PublicMessageContract` | `/public-messages` | لیست پیام‌ها |
| 18.2 | `IPublicMessageService.GetByIdAsync` | `PublicMessageContract` | PublicMessages | جزئیات |
| 18.3 | `IPublicMessageService.CreateAsync` / `UpdateAsync` | `PublicMessageContract` | MessageFormDialog | ایجاد/ویرایش |
| 18.4 | `IPublicMessageService.PublishAsync` / `ArchiveAsync` | `PublicMessageContract` | PublicMessages | انتشار/بایگانی |
| 18.5 | `IPublicMessageService.DeleteAsync` | `PublicMessageContract` | PublicMessages | حذف |
---
## ۱۹. بلاگ
| # | سرویس / متد | gRPC Contract | صفحه / کامپوننت | کاربرد |
|---|-------------|---------------|-----------------|--------|
| 19.1 | `IBlogPostService.GetAllAsync` | `BlogPostContract` | `/blog/posts` | لیست پست‌ها |
| 19.2 | `IBlogPostService.GetByIdAsync` | `BlogPostContract` | BlogPostEditDialog | جزئیات پست |
| 19.3 | `IBlogPostService.CreateAsync` / `UpdateAsync` | `BlogPostContract` | BlogPostEditDialog | ایجاد/ویرایش |
| 19.4 | `IBlogPostService.DeleteAsync` | `BlogPostContract` | BlogPostManagement | حذف |
| 19.5 | `IBlogPostService.PublishAsync` / `ArchiveAsync` | `BlogPostContract` | BlogPostManagement | انتشار/بایگانی |
| 19.6 | `IBlogCategoryService` — CRUD | `BlogCategoryContract` | `/blog/categories` | مدیریت دسته‌بندی بلاگ |
---
## ۲۰. مدیریت محتوای سایت
| # | سرویس / متد | gRPC Contract | صفحه / کامپوننت | کاربرد |
|---|-------------|---------------|-----------------|--------|
| 20.1 | `ISitePageSettingsService.GetAllAsync` | `SitePageSettingsContract` | `/content/page-settings` | لیست صفحات (landing, about, contact, licenses) |
| 20.2 | `ISitePageSettingsService.GetByKeyAsync` | `SitePageSettingsContract` | PageSettings dialogs | جزئیات یک صفحه |
| 20.3 | `ISitePageSettingsService.SaveSettingsAsync` | `SitePageSettingsContract` | PageSettingsEditDialog | ذخیره hero + JSON تنظیمات |
| 20.4 | `ISitePageSettingsService.SaveImageAsync` / `DeleteImageAsync` | `SitePageSettingsContract` | PageImagesDialog | مدیریت تصاویر گروه‌بندی‌شده |
| 20.5 | `ISitePageService` — CRUD sections | `SitePageContract` | `/content/pages` | صفحات داینامیک قدیمی (section-based) |
---
## ۲۱. تنظیمات سیستم و سلامت
| # | سرویس / متد | gRPC Contract | صفحه / کامپوننت | کاربرد |
|---|-------------|---------------|-----------------|--------|
| 21.1 | `ConfigurationContract.GetAllConfigurations` | `ConfigurationContract` | `/system/configuration` | خواندن key-value تنظیمات (کمیسیون، شبکه، باشگاه، VAT…) |
| 21.2 | `ConfigurationContract.CreateOrUpdateConfiguration` | `ConfigurationContract` | `/system/configuration` | ذخیره تنظیمات |
| 21.3 | `HealthContract.GetSystemHealth` | `HealthContract` | `/system/health` | وضعیت سلامت سرویس‌ها |
| 21.4 | `AlertsMonitoring` | — (mock) | `/system/alerts` | **TODO — API هشدار هنوز وصل نشده** |
---
## ۲۲. نسخه اپلیکیشن
| # | سرویس / متد | gRPC Contract | صفحه / کامپوننت | کاربرد |
|---|-------------|---------------|-----------------|--------|
| 22.1 | `IAppVersionService.GetAllAsync` | `AppVersionContract` | `/settings/app-versions` | لیست نسخه FrontOffice/App |
| 22.2 | `IAppVersionService.GetByNameAsync` | `AppVersionContract` | AppVersions | جزئیات یک app |
| 22.3 | `IAppVersionService.UpdateAsync` | `AppVersionContract.UpdateAppVersion` | AppVersions dialog | به‌روزرسانی نسخه + force update |
---
## ۲۳. کامپوننت‌های مشترک (AutoComplete / Hub)
| کامپوننت | Contract | کاربرد |
|----------|----------|--------|
| `UserAutoComplete` | `UserContract` | جستجوی کاربر در فرم‌ها |
| `ProductsAutoComplete` | `ProductsContract` | جستجوی محصول |
| `CategoryAutoComplete` / MultiSelect | `CategoryContract` | انتخاب دسته |
| `PackageSelect` | `PackageContract` | انتخاب پکیج |
| `WeekNumberPicker` | `CommissionContract` | انتخاب هفته |
| `DiscountProductsAutoComplete` | `DiscountProductContract` | جستجوی محصول تخفیفی |
| Hub pages (`ClubHub`, `DiscountShopHub`, `SystemHub`, `OrdersHub`, `BlogHub`) | — | مسیریابی tab-based؛ RPC در child component |
---
## ۲۴. سرویس‌های ثبت‌شده ولی بدون استفاده / ناقص
| سرویس / صفحه | وضعیت |
|--------------|--------|
| `IBlogPostImageService` (`BlogPostImageContract`) | ثبت در DI؛ **هیچ صفحه‌ای inject نمی‌کند** |
| `RolePermissionsDialog` | UI permission؛ **ذخیره در backend TODO** |
| `AuthorizationService` | فقط JWT role؛ **Permission API از CMS/BFF TODO** |
| `/system/alerts` | داده mock؛ **AlertContract وجود ندارد** |
---
## نمودار جریان ورود ادمین
```mermaid
flowchart LR
A[LoginPage] -->|CreateNewOtpToken| B[SMS OTP]
B --> C[VerifyCodePage]
C -->|VerifyOtpToken| D[JWT in localStorage]
D --> E[All gRPC via TokenHandler]
```
---
## فهرست gRPC Contractهای فعال BackOffice
| Contract | Wrapper / استفاده مستقیم |
|----------|---------------------------|
| `OtpTokenContract` | Login |
| `UserContract` | Login, Users, Dashboard, Search |
| `RoleContract` | Roles, UserRoles |
| `UserRoleContract` | UserRole pages |
| `UserAddressContract` | UserAddress pages |
| `PackageContract` | Packages, ManualPayment |
| `ProductsContract` | Products, Categories drag-drop |
| `CategoryContract` | Categories |
| `UserOrderContract` | Orders |
| `ManualPaymentContract` | Manual payments |
| `PaymentTransactionContract` | Gateway transaction report (`/payment/transactions`) |
| `TransactionsContract` | Ledger transaction report (`/payment/ledger-transactions`) |
| `UserPackagePurchaseContract` | Club package purchase report |
| `ClubMembershipCycleContract` | Club cycle report |
| `ClubMembershipCycleHistoryContract` | Club cycle history report |
| `UserWalletContract` | Wallet management |
| `UserWalletHistoryContract` | Wallet change log |
| `UserContractContract` | Signed contracts |
| `CommissionContract` | Commission, Network balances, Worker |
| `NetworkMembershipContract` | Network tree/info |
| `ClubMembershipContract` | Club members/features |
| `ConfigurationContract` | System config, Club features, Package |
| `DiscountProductContract` | DiscountProductService |
| `DiscountCategoryContract` | DiscountCategoryService |
| `DiscountOrderContract` | DiscountOrderService |
| `TagContract` / `ProductTagContract` | TagService |
| `PublicMessageContract` | PublicMessageService |
| `InventoryContract` | Inventory pages |
| `BlogPostContract` | BlogPostService |
| `BlogCategoryContract` | BlogCategoryService |
| `BlogPostImageContract` | BlogPostImageService *(unused)* |
| `SitePageSettingsContract` | SitePageSettingsService |
| `SitePageContract` | SitePageService |
| `AppVersionContract` | AppVersionService |
| `HealthContract` | Health dashboard |
---
## مقایسه با FrontOffice
| موضوع | FrontOffice (مشتری) | BackOffice (ادمین) |
|-------|----------------------|-------------------|
| Auth | OTP + JWT (UserContract) | OTP + JWT (همان flow) |
| Orders | Customer RPC | Admin filter + status management |
| Discount Shop | User orders + checkout | Full CRUD + admin order management |
| Commission | Read-only (my payouts) | Full admin (calculation, approval, reports) |
| Network | My tree only | Full tree + change parent |
| Content | Read page settings | Edit page settings + blog + public messages |
| Wallet | My balances | All users wallet + history log |
مرجع مشابه برای فرانت مشتری: [`FrontOffice/docs/CUSTOMER-SERVICES.md`](../FrontOffice/docs/CUSTOMER-SERVICES.md)
---
## یادداشت deploy (خرداد ۱۴۰۵)
| گام | جزئیات |
|-----|--------|
| Proto | `Foursat.CMSMicroservice.Protobuf` **0.0.203** (گزارش دفتر تراکنش) |
| CMS | deploy WebApi (شامل `GetTransactionSummary` + فیلتر تاریخ) |
| BackOffice | NuGet 0.0.203 یا ProjectReference در monorepo |
| FrontOffice | **redeploy لازم نیست** برای گزارش ledger؛ برای شارژ کیف اصلی proto **0.0.202** لازم است |
گزارش‌های read-only (بخش ۱۰ و ۱۵): فقط فیلتر + grid + summary + CSV — بدون CRUD یا detail dialog.
مستندات فنی کامل: [`totalDoc/technical/TECH-08-SESSION-LOG-202606.md`](../../totalDoc/technical/TECH-08-SESSION-LOG-202606.md) — §10 Ledger transactions
+4
View File
@@ -0,0 +1,4 @@
# Docs moved to totalDoc
See [totalDoc/INDEX.md](../../totalDoc/INDEX.md) for all documentation.
+3
View File
@@ -0,0 +1,3 @@
# Docs moved to totalDoc
See [totalDoc/INDEX.md](../../totalDoc/INDEX.md) for all documentation.
+1
View File
@@ -0,0 +1 @@
Docs moved to /totalDoc — see totalDoc/INDEX.md
File diff suppressed because it is too large Load Diff
+82
View File
@@ -0,0 +1,82 @@
---
# BackOffice UI Deployment (Blazor WASM + nginx)
apiVersion: apps/v1
kind: Deployment
metadata:
name: backoffice
namespace: default
labels:
app: backoffice
spec:
replicas: 1
selector:
matchLabels:
app: backoffice
template:
metadata:
labels:
app: backoffice
spec:
containers:
- name: backoffice
image: 194.5.195.53:30080/admin/backoffice:latest
imagePullPolicy: Always
ports:
- containerPort: 80
name: http
env:
- name: BLAZOR_ENVIRONMENT
value: "Staging"
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
imagePullSecrets:
- name: gitea-registry-secret
---
# BackOffice UI Service
apiVersion: v1
kind: Service
metadata:
name: backoffice-svc
namespace: default
spec:
selector:
app: backoffice
ports:
- port: 80
targetPort: 80
type: ClusterIP
---
# BackOffice UI Ingress
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: backoffice-ingress
namespace: default
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/service-upstream: "true"
spec:
ingressClassName: nginx
tls:
- hosts:
- backoffice.se.kbs1.ir
secretName: backoffice-tls
rules:
- host: backoffice.se.kbs1.ir
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: backoffice-svc
port:
number: 80
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+25
View File
@@ -0,0 +1,25 @@
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/.idea
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md
+221 -29
View File
@@ -8,57 +8,250 @@
<BlazorCacheBootResources>false</BlazorCacheBootResources> <BlazorCacheBootResources>false</BlazorCacheBootResources>
<GenerateTargetFrameworkAttribute>false</GenerateTargetFrameworkAttribute> <GenerateTargetFrameworkAttribute>false</GenerateTargetFrameworkAttribute>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<!-- Suppress pre-existing warnings that do not affect runtime correctness -->
<NoWarn>MUD0002;CA2022;BL0005</NoWarn>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<Compile Remove="Common\NewFolder\**"/> <Compile Remove="Common\NewFolder\**"/>
<Content Remove="Common\NewFolder\**"/> <Content Remove="Common\NewFolder\**"/>
<Content Include="..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
<EmbeddedResource Remove="Common\NewFolder\**"/> <EmbeddedResource Remove="Common\NewFolder\**"/>
<None Remove="Common\NewFolder\**"/> <None Remove="Common\NewFolder\**"/>
<!-- =========================================================== -->
<!-- TEMPORARILY EXCLUDED - Missing Proto Projects -->
<!-- TODO: Create these proto projects in BackOffice.BFF -->
<!-- =========================================================== -->
<!-- DiscountShop Pages & Services - FULLY ENABLED -->
<!-- <Compile Remove="Pages\DiscountShop\**" /> -->
<!-- <Content Remove="Pages\DiscountShop\**" /> -->
<!-- <Compile Remove="Services\DiscountProduct\**" /> -->
<!-- <Compile Remove="Services\DiscountCategory\**" /> -->
<!-- <Compile Remove="Services\DiscountOrder\**" /> -->
<!-- Tag Pages & Services - ENABLED -->
<!-- <Compile Remove="Pages\\Tag\\**" /> -->
<!-- <Content Remove="Pages\\Tag\\**" /> -->
<!-- <Compile Remove="Services\\Tag\\**" /> -->
<!-- PublicMessages Pages & Services - ENABLED -->
<!-- <Compile Remove="Pages\PublicMessages\**" /> -->
<!-- <Content Remove="Pages\PublicMessages\**" /> -->
<!-- <Compile Remove="Services\PublicMessage\**" /> -->
<!-- ManualPayment - ENABLED -->
<!-- <Compile Remove="Pages\Payment\ManualPayments.razor" /> -->
<!-- <Content Remove="Pages\Payment\ManualPayments.razor" /> -->
<!-- <Compile Remove="Pages\Payment\ManualPayments.razor.cs" /> -->
<!-- <Compile Remove="Pages\Payment\Components\ManualPaymentDialog.razor" /> -->
<!-- <Content Remove="Pages\Payment\Components\ManualPaymentDialog.razor" /> -->
<!-- <Compile Remove="Pages\Payment\Components\ManualPaymentDialog.razor.cs" /> -->
<!-- Dashboard widgets - ENABLED -->
<!-- <Compile Remove="Pages\Dashboard\DiscountShopWidget.razor" /> -->
<!-- <Content Remove="Pages\Dashboard\DiscountShopWidget.razor" /> -->
<!-- <Compile Remove="Pages\Dashboard\DiscountShopWidget.razor.cs" /> -->
<!-- BulkEdit - ENABLED -->
<!-- <Compile Remove="Pages\Products\BulkEdit.razor" /> -->
<!-- <Content Remove="Pages\Products\BulkEdit.razor" /> -->
<!-- <Compile Remove="Pages\Products\BulkEdit.razor.cs" /> -->
<!-- GalleryDialog - ENABLED (proto added) -->
<!-- <Compile Remove="Pages\Products\Components\GalleryDialog.razor" /> -->
<!-- <Content Remove="Pages\Products\Components\GalleryDialog.razor" /> -->
<!-- <Compile Remove="Pages\Products\Components\GalleryDialog.razor.cs" /> -->
<!-- CreateDialog/UpdateDialog - ENABLED (proto added) -->
<!-- <Compile Remove="Pages\Products\Components\CreateDialog.razor" /> -->
<!-- <Content Remove="Pages\Products\Components\CreateDialog.razor" /> -->
<!-- <Compile Remove="Pages\Products\Components\CreateDialog.razor.cs" /> -->
<!-- <Compile Remove="Pages\Products\Components\UpdateDialog.razor" /> -->
<!-- <Content Remove="Pages\Products\Components\UpdateDialog.razor" /> -->
<!-- <Compile Remove="Pages\Products\Components\UpdateDialog.razor.cs" /> -->
<!-- Category/Product DragDrop pages - ENABLED -->
<!-- <Compile Remove="Pages\Category\CategoryProductsDragDropPage.razor" /> -->
<!-- <Content Remove="Pages\Category\CategoryProductsDragDropPage.razor" /> -->
<!-- <Compile Remove="Pages\Category\CategoryProductsDragDropPage.razor.cs" /> -->
<!-- <Compile Remove="Pages\Products\ProductCategoriesDragDropPage.razor" /> -->
<!-- <Content Remove="Pages\Products\ProductCategoriesDragDropPage.razor" /> -->
<!-- <Compile Remove="Pages\Products\ProductCategoriesDragDropPage.razor.cs" /> -->
<!-- UserOrder Components - RESTORED - proto methods exist -->
<!-- <Compile Remove="Pages\UserOrder\Components\CancelOrderDialog.razor" /> -->
<!-- <Content Remove="Pages\UserOrder\Components\CancelOrderDialog.razor" /> -->
<!-- <Compile Remove="Pages\UserOrder\Components\CancelOrderDialog.razor.cs" /> -->
<!-- <Compile Remove="Pages\UserOrder\Components\ApplyDiscountDialog.razor" /> -->
<!-- <Content Remove="Pages\UserOrder\Components\ApplyDiscountDialog.razor" /> -->
<!-- <Compile Remove="Pages\UserOrder\Components\ApplyDiscountDialog.razor.cs" /> -->
<!-- <Compile Remove="Pages\UserOrder\Components\ChangeOrderStatusDialog.razor" /> -->
<!-- <Content Remove="Pages\UserOrder\Components\ChangeOrderStatusDialog.razor" /> -->
<!-- <Compile Remove="Pages\UserOrder\Components\ChangeOrderStatusDialog.razor.cs" /> -->
<!-- UserOrderDetailsDialog - RESTORED - VAT fields exist in proto -->
<!-- <Compile Remove="Pages\UserOrder\Components\UserOrderDetailsDialog.razor" /> -->
<!-- <Content Remove="Pages\UserOrder\Components\UserOrderDetailsDialog.razor" /> -->
<!-- <Compile Remove="Pages\UserOrder\Components\UserOrderDetailsDialog.razor.cs" /> -->
<!-- UserOrderMainPage - RESTORED - dialogs are back -->
<!-- <Compile Remove="Pages\UserOrder\UserOrderMainPage.razor" /> -->
<!-- <Content Remove="Pages\UserOrder\UserOrderMainPage.razor" /> -->
<!-- <Compile Remove="Pages\UserOrder\UserOrderMainPage.razor.cs" /> -->
<!-- Transactions - ENABLED -->
<!-- <Compile Remove="Pages\Payment\Transactions.razor" /> -->
<!-- <Content Remove="Pages\Payment\Transactions.razor" /> -->
<!-- <Compile Remove="Pages\Payment\Transactions.razor.cs" /> -->
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Blazored.LocalStorage" Version="4.5.0"/> <PackageReference Include="Blazored.LocalStorage" Version="4.5.0"/>
<PackageReference Include="Foursat.BackOffice.BFF.Category.Protobuf" Version="0.0.3" />
<PackageReference Include="Foursat.BackOffice.BFF.Otp.Protobuf" Version="0.0.111" />
<PackageReference Include="FourSat.BackOffice.BFF.Package.Protobuf" Version="0.0.111" />
<PackageReference Include="Foursat.BackOffice.BFF.Products.Protobuf" Version="0.0.8" />
<PackageReference Include="Foursat.BackOffice.BFF.Role.Protobuf" Version="0.0.111" />
<PackageReference Include="Foursat.BackOffice.BFF.User.Protobuf" Version="0.0.111" />
<PackageReference Include="Foursat.BackOffice.BFF.UserAddress.Protobuf" Version="0.0.111" />
<PackageReference Include="Foursat.BackOffice.BFF.UserOrder.Protobuf" Version="0.0.114" />
<PackageReference Include="Foursat.BackOffice.BFF.UserRole.Protobuf" Version="0.0.111" />
<!--<PackageReference Include="Foursat.BackOffice.BFF.Commission.Protobuf" Version="0.0.2" />-->
<ProjectReference Include="../../../BackOffice.BFF/src/Protobufs/BackOffice.BFF.Commission.Protobuf/BackOffice.BFF.Commission.Protobuf.csproj" />
<!--<PackageReference Include="Foursat.BackOffice.BFF.ClubMembership.Protobuf" Version="0.0.1" />-->
<!--<PackageReference Include="Foursat.BackOffice.BFF.NetworkMembership.Protobuf" Version="0.0.1" />-->
<ProjectReference Include="../../../BackOffice.BFF/src/Protobufs/BackOffice.BFF.ClubMembership.Protobuf/BackOffice.BFF.ClubMembership.Protobuf.csproj" />
<ProjectReference Include="../../../BackOffice.BFF/src/Protobufs/BackOffice.BFF.NetworkMembership.Protobuf/BackOffice.BFF.NetworkMembership.Protobuf.csproj" />
<ProjectReference Include="../../../BackOffice.BFF/src/Protobufs/BackOffice.BFF.Configuration.Protobuf/BackOffice.BFF.Configuration.Protobuf.csproj" />
<ProjectReference Include="../../../BackOffice.BFF/src/Protobufs/BackOffice.BFF.Health.Protobuf/BackOffice.BFF.Health.Protobuf.csproj" />
<PackageReference Include="HtmlAgilityPack" Version="1.12.1" />
<PackageReference Include="DateTimeConverterCL" Version="1.0.0"/> <PackageReference Include="DateTimeConverterCL" Version="1.0.0"/>
<PackageReference Include="Grpc.Core" Version="2.46.6"/> <PackageReference Include="Grpc.Core" Version="2.46.6"/>
<PackageReference Include="Grpc.Net.Client" Version="2.71.0"/> <PackageReference Include="Grpc.Net.Client" Version="2.71.0"/>
<PackageReference Include="Grpc.Net.Client.Web" Version="2.71.0"/> <PackageReference Include="Grpc.Net.Client.Web" Version="2.71.0"/>
<PackageReference Include="HtmlAgilityPack" Version="1.12.1"/>
<PackageReference Include="Mapster" Version="7.4.0"/> <PackageReference Include="Mapster" Version="7.4.0"/>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="9.0.11"/> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="9.0.11"/>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="9.0.11"/> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="9.0.11"/>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="9.0.11" PrivateAssets="all"/> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="9.0.11" PrivateAssets="all"/>
<PackageReference Include="MudBlazor" Version="8.14.0"/> <PackageReference Include="MudBlazor" Version="8.14.0"/>
<PackageReference Include="Newtonsoft.Json" Version="13.0.4"/> <PackageReference Include="Newtonsoft.Json" Version="13.0.4"/>
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.8.0"/> <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.8.0"/>
<PackageReference Include="Tizzani.MudBlazor.HtmlEditor" Version="2.3.0"/> <PackageReference Include="Tizzani.MudBlazor.HtmlEditor" Version="2.3.0"/>
<!-- Proto dependencies (needed when using DLL references) -->
<PackageReference Include="Google.Protobuf" Version="3.28.3"/>
<PackageReference Include="Google.Api.CommonProtos" Version="2.10.0"/>
<PackageReference Include="FluentValidation" Version="11.2.2"/>
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.2.2"/>
</ItemGroup>
<!-- ============================================================ -->
<!-- Proto References: Direct reference to CMS Protobuf project -->
<!-- This enables direct communication with CMS (no BFF needed) -->
<!-- ============================================================ -->
<!-- CMS Protobuf: NuGet in CI/Docker; ProjectReference for local monorepo dev -->
<ItemGroup Condition="Exists('../../../CMS/src/CMSMicroservice.Protobuf/CMSMicroservice.Protobuf.csproj')">
<ProjectReference Include="../../../CMS/src/CMSMicroservice.Protobuf/CMSMicroservice.Protobuf.csproj" />
</ItemGroup>
<ItemGroup Condition="!Exists('../../../CMS/src/CMSMicroservice.Protobuf/CMSMicroservice.Protobuf.csproj')">
<PackageReference Include="Foursat.CMSMicroservice.Protobuf" Version="0.0.208" />
</ItemGroup>
<!-- ============================================================ -->
<!-- OLD BFF Proto References (DEPRECATED - kept for reference) -->
<!-- ============================================================ -->
<!-- CI/CD Mode: Use pre-built DLLs from libs/ folder -->
<ItemGroup Condition="false AND Exists('../../libs/BackOffice.BFF.Common.Protobuf.dll')">
<Reference Include="BackOffice.BFF.Common.Protobuf">
<HintPath>../../libs/BackOffice.BFF.Common.Protobuf.dll</HintPath>
</Reference>
<Reference Include="BackOffice.BFF.Category.Protobuf">
<HintPath>../../libs/BackOffice.BFF.Category.Protobuf.dll</HintPath>
</Reference>
<Reference Include="BackOffice.BFF.ClubMembership.Protobuf">
<HintPath>../../libs/BackOffice.BFF.ClubMembership.Protobuf.dll</HintPath>
</Reference>
<Reference Include="BackOffice.BFF.Commission.Protobuf">
<HintPath>../../libs/BackOffice.BFF.Commission.Protobuf.dll</HintPath>
</Reference>
<Reference Include="BackOffice.BFF.Configuration.Protobuf">
<HintPath>../../libs/BackOffice.BFF.Configuration.Protobuf.dll</HintPath>
</Reference>
<Reference Include="BackOffice.BFF.DiscountCategory.Protobuf">
<HintPath>../../libs/BackOffice.BFF.DiscountCategory.Protobuf.dll</HintPath>
</Reference>
<Reference Include="BackOffice.BFF.DiscountOrder.Protobuf">
<HintPath>../../libs/BackOffice.BFF.DiscountOrder.Protobuf.dll</HintPath>
</Reference>
<Reference Include="BackOffice.BFF.DiscountProduct.Protobuf">
<HintPath>../../libs/BackOffice.BFF.DiscountProduct.Protobuf.dll</HintPath>
</Reference>
<Reference Include="BackOffice.BFF.DiscountShoppingCart.Protobuf">
<HintPath>../../libs/BackOffice.BFF.DiscountShoppingCart.Protobuf.dll</HintPath>
</Reference>
<Reference Include="BackOffice.BFF.Health.Protobuf">
<HintPath>../../libs/BackOffice.BFF.Health.Protobuf.dll</HintPath>
</Reference>
<Reference Include="BackOffice.BFF.Inventory.Protobuf">
<HintPath>../../libs/BackOffice.BFF.Inventory.Protobuf.dll</HintPath>
</Reference>
<Reference Include="BackOffice.BFF.ManualPayment.Protobuf">
<HintPath>../../libs/BackOffice.BFF.ManualPayment.Protobuf.dll</HintPath>
</Reference>
<Reference Include="BackOffice.BFF.NetworkMembership.Protobuf">
<HintPath>../../libs/BackOffice.BFF.NetworkMembership.Protobuf.dll</HintPath>
</Reference>
<Reference Include="BackOffice.BFF.Otp.Protobuf">
<HintPath>../../libs/BackOffice.BFF.Otp.Protobuf.dll</HintPath>
</Reference>
<Reference Include="BackOffice.BFF.Package.Protobuf">
<HintPath>../../libs/BackOffice.BFF.Package.Protobuf.dll</HintPath>
</Reference>
<Reference Include="BackOffice.BFF.Products.Protobuf">
<HintPath>../../libs/BackOffice.BFF.Products.Protobuf.dll</HintPath>
</Reference>
<Reference Include="BackOffice.BFF.ProductTag.Protobuf">
<HintPath>../../libs/BackOffice.BFF.ProductTag.Protobuf.dll</HintPath>
</Reference>
<Reference Include="BackOffice.BFF.PublicMessage.Protobuf">
<HintPath>../../libs/BackOffice.BFF.PublicMessage.Protobuf.dll</HintPath>
</Reference>
<Reference Include="BackOffice.BFF.Role.Protobuf">
<HintPath>../../libs/BackOffice.BFF.Role.Protobuf.dll</HintPath>
</Reference>
<Reference Include="BackOffice.BFF.Tag.Protobuf">
<HintPath>../../libs/BackOffice.BFF.Tag.Protobuf.dll</HintPath>
</Reference>
<Reference Include="BackOffice.BFF.User.Protobuf">
<HintPath>../../libs/BackOffice.BFF.User.Protobuf.dll</HintPath>
</Reference>
<Reference Include="BackOffice.BFF.UserAddress.Protobuf">
<HintPath>../../libs/BackOffice.BFF.UserAddress.Protobuf.dll</HintPath>
</Reference>
<Reference Include="BackOffice.BFF.UserOrder.Protobuf">
<HintPath>../../libs/BackOffice.BFF.UserOrder.Protobuf.dll</HintPath>
</Reference>
<Reference Include="BackOffice.BFF.UserRole.Protobuf">
<HintPath>../../libs/BackOffice.BFF.UserRole.Protobuf.dll</HintPath>
</Reference>
</ItemGroup>
<!-- Dev Mode: OLD BFF ProjectReference (DEPRECATED) -->
<ItemGroup Condition="false AND !Exists('../../libs/BackOffice.BFF.Common.Protobuf.dll')">
<ProjectReference Include="../../../BackOffice.BFF/src/Protobufs/BackOffice.BFF.Common.Protobuf/BackOffice.BFF.Common.Protobuf.csproj" />
<ProjectReference Include="../../../BackOffice.BFF/src/Protobufs/BackOffice.BFF.Category.Protobuf/BackOffice.BFF.Category.Protobuf.csproj" />
<ProjectReference Include="../../../BackOffice.BFF/src/Protobufs/BackOffice.BFF.ClubMembership.Protobuf/BackOffice.BFF.ClubMembership.Protobuf.csproj" />
<ProjectReference Include="../../../BackOffice.BFF/src/Protobufs/BackOffice.BFF.Commission.Protobuf/BackOffice.BFF.Commission.Protobuf.csproj" />
<ProjectReference Include="../../../BackOffice.BFF/src/Protobufs/BackOffice.BFF.Configuration.Protobuf/BackOffice.BFF.Configuration.Protobuf.csproj" />
<ProjectReference Include="../../../BackOffice.BFF/src/Protobufs/BackOffice.BFF.DiscountCategory.Protobuf/BackOffice.BFF.DiscountCategory.Protobuf.csproj" />
<ProjectReference Include="../../../BackOffice.BFF/src/Protobufs/BackOffice.BFF.DiscountOrder.Protobuf/BackOffice.BFF.DiscountOrder.Protobuf.csproj" />
<ProjectReference Include="../../../BackOffice.BFF/src/Protobufs/BackOffice.BFF.DiscountProduct.Protobuf/BackOffice.BFF.DiscountProduct.Protobuf.csproj" />
<ProjectReference Include="../../../BackOffice.BFF/src/Protobufs/BackOffice.BFF.DiscountShoppingCart.Protobuf/BackOffice.BFF.DiscountShoppingCart.Protobuf.csproj" />
<ProjectReference Include="../../../BackOffice.BFF/src/Protobufs/BackOffice.BFF.Health.Protobuf/BackOffice.BFF.Health.Protobuf.csproj" />
<ProjectReference Include="../../../BackOffice.BFF/src/Protobufs/BackOffice.BFF.Inventory.Protobuf/BackOffice.BFF.Inventory.Protobuf.csproj" />
<ProjectReference Include="../../../BackOffice.BFF/src/Protobufs/BackOffice.BFF.ManualPayment.Protobuf/BackOffice.BFF.ManualPayment.Protobuf.csproj" />
<ProjectReference Include="../../../BackOffice.BFF/src/Protobufs/BackOffice.BFF.NetworkMembership.Protobuf/BackOffice.BFF.NetworkMembership.Protobuf.csproj" />
<ProjectReference Include="../../../BackOffice.BFF/src/Protobufs/BackOffice.BFF.Otp.Protobuf/BackOffice.BFF.Otp.Protobuf.csproj" />
<ProjectReference Include="../../../BackOffice.BFF/src/Protobufs/BackOffice.BFF.Package.Protobuf/BackOffice.BFF.Package.Protobuf.csproj" />
<ProjectReference Include="../../../BackOffice.BFF/src/Protobufs/BackOffice.BFF.Products.Protobuf/BackOffice.BFF.Products.Protobuf.csproj" />
<ProjectReference Include="../../../BackOffice.BFF/src/Protobufs/BackOffice.BFF.ProductTag.Protobuf/BackOffice.BFF.ProductTag.Protobuf.csproj" />
<ProjectReference Include="../../../BackOffice.BFF/src/Protobufs/BackOffice.BFF.PublicMessage.Protobuf/BackOffice.BFF.PublicMessage.Protobuf.csproj" />
<ProjectReference Include="../../../BackOffice.BFF/src/Protobufs/BackOffice.BFF.Role.Protobuf/BackOffice.BFF.Role.Protobuf.csproj" />
<ProjectReference Include="../../../BackOffice.BFF/src/Protobufs/BackOffice.BFF.Tag.Protobuf/BackOffice.BFF.Tag.Protobuf.csproj" />
<ProjectReference Include="../../../BackOffice.BFF/src/Protobufs/BackOffice.BFF.User.Protobuf/BackOffice.BFF.User.Protobuf.csproj" />
<ProjectReference Include="../../../BackOffice.BFF/src/Protobufs/BackOffice.BFF.UserAddress.Protobuf/BackOffice.BFF.UserAddress.Protobuf.csproj" />
<ProjectReference Include="../../../BackOffice.BFF/src/Protobufs/BackOffice.BFF.UserOrder.Protobuf/BackOffice.BFF.UserOrder.Protobuf.csproj" />
<ProjectReference Include="../../../BackOffice.BFF/src/Protobufs/BackOffice.BFF.UserRole.Protobuf/BackOffice.BFF.UserRole.Protobuf.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -66,5 +259,4 @@
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -1,34 +1,54 @@
@* ── Responsive base page: filter above grid, collapsible ── *@
<MudStack Row="true" AlignItems="AlignItems.Center" Class="pa-2 pr-4 rounded" Style="height:85vh;"> <MudStack Spacing="0" Class="base-page">
<MudItem xs="2">
<MudPaper Height="85vh" Style="overflow:hidden; position:relative;" Outlined="true" Class="rounded-lg"> @* ── Header row: page title + filter toggle ── *@
<MudStack Class="pa-5" Style="height:100%;"> @if (Filters != null)
<MudStack Row="true" AlignItems="AlignItems.Center"> {
<MudText Typo="Typo.subtitle2">فیلتر ها</MudText> <MudPaper Outlined="true" Class="rounded-lg mb-3 base-page__filter-panel">
<MudSpacer /> <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) @if (IsFiltered)
{ {
<MudButton Color="Color.Error" Variant="Variant.Text" Size="Size.Small" OnClick="@(async () => await OnClearFilterClick.InvokeAsync())">حذف فیلتر</MudButton> <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> </MudStack>
<MudItem Style="height:100%;">
<MudStack StretchItems="StretchItems.None" Class="filter-item" Style="height:100%;"> @if (_filterOpen)
<MudStack> {
<MudDivider />
<div class="pa-3">
<MudGrid Spacing="2">
@Filters @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> </MudStack>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Size="Size.Small" FullWidth="true" Class="mt-auto" OnClick="@(async () => await OnSubmitClick.InvokeAsync())">جستجو</MudButton> </div>
</MudStack> }
</MudItem>
</MudStack>
</MudPaper> </MudPaper>
}
</MudItem> @* ── Content area (DataGrid or custom layout) ── *@
<MudItem xs="10"> <MudPaper Outlined="true" Class="rounded-lg base-page__content" Style="overflow:hidden; position:relative;">
<MudPaper Height="85vh" Style="overflow:hidden; position:relative;" Outlined="true" Class="rounded-lg">
@Content @Content
</MudPaper> </MudPaper>
</MudItem>
</MudStack> </MudStack>
@@ -1,11 +1,17 @@
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
namespace BackOffice.Common.BaseComponents; namespace BackOffice.Common.BaseComponents;
public partial class BasePageComponent 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 RenderFragment Content { get; set; } = default!;
[Parameter] public bool IsFiltered { get; set; } [Parameter] public bool IsFiltered { get; set; }
[Parameter] public EventCallback OnSubmitClick { get; set; } [Parameter] public EventCallback OnSubmitClick { get; set; }
[Parameter] public EventCallback OnClearFilterClick { 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;
}
@@ -8,21 +8,46 @@ namespace BackOffice.Common.BaseComponents;
public partial class DateRangePicker public partial class DateRangePicker
{ {
private DateRange _dateRange = new(); private DateRange _dateRange = new();
private MudDateRangePicker _picker = default!;
private DateTime? _lastSyncedFrom;
private DateTime? _lastSyncedTo;
private MudDateRangePicker _picker;
[Parameter] public string Label { get; set; } = "انتخاب بازه زمانی"; [Parameter] public string Label { get; set; } = "انتخاب بازه زمانی";
[Parameter] public DateTime? DefaultStart { get; set; } [Parameter] public DateTime? DefaultStart { get; set; }
[Parameter] public DateTime? DefaultEnd { get; set; } [Parameter] public DateTime? DefaultEnd { get; set; }
[Parameter] public EventCallback<DateRange> OnChanged { get; set; } [Parameter] public EventCallback<DateRange> OnChanged { get; set; }
[Parameter] public DateTime? From { get; set; }
[Parameter] public EventCallback<DateTime?> FromChanged { get; set; }
[Parameter] public DateTime? To { get; set; }
[Parameter] public EventCallback<DateTime?> ToChanged { get; set; }
protected override void OnInitialized() protected override void OnInitialized()
{ {
base.OnInitialized(); base.OnInitialized();
if (DefaultStart.HasValue) var start = From ?? DefaultStart;
_dateRange.Start = DefaultStart.Value.Date; var end = To ?? DefaultEnd;
ApplyExternalRange(start, end);
if (DefaultEnd.HasValue) _lastSyncedFrom = From;
_dateRange.End = DefaultEnd.Value.Date; _lastSyncedTo = To;
} }
protected override void OnParametersSet()
{
// فقط وقتی والد From/To را عوض کرده (مثلاً پاک کردن فیلتر)، نه هنگام انتخاب داخل پیکر
if (From == _lastSyncedFrom && To == _lastSyncedTo)
return;
_lastSyncedFrom = From;
_lastSyncedTo = To;
ApplyExternalRange(From, To);
}
private void ApplyExternalRange(DateTime? start, DateTime? end)
{
_dateRange = new DateRange(start?.Date, end?.Date);
}
public CultureInfo GetPersianCulture() public CultureInfo GetPersianCulture()
{ {
var culture = new CultureInfo("fa-IR"); var culture = new CultureInfo("fa-IR");
@@ -53,19 +78,35 @@ public partial class DateRangePicker
culture.NumberFormat.NumberNegativePattern = 0; culture.NumberFormat.NumberNegativePattern = 0;
return culture; return culture;
} }
private async Task OnClickOK() private async Task OnClickOK()
{ {
if (_picker.DateRange is not null) var range = _picker.DateRange ?? _dateRange;
{ _dateRange = range ?? new DateRange();
await OnChanged.InvokeAsync(_picker.DateRange); await NotifyRangeChangedAsync(_dateRange);
}
await _picker.CloseAsync(); await _picker.CloseAsync();
} }
private async Task OnClickClear() private async Task OnClickClear()
{ {
_dateRange = new DateRange(); _dateRange = new DateRange();
await OnChanged.InvokeAsync(_dateRange); await NotifyRangeChangedAsync(_dateRange);
await _picker.CloseAsync(); await _picker.CloseAsync();
} }
private async Task NotifyRangeChangedAsync(DateRange range)
{
var start = range.Start?.Date;
var end = range.End?.Date;
_lastSyncedFrom = start;
_lastSyncedTo = end;
if (From != start)
await FromChanged.InvokeAsync(start);
if (To != end)
await ToChanged.InvokeAsync(end);
await OnChanged.InvokeAsync(range);
}
} }
@@ -7,6 +7,6 @@
} }
else 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 System.Threading.Tasks;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.Web;
using Microsoft.Extensions.Configuration;
using MudBlazor; using MudBlazor;
namespace BackOffice.Common.BaseComponents namespace BackOffice.Common.BaseComponents
@@ -13,13 +14,24 @@ namespace BackOffice.Common.BaseComponents
{ {
private string src = string.Empty; private string src = string.Empty;
[Parameter] public EventCallback OnClick { get; set; } [Parameter] public EventCallback OnClick { get; set; }
[Inject] private IConfiguration Configuration { get; set; } = default!;
private string FileBaseUrl => Configuration["GwUrl"]?.TrimEnd('/') ?? "";
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
await base.OnParametersSetAsync(); await base.OnParametersSetAsync();
if (!string.IsNullOrWhiteSpace(Src)) 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;
} }
} }
@@ -1,29 +1,49 @@
using BackOffice.BFF.Otp.Protobuf.Protos.Otp; using CMSMicroservice.Protobuf.Protos.OtpToken;
using BackOffice.BFF.Package.Protobuf.Protos.Package; using CMSMicroservice.Protobuf.Protos.Package;
using BackOffice.BFF.Role.Protobuf.Protos.Role; using CMSMicroservice.Protobuf.Protos.Role;
using BackOffice.BFF.Products.Protobuf.Protos.Products; using CMSMicroservice.Protobuf.Protos.Products;
using BackOffice.BFF.User.Protobuf.Protos.User; using CMSMicroservice.Protobuf.Protos.User;
using BackOffice.BFF.UserAddress.Protobuf.Protos.UserAddress; using CMSMicroservice.Protobuf.Protos.UserAddress;
using BackOffice.BFF.UserOrder.Protobuf.Protos.UserOrder; using CMSMicroservice.Protobuf.Protos.UserOrder;
using BackOffice.BFF.UserRole.Protobuf.Protos.UserRole; using CMSMicroservice.Protobuf.Protos.UserRole;
using BackOffice.BFF.Category.Protobuf.Protos.Category; using CMSMicroservice.Protobuf.Protos.Category;
using BackOffice.BFF.Commission.Protobuf; using CMSMicroservice.Protobuf.Protos.Commission;
using BackOffice.BFF.NetworkMembership.Protobuf; using CMSMicroservice.Protobuf.Protos.ClubMembership;
using BackOffice.BFF.ClubMembership.Protobuf; using CMSMicroservice.Protobuf.Protos.Configuration;
using BackOffice.BFF.Configuration.Protobuf; using CMSMicroservice.Protobuf.Protos.NetworkMembership;
using BackOffice.BFF.Health.Protobuf; using CMSMicroservice.Protobuf.Protos.Health;
using BackOffice.BFF.DiscountProduct.Protobuf.Protos.DiscountProduct; using CMSMicroservice.Protobuf.Protos.AppVersion;
using BackOffice.BFF.DiscountCategory.Protobuf.Protos.DiscountCategory;
using BackOffice.BFF.DiscountOrder.Protobuf.Protos.DiscountOrder; // Discount Shop Proto Clients
using BackOffice.BFF.Tag.Protobuf.Protos.Tag; using CMSMicroservice.Protobuf.Protos.DiscountProduct;
using BackOffice.BFF.ProductTag.Protobuf.Protos.ProductTag; using CMSMicroservice.Protobuf.Protos.DiscountCategory;
using BackOffice.BFF.PublicMessage.Protobuf.Protos.PublicMessage; using CMSMicroservice.Protobuf.Protos.DiscountOrder;
using CMSMicroservice.Protobuf.Protos.Tag;
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.SitePageSettings;
using CMSMicroservice.Protobuf.Protos.UserContract;
using CMSMicroservice.Protobuf.Protos.UserWallet;
using CMSMicroservice.Protobuf.Protos.UserWalletHistory;
using CMSMicroservice.Protobuf.Protos.PaymentTransaction;
using CMSMicroservice.Protobuf.Protos.Transactions;
using CMSMicroservice.Protobuf.Protos.UserPackagePurchase;
using CMSMicroservice.Protobuf.Protos.ClubMembershipCycle;
using CMSMicroservice.Protobuf.Protos.ClubMembershipCycleHistory;
using BackOffice.Common.Utilities; using BackOffice.Common.Utilities;
using BackOffice.Services.DiscountProduct; using BackOffice.Services.DiscountProduct;
using BackOffice.Services.DiscountCategory; using BackOffice.Services.DiscountCategory;
using BackOffice.Services.DiscountOrder; using BackOffice.Services.DiscountOrder;
using BackOffice.Services.PublicMessage; using BackOffice.Services.PublicMessage;
using BackOffice.Services.Tag; using BackOffice.Services.Tag;
using BackOffice.Services.Blog;
using BackOffice.Services.Content;
using Blazored.LocalStorage; using Blazored.LocalStorage;
using Grpc.Core; using Grpc.Core;
using Grpc.Core.Interceptors; using Grpc.Core.Interceptors;
@@ -58,13 +78,27 @@ public static class ConfigureServices
services.AddMudServices(); services.AddMudServices();
services.AddGrpcServices(configuration); services.AddGrpcServices(configuration);
// Persian DateTime Service
services.AddSingleton<BackOffice.Services.IPersianDateTimeService, BackOffice.Services.PersianDateTimeService>();
// Application Services // Application Services
services.AddScoped<BackOffice.Services.Authorization.IAuthorizationService, BackOffice.Services.Authorization.AuthorizationService>();
services.AddScoped<BackOffice.Services.AppVersion.IAppVersionService, BackOffice.Services.AppVersion.AppVersionService>();
// Discount Shop Services
services.AddScoped<IDiscountProductService, DiscountProductService>(); services.AddScoped<IDiscountProductService, DiscountProductService>();
services.AddScoped<IDiscountCategoryService, DiscountCategoryService>(); services.AddScoped<IDiscountCategoryService, DiscountCategoryService>();
services.AddScoped<IDiscountOrderService, DiscountOrderService>(); services.AddScoped<IDiscountOrderService, DiscountOrderService>();
services.AddScoped<IPublicMessageService, PublicMessageService>(); services.AddScoped<IPublicMessageService, PublicMessageService>();
services.AddScoped<ITagService, TagService>(); services.AddScoped<ITagService, TagService>();
// Blog & Content Management Services
services.AddScoped<IBlogPostService, BlogPostService>();
services.AddScoped<IBlogCategoryService, BlogCategoryService>();
services.AddScoped<IBlogPostImageService, BlogPostImageService>();
services.AddScoped<ISitePageService, SitePageService>();
services.AddScoped<ISitePageSettingsService, SitePageSettingsService>();
return services; return services;
} }
@@ -90,7 +124,7 @@ public static class ConfigureServices
return CreateAuthenticatedChannel(baseUri, httpClient, sp); return CreateAuthenticatedChannel(baseUri, httpClient, sp);
}); });
services.AddTransient(sp => new OtpContract.OtpContractClient(sp.GetRequiredService<CallInvoker>())); services.AddTransient(sp => new OtpTokenContract.OtpTokenContractClient(sp.GetRequiredService<CallInvoker>()));
services.AddTransient(sp => new PackageContract.PackageContractClient(sp.GetRequiredService<CallInvoker>())); services.AddTransient(sp => new PackageContract.PackageContractClient(sp.GetRequiredService<CallInvoker>()));
services.AddTransient(sp => new ProductsContract.ProductsContractClient(sp.GetRequiredService<CallInvoker>())); services.AddTransient(sp => new ProductsContract.ProductsContractClient(sp.GetRequiredService<CallInvoker>()));
services.AddTransient(sp => new RoleContract.RoleContractClient(sp.GetRequiredService<CallInvoker>())); services.AddTransient(sp => new RoleContract.RoleContractClient(sp.GetRequiredService<CallInvoker>()));
@@ -106,19 +140,47 @@ public static class ConfigureServices
services.AddTransient(sp => new ClubMembershipContract.ClubMembershipContractClient(sp.GetRequiredService<CallInvoker>())); services.AddTransient(sp => new ClubMembershipContract.ClubMembershipContractClient(sp.GetRequiredService<CallInvoker>()));
services.AddTransient(sp => new ConfigurationContract.ConfigurationContractClient(sp.GetRequiredService<CallInvoker>())); services.AddTransient(sp => new ConfigurationContract.ConfigurationContractClient(sp.GetRequiredService<CallInvoker>()));
services.AddTransient(sp => new HealthContract.HealthContractClient(sp.GetRequiredService<CallInvoker>())); services.AddTransient(sp => new HealthContract.HealthContractClient(sp.GetRequiredService<CallInvoker>()));
services.AddTransient(sp => new AppVersionContract.AppVersionContractClient(sp.GetRequiredService<CallInvoker>()));
// Discount Shop Services // Discount Shop Services
services.AddTransient(sp => new DiscountProductsContract.DiscountProductsContractClient(sp.GetRequiredService<CallInvoker>())); services.AddTransient(sp => new DiscountProductContract.DiscountProductContractClient(sp.GetRequiredService<CallInvoker>()));
services.AddTransient(sp => new DiscountCategoriesContract.DiscountCategoriesContractClient(sp.GetRequiredService<CallInvoker>())); services.AddTransient(sp => new DiscountCategoryContract.DiscountCategoryContractClient(sp.GetRequiredService<CallInvoker>()));
services.AddTransient(sp => new DiscountOrdersContract.DiscountOrdersContractClient(sp.GetRequiredService<CallInvoker>())); services.AddTransient(sp => new DiscountOrderContract.DiscountOrderContractClient(sp.GetRequiredService<CallInvoker>()));
// Public Message Service // Public Message Service
services.AddTransient(sp => new PublicMessagesContract.PublicMessagesContractClient(sp.GetRequiredService<CallInvoker>())); services.AddTransient(sp => new PublicMessageContract.PublicMessageContractClient(sp.GetRequiredService<CallInvoker>()));
// Tag Management Services // Tag Management Services
services.AddTransient(sp => new TagContract.TagContractClient(sp.GetRequiredService<CallInvoker>())); services.AddTransient(sp => new TagContract.TagContractClient(sp.GetRequiredService<CallInvoker>()));
services.AddTransient(sp => new ProductTagContract.ProductTagContractClient(sp.GetRequiredService<CallInvoker>())); services.AddTransient(sp => new ProductTagContract.ProductTagContractClient(sp.GetRequiredService<CallInvoker>()));
// Manual Payment Service
services.AddTransient(sp => new ManualPaymentContract.ManualPaymentContractClient(sp.GetRequiredService<CallInvoker>()));
// 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>()));
services.AddTransient(sp => new SitePageSettingsContract.SitePageSettingsContractClient(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 UserWalletHistoryContract.UserWalletHistoryContractClient(sp.GetRequiredService<CallInvoker>()));
// Admin Report Services
services.AddTransient(sp => new PaymentTransactionContract.PaymentTransactionContractClient(sp.GetRequiredService<CallInvoker>()));
services.AddTransient(sp => new TransactionsContract.TransactionsContractClient(sp.GetRequiredService<CallInvoker>()));
services.AddTransient(sp => new UserPackagePurchaseContract.UserPackagePurchaseContractClient(sp.GetRequiredService<CallInvoker>()));
services.AddTransient(sp => new ClubMembershipCycleContract.ClubMembershipCycleContractClient(sp.GetRequiredService<CallInvoker>()));
services.AddTransient(sp => new ClubMembershipCycleHistoryContract.ClubMembershipCycleHistoryContractClient(sp.GetRequiredService<CallInvoker>()));
return services; return services;
} }
@@ -127,27 +189,28 @@ public static class ConfigureServices
var credentials = CallCredentials.FromInterceptor(async (context, metadata) => var credentials = CallCredentials.FromInterceptor(async (context, metadata) =>
{ {
var provider = serviceProvider.GetRequiredService<ITokenProvider>(); var provider = serviceProvider.GetRequiredService<ITokenProvider>();
// var accessToken = await provider.RequestAccessToken();
// accessToken.TryGetToken(out var token);
var token = await provider.GetTokenAsync(); var token = await provider.GetTokenAsync();
if (!string.IsNullOrEmpty(token)) if (!string.IsNullOrEmpty(token))
{ {
// Console.WriteLine($"Authorization Bearer {token.Value}");
metadata.Add("Authorization", $"Bearer {token}"); metadata.Add("Authorization", $"Bearer {token}");
} }
await Task.CompletedTask; await Task.CompletedTask;
}); });
// SslCredentials is used here because this channel is using TLS. // تشخیص HTTP یا HTTPS
// CallCredentials can't be used with ChannelCredentials.Insecure on non-TLS channels. var isHttps = address.StartsWith("https://", StringComparison.OrdinalIgnoreCase);
var channelCredentials = isHttps
? ChannelCredentials.Create(new SslCredentials(), credentials)
: ChannelCredentials.Create(ChannelCredentials.Insecure, credentials);
var channel = GrpcChannel.ForAddress(address, new GrpcChannelOptions var channel = GrpcChannel.ForAddress(address, new GrpcChannelOptions
{ {
UnsafeUseInsecureChannelCallCredentials = true, UnsafeUseInsecureChannelCallCredentials = !isHttps, // فقط برای HTTP
Credentials = ChannelCredentials.Create(new SslCredentials(), credentials), Credentials = channelCredentials,
HttpClient = httpClient, HttpClient = httpClient,
MaxReceiveMessageSize = 1000 * 1024 * 1024, // 1 GB MaxReceiveMessageSize = 1000 * 1024 * 1024,
MaxSendMessageSize = 1000 * 1024 * 1024 // 1 GB MaxSendMessageSize = 1000 * 1024 * 1024
}); });
return channel.Intercept(new ErrorHandlerInterceptor()); return channel.Intercept(new ErrorHandlerInterceptor());
} }
@@ -1,4 +1,4 @@
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
using System.Text.Json; using System.Text.Json;
using Blazored.LocalStorage; using Blazored.LocalStorage;
@@ -27,12 +27,53 @@ public class ApiAuthenticationStateProvider : AuthenticationStateProvider
var handler = new JwtSecurityTokenHandler(); var handler = new JwtSecurityTokenHandler();
var token = handler.ReadJwtToken(savedToken); var token = handler.ReadJwtToken(savedToken);
// Parse the token payload to handle array-based roles
var claims = new List<Claim>();
var AuthenticationState = new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity(token.Claims, "jwt"))); foreach (var claim in token.Claims)
{
return AuthenticationState; // Handle role claims specially to support multiple roles
if (claim.Type == "http://schemas.microsoft.com/ws/2008/06/identity/claims/role" ||
claim.Type == ClaimTypes.Role ||
claim.Type == "role")
{
// Check if the value is a JSON array
if (claim.Value.TrimStart().StartsWith("["))
{
try
{
var roles = JsonSerializer.Deserialize<string[]>(claim.Value);
if (roles != null)
{
foreach (var role in roles)
{
claims.Add(new Claim(ClaimTypes.Role, role));
} }
catch (Exception ex) }
}
catch
{
// If parsing fails, add as single claim
claims.Add(new Claim(ClaimTypes.Role, claim.Value));
}
}
else
{
// Single role value
claims.Add(new Claim(ClaimTypes.Role, claim.Value));
}
}
else
{
claims.Add(claim);
}
}
var authenticationState = new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity(claims, "jwt")));
return authenticationState;
}
catch
{ {
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())); return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
} }
@@ -1,4 +1,5 @@
using BackOffice.Common.Utilities; using System.IdentityModel.Tokens.Jwt;
using BackOffice.Common.Utilities;
using Blazored.LocalStorage; using Blazored.LocalStorage;
namespace BackOffice.Common.Utilities; namespace BackOffice.Common.Utilities;
@@ -19,9 +20,41 @@ public class AppTokenProvider : ITokenProvider
{ {
var authorizationToken = await _localStorage.GetItemAsync<string>(GlobalConstants.JwtTokenKey); var authorizationToken = await _localStorage.GetItemAsync<string>(GlobalConstants.JwtTokenKey);
if (!string.IsNullOrEmpty(authorizationToken)) if (!string.IsNullOrEmpty(authorizationToken))
_token = authorizationToken.ToString().Replace("Bearer ", ""); {
var raw = authorizationToken.ToString().Replace("Bearer ", "");
// Don't send expired tokens — they cause 401 on anonymous endpoints like login
if (IsTokenExpired(raw))
{
await _localStorage.RemoveItemAsync(GlobalConstants.JwtTokenKey);
_token = null;
return _token;
}
_token = raw;
}
}
else if (IsTokenExpired(_token))
{
// Cached token has expired since last check
await _localStorage.RemoveItemAsync(GlobalConstants.JwtTokenKey);
_token = null;
} }
return _token; return _token;
} }
private static bool IsTokenExpired(string token)
{
try
{
var handler = new JwtSecurityTokenHandler();
var jwt = handler.ReadJwtToken(token);
// Add 30-second buffer to avoid edge-case race
return jwt.ValidTo < DateTime.UtcNow.AddSeconds(-30);
}
catch
{
// Malformed token — treat as expired
return true;
}
}
} }
@@ -0,0 +1,145 @@
using System.Globalization;
using System.Net;
using System.Text;
namespace BackOffice.Common.Utilities;
/// <summary>
/// خروجی سازگار با Excel فارسی:
/// - XML Spreadsheet (.xls) برای ستون‌بندی مطمئن + متن فارسی
/// - CSV با جداکننده ; به‌عنوان جایگزین سبک
/// </summary>
public static class CsvExportHelper
{
/// <summary>جداکننده پیش‌فرض ویندوز فارسی برای CSV.</summary>
public const char Separator = ';';
public static string Escape(string? value)
{
if (string.IsNullOrEmpty(value))
return string.Empty;
// همیشه quote تا Excel فارسی ستون را نشکند
return $"\"{value.Replace("\"", "\"\"")}\"";
}
public static string Cell(object? value) => value switch
{
null => Escape(string.Empty),
string s => Escape(s),
bool b => b ? "1" : "0",
byte or sbyte or short or ushort or int or uint or long or ulong or float or double or decimal
=> Convert.ToString(value, CultureInfo.InvariantCulture) ?? "0",
IFormattable f => Escape(f.ToString(null, CultureInfo.InvariantCulture) ?? string.Empty),
_ => Escape(Convert.ToString(value, CultureInfo.InvariantCulture))
};
public static string Row(params object?[] cells) =>
string.Join(Separator, cells.Select(Cell));
/// <summary>
/// نام نمایشی مشتری؛ اگر خالی باشد موبایل یا شناسه کاربر.
/// </summary>
public static string CustomerName(string? fullName, string? mobile = null, long? userId = null)
{
if (!string.IsNullOrWhiteSpace(fullName))
return fullName.Trim();
if (!string.IsNullOrWhiteSpace(mobile))
return mobile.Trim();
if (userId is > 0)
return $"کاربر {userId}";
return "—";
}
public static string ToBase64(StringBuilder body)
{
// CRLF صریح — Excel روی ویندوز با \n لینوکس گاهی به‌هم می‌ریزد
var text = body.ToString().Replace("\r\n", "\n").Replace("\r", "\n").Replace("\n", "\r\n");
var bytes = Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes(text)).ToArray();
return Convert.ToBase64String(bytes);
}
/// <summary>
/// خروجی SpreadsheetML که Excel به‌صورت ستون‌ستون و با UTF-8 درست باز می‌کند.
/// فایل را با پسوند .xls ذخیره کنید.
/// </summary>
public static string ToExcelXmlBase64(string sheetName, IReadOnlyList<string> headers, IEnumerable<object?[]> rows)
{
var sb = new StringBuilder();
sb.AppendLine(@"<?xml version=""1.0"" encoding=""UTF-8""?>");
sb.AppendLine(@"<?mso-application progid=""Excel.Sheet""?>");
sb.AppendLine(@"<Workbook xmlns=""urn:schemas-microsoft-com:office:spreadsheet""");
sb.AppendLine(@" xmlns:o=""urn:schemas-microsoft-com:office:office""");
sb.AppendLine(@" xmlns:x=""urn:schemas-microsoft-com:office:excel""");
sb.AppendLine(@" xmlns:ss=""urn:schemas-microsoft-com:office:spreadsheet""");
sb.AppendLine(@" xmlns:html=""http://www.w3.org/TR/REC-html40"">");
sb.AppendLine(@"<Styles>");
sb.AppendLine(@"<Style ss:ID=""Header""><Font ss:Bold=""1""/></Style>");
sb.AppendLine(@"<Style ss:ID=""Text""><NumberFormat ss:Format=""@""/></Style>");
sb.AppendLine(@"</Styles>");
sb.Append("<Worksheet ss:Name=\"").Append(XmlEscape(sheetName)).AppendLine("\">");
sb.AppendLine("<Table>");
sb.AppendLine("<Row>");
foreach (var header in headers)
{
sb.Append("<Cell ss:StyleID=\"Header\"><Data ss:Type=\"String\">")
.Append(XmlEscape(header))
.AppendLine("</Data></Cell>");
}
sb.AppendLine("</Row>");
foreach (var row in rows)
{
sb.AppendLine("<Row>");
foreach (var cell in row)
AppendExcelCell(sb, cell);
sb.AppendLine("</Row>");
}
sb.AppendLine("</Table>");
sb.AppendLine("</Worksheet>");
sb.AppendLine("</Workbook>");
var bytes = Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes(sb.ToString())).ToArray();
return Convert.ToBase64String(bytes);
}
private static void AppendExcelCell(StringBuilder sb, object? value)
{
switch (value)
{
case null:
sb.AppendLine(@"<Cell ss:StyleID=""Text""><Data ss:Type=""String""></Data></Cell>");
break;
case string s:
sb.Append(@"<Cell ss:StyleID=""Text""><Data ss:Type=""String"">")
.Append(XmlEscape(s))
.AppendLine("</Data></Cell>");
break;
case bool b:
sb.Append(@"<Cell><Data ss:Type=""Boolean"">")
.Append(b ? "1" : "0")
.AppendLine("</Data></Cell>");
break;
case byte or sbyte or short or ushort or int or uint or long or ulong:
sb.Append(@"<Cell><Data ss:Type=""Number"">")
.Append(Convert.ToString(value, CultureInfo.InvariantCulture))
.AppendLine("</Data></Cell>");
break;
case float or double or decimal:
sb.Append(@"<Cell><Data ss:Type=""Number"">")
.Append(Convert.ToString(value, CultureInfo.InvariantCulture))
.AppendLine("</Data></Cell>");
break;
default:
sb.Append(@"<Cell ss:StyleID=""Text""><Data ss:Type=""String"">")
.Append(XmlEscape(Convert.ToString(value, CultureInfo.InvariantCulture)))
.AppendLine("</Data></Cell>");
break;
}
}
private static string XmlEscape(string? value) =>
WebUtility.HtmlEncode(value ?? string.Empty);
}
@@ -0,0 +1,161 @@
using System.Globalization;
using System.Net;
using System.Text;
using DateTimeConverterCL;
namespace BackOffice.Common.Utilities;
/// <summary>ساخت HTML فاکتور برای چاپ مرورگر (jsHtmlToPdf).</summary>
public static class OrderInvoicePrint
{
public sealed class InvoiceModel
{
public string ShopTitle { get; set; } = "فروشگاه";
public string OrderId { get; set; } = "";
public string? CustomerName { get; set; }
public string? CustomerMobile { get; set; }
public string? CustomerNationalCode { get; set; }
public string? Address { get; set; }
public string? PostalCode { get; set; }
public string? PaymentStatus { get; set; }
public string? PaymentMethod { get; set; }
public string? TransactionId { get; set; }
public string? TrackingCode { get; set; }
public DateTime? Created { get; set; }
public DateTime? PaymentDate { get; set; }
public long Subtotal { get; set; }
public long DiscountTotal { get; set; }
public long CreditUsed { get; set; }
public long GatewayAmount { get; set; }
public long VatAmount { get; set; }
public long TotalAmount { get; set; }
public List<InvoiceLine> Lines { get; set; } = new();
}
public sealed class InvoiceLine
{
public string Title { get; set; } = "";
public int Quantity { get; set; }
public long UnitPrice { get; set; }
public long Discount { get; set; }
public long LineTotal { get; set; }
}
public static string BuildHtml(InvoiceModel model)
{
var sb = new StringBuilder();
sb.Append("<!DOCTYPE html><html lang=\"fa\" dir=\"rtl\"><head><meta charset=\"utf-8\"/>");
sb.Append("<title>").Append(Esc($"فاکتور {model.OrderId}")).Append("</title>");
sb.Append("""
<style>
* { box-sizing: border-box; }
body { font-family: Tahoma, "Segoe UI", sans-serif; color: #111; margin: 24px; font-size: 13px; }
h1 { font-size: 20px; margin: 0 0 4px; }
.muted { color: #555; }
.header { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 2px solid #222; padding-bottom: 12px; margin-bottom: 16px; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 24px; margin-bottom: 16px; }
.label { color: #666; font-size: 11px; }
.value { font-weight: 600; }
table { width: 100%; border-collapse: collapse; margin: 12px 0 16px; }
th, td { border: 1px solid #ccc; padding: 8px; text-align: right; }
th { background: #f3f3f3; }
.num { text-align: left; font-variant-numeric: tabular-nums; white-space: nowrap; }
.totals { width: 320px; margin-right: auto; }
.totals td { border: none; padding: 4px 0; }
.totals .grand { font-size: 15px; font-weight: 700; border-top: 2px solid #222; padding-top: 8px; }
.footer { margin-top: 28px; font-size: 11px; color: #666; border-top: 1px dashed #bbb; padding-top: 8px; }
@media print {
body { margin: 0; }
.no-print { display: none !important; }
}
</style>
""");
sb.Append("</head><body>");
sb.Append("<div class=\"header\"><div>");
sb.Append("<h1>").Append(Esc(model.ShopTitle)).Append("</h1>");
sb.Append("<div class=\"muted\">فاکتور فروش</div></div><div>");
sb.Append("<div><span class=\"label\">شماره فاکتور: </span><span class=\"value\">").Append(Esc(model.OrderId)).Append("</span></div>");
if (model.Created.HasValue)
sb.Append("<div><span class=\"label\">تاریخ ثبت: </span>").Append(Esc(FormatDate(model.Created.Value))).Append("</div>");
if (model.PaymentDate.HasValue)
sb.Append("<div><span class=\"label\">تاریخ پرداخت: </span>").Append(Esc(FormatDate(model.PaymentDate.Value))).Append("</div>");
sb.Append("</div></div>");
sb.Append("<div class=\"grid\">");
AppendField(sb, "نام مشتری", model.CustomerName);
AppendField(sb, "موبایل", model.CustomerMobile);
AppendField(sb, "کد ملی", model.CustomerNationalCode);
AppendField(sb, "کد پستی", model.PostalCode);
AppendField(sb, "وضعیت پرداخت", model.PaymentStatus);
AppendField(sb, "روش پرداخت", model.PaymentMethod);
AppendField(sb, "کد تراکنش", model.TransactionId);
AppendField(sb, "کد رهگیری", model.TrackingCode);
if (!string.IsNullOrWhiteSpace(model.Address))
{
sb.Append("<div style=\"grid-column:1/-1\"><div class=\"label\">آدرس</div><div class=\"value\">")
.Append(Esc(model.Address)).Append("</div></div>");
}
sb.Append("</div>");
sb.Append("<table><thead><tr>");
sb.Append("<th>#</th><th>کالا</th><th>تعداد</th><th>قیمت واحد</th><th>تخفیف</th><th>مبلغ</th>");
sb.Append("</tr></thead><tbody>");
var i = 1;
foreach (var line in model.Lines)
{
sb.Append("<tr>");
sb.Append("<td class=\"num\">").Append(i++).Append("</td>");
sb.Append("<td>").Append(Esc(line.Title)).Append("</td>");
sb.Append("<td class=\"num\">").Append(line.Quantity).Append("</td>");
sb.Append("<td class=\"num\">").Append(Money(line.UnitPrice)).Append("</td>");
sb.Append("<td class=\"num\">").Append(Money(line.Discount)).Append("</td>");
sb.Append("<td class=\"num\">").Append(Money(line.LineTotal)).Append("</td>");
sb.Append("</tr>");
}
if (model.Lines.Count == 0)
sb.Append("<tr><td colspan=\"6\" class=\"muted\">آیتمی ثبت نشده است</td></tr>");
sb.Append("</tbody></table>");
sb.Append("<table class=\"totals\"><tbody>");
if (model.Subtotal > 0)
AppendTotalRow(sb, "جمع اقلام", model.Subtotal);
if (model.DiscountTotal > 0)
AppendTotalRow(sb, "تخفیف", model.DiscountTotal);
if (model.VatAmount > 0)
AppendTotalRow(sb, "مالیات بر ارزش افزوده", model.VatAmount);
if (model.CreditUsed > 0)
AppendTotalRow(sb, "پرداخت از کیف اعتباری", model.CreditUsed);
if (model.GatewayAmount > 0)
AppendTotalRow(sb, "پرداخت درگاه / نقدی", model.GatewayAmount);
sb.Append("<tr class=\"grand\"><td>مبلغ قابل پرداخت</td><td class=\"num\">")
.Append(Money(model.TotalAmount)).Append(" تومان</td></tr>");
sb.Append("</tbody></table>");
sb.Append("<div class=\"footer\">چاپ‌شده از پنل ادمین — ").Append(Esc(FormatDate(DateTime.Now))).Append("</div>");
sb.Append("</body></html>");
return sb.ToString();
}
private static void AppendField(StringBuilder sb, string label, string? value)
{
if (string.IsNullOrWhiteSpace(value)) return;
sb.Append("<div><div class=\"label\">").Append(Esc(label)).Append("</div><div class=\"value\">")
.Append(Esc(value)).Append("</div></div>");
}
private static void AppendTotalRow(StringBuilder sb, string label, long amount)
{
sb.Append("<tr><td>").Append(Esc(label)).Append("</td><td class=\"num\">")
.Append(Money(amount)).Append(" تومان</td></tr>");
}
private static string Money(long amount) => amount.ToString("N0", CultureInfo.InvariantCulture);
private static string FormatDate(DateTime dt)
{
try { return dt.ToLocalTime().MiladiToJalaliWithTime(); }
catch { return dt.ToLocalTime().ToString("yyyy/MM/dd HH:mm"); }
}
private static string Esc(string? s) => WebUtility.HtmlEncode(s ?? "");
}
@@ -0,0 +1,83 @@
namespace BackOffice.Common.Utilities;
using CMSMicroservice.Protobuf.Protos;
using MudBlazor;
public static class ReportLabels
{
public static string GetPaymentStatusLabel(bool success) => success ? "موفق" : "در انتظار / ناموفق";
public static string GetTransactionPaymentStatusLabel(PaymentStatus? status) => status switch
{
PaymentStatus.Success => "موفق",
PaymentStatus.Pending => "در انتظار",
PaymentStatus.Reject => "رد شده",
_ => "—"
};
public static Color GetTransactionPaymentStatusColor(PaymentStatus? status) => status switch
{
PaymentStatus.Success => Color.Success,
PaymentStatus.Pending => Color.Warning,
PaymentStatus.Reject => Color.Error,
_ => Color.Default
};
public static string GetTransactionTypeLabel(TransactionType? type) => type switch
{
TransactionType.Buy => "خرید",
TransactionType.DepositIpg => "واریز درگاه",
TransactionType.DepositExternal1 => "واریز خارجی",
TransactionType.Withdraw => "برداشت",
TransactionType.NetworkCommission => "کمیسیون شبکه",
TransactionType.ClubActivation => "فعال‌سازی باشگاه",
TransactionType.DiscountWalletCharge => "شارژ کیف تخفیف",
TransactionType.DiscountShopPurchase => "خرید فروشگاه تخفیف",
TransactionType.MagicWalletDeposit => "واریز کیف جادویی",
TransactionType.MagicWalletBonus => "بونوس کیف جادویی",
TransactionType.CreditWalletCharge => "شارژ کیف اصلی",
TransactionType.ManualCreditWalletCharge => "شارژ دستی کیف اصلی",
_ => type?.ToString() ?? "—"
};
public static string GetPurchaseMethodLabel(int method) => method switch
{
1 => "دایا",
2 => "درگاه",
3 => "دستی",
_ => "نامشخص"
};
public static string GetClubMembershipActionLabel(int action) => action switch
{
0 => "فعال‌سازی",
1 => "غیرفعال‌سازی",
2 => "ویرایش",
3 => "اصلاح دستی",
_ => action.ToString()
};
public static string GetClubCycleActionLabel(int action) => action switch
{
0 => "شروع دوره",
1 => "ورود به حالت جادویی",
2 => "پایان حالت جادویی",
3 => "تکمیل دوره",
4 => "لغو",
5 => "اصلاح دستی",
_ => action.ToString()
};
public static string GetMagicStateLabel(int? magicStarted, DateTime? started, DateTime? completed)
{
if (completed.HasValue) return "جادویی تکمیل‌شده";
if (started.HasValue) return "در حالت جادویی";
return "عادی";
}
public static string FormatActiveChange(bool oldActive, bool newActive) =>
$"{(oldActive ? "فعال" : "غیرفعال")} → {(newActive ? "فعال" : "غیرفعال")}";
public static string FormatBoolChange(bool oldValue, bool newValue) =>
$"{(oldValue ? "بله" : "خیر")} → {(newValue ? "بله" : "خیر")}";
}
@@ -10,6 +10,7 @@ public static class RouteConstance
public const string UserPage = "/UserPage/"; public const string UserPage = "/UserPage/";
public const string UserOrder = "/UserOrderPage/"; public const string UserOrder = "/UserOrderPage/";
public const string Orders = "/OrdersPage/"; public const string Orders = "/OrdersPage/";
public const string OrdersSalesReports = "/OrdersSalesReportsPage/";
public const string UserRole = "/UserRolePage/"; public const string UserRole = "/UserRolePage/";
public const string UserAddress = "/UserAddressPage/"; public const string UserAddress = "/UserAddressPage/";
public const string Products = "/ProductsPage/"; public const string Products = "/ProductsPage/";
@@ -17,4 +18,11 @@ public static class RouteConstance
public const string ProductCategories = "/ProductCategoriesPage/"; public const string ProductCategories = "/ProductCategoriesPage/";
public const string ProductsBulkEdit = "/ProductsBulkEditPage/"; public const string ProductsBulkEdit = "/ProductsBulkEditPage/";
public const string CategoryProducts = "/CategoryProductsPage/"; public const string CategoryProducts = "/CategoryProductsPage/";
// Inventory Management
public const string Inventory = "/InventoryPage/";
public const string InventoryLowStock = "/InventoryLowStockPage/";
public const string InventoryWarehouses = "/InventoryWarehousesPage/";
public const string InventoryAddStock = "/InventoryAddStockPage/";
public const string InventoryMovements = "/InventoryMovementsPage/";
} }
+38
View File
@@ -0,0 +1,38 @@
FROM 194.5.195.53:32082/dotnet/sdk:9.0 AS build
WORKDIR /src
# Copy NuGet config and project file
COPY ["BackOffice/NuGet.config", "NuGet.config"]
COPY ["BackOffice/BackOffice.csproj", "BackOffice/"]
# Restore dependencies
RUN dotnet restore "BackOffice/BackOffice.csproj" --configfile NuGet.config
# Copy all source code
COPY . .
# Build and publish
WORKDIR "/src/BackOffice"
RUN dotnet publish "BackOffice.csproj" -c Release -o /app/publish
# Runtime stage - nginx for Blazor WebAssembly
FROM 194.5.195.53:32082/nginx:alpine AS final
WORKDIR /usr/share/nginx/html
# Remove default nginx files
RUN rm -rf /usr/share/nginx/html/*
# Copy published wwwroot (Blazor WASM output)
COPY --from=build /app/publish/wwwroot .
# Copy entrypoint script that sets blazor-environment header
COPY ["BackOffice/docker-entrypoint.sh", "/docker-entrypoint.sh"]
RUN chmod +x /docker-entrypoint.sh
# Default Blazor environment (override via K8s env var BLAZOR_ENVIRONMENT)
ENV BLAZOR_ENVIRONMENT=Staging
# Default gateway URL for gRPC calls (injected into appsettings at startup)
ENV GW_URL=https://cms.kbs2.ir
EXPOSE 80
CMD ["/docker-entrypoint.sh"]
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<!-- Local packages (dev builds) -->
<!-- <add key="Local" value="/home/masoud/Apps/project/FourSat/nupkg" /> -->
<!-- Nexus (hosts FourSat packages + proxies nuget.org) -->
<add key="Nexus" value="http://194.5.195.53:32081/repository/nuget-all/index.json" allowInsecureConnections="true" />
</packageSources>
<packageSourceCredentials>
<Nexus>
<add key="Username" value="admin" />
<add key="ClearTextPassword" value="87zH26nbqT" />
</Nexus>
</packageSourceCredentials>
</configuration>
@@ -1,4 +1,4 @@
@using BackOffice.BFF.Category.Protobuf.Protos.Category @using CMSMicroservice.Protobuf.Protos.Category
<MudAutocomplete T="GetAllCategoryByFilterResponseModel" <MudAutocomplete T="GetAllCategoryByFilterResponseModel"
Label="@Label" Label="@Label"
@@ -1,4 +1,5 @@
using BackOffice.BFF.Category.Protobuf.Protos.Category; using CMSMicroservice.Protobuf.Protos;
using CMSMicroservice.Protobuf.Protos.Category;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
namespace BackOffice.Pages.AutoComplete; namespace BackOffice.Pages.AutoComplete;
@@ -1,4 +1,4 @@
@using BackOffice.BFF.Category.Protobuf.Protos.Category @using CMSMicroservice.Protobuf.Protos.Category
<MudAutocomplete T="GetAllCategoryByFilterResponseModel" <MudAutocomplete T="GetAllCategoryByFilterResponseModel"
Label="@Label" Label="@Label"
@@ -1,4 +1,5 @@
using BackOffice.BFF.Category.Protobuf.Protos.Category; using CMSMicroservice.Protobuf.Protos;
using CMSMicroservice.Protobuf.Protos.Category;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
namespace BackOffice.Pages.AutoComplete; namespace BackOffice.Pages.AutoComplete;
@@ -1,13 +1,15 @@
@using BackOffice.BFF.Category.Protobuf.Protos.Category @using CMSMicroservice.Protobuf.Protos.Category
<MudSelect T="long" <MudSelect T="long"
@ref="_selectRef" @ref="_selectRef"
Label="@Label" Label="@Label"
Variant="Variant.Outlined" Variant="Variant.Outlined"
Margin="Margin.Dense"
Disabled="@Disabled" Disabled="@Disabled"
MultiSelection="true" MultiSelection="true"
MultiSelectionTextFunc="@GetSelectedText"
SelectedValues="_internalSelectedIds" SelectedValues="_internalSelectedIds"
SelectedValuesChanged="@(_ => OnSelectedValuesChangedAsync())"> SelectedValuesChanged="OnSelectedValuesChangedAsync">
@foreach (var item in _items) @foreach (var item in _items)
{ {
<MudSelectItem T="long" Value="@item.Id"> <MudSelectItem T="long" Value="@item.Id">
@@ -1,7 +1,8 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using BackOffice.BFF.Category.Protobuf.Protos.Category; using CMSMicroservice.Protobuf.Protos;
using CMSMicroservice.Protobuf.Protos.Category;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
namespace BackOffice.Pages.AutoComplete; namespace BackOffice.Pages.AutoComplete;
@@ -61,11 +62,9 @@ public partial class CategoryMultiSelectCombo
: new HashSet<long>(); : new HashSet<long>();
} }
private async Task OnSelectedValuesChangedAsync() private async Task OnSelectedValuesChangedAsync(IEnumerable<long> values)
{ {
var current = _selectRef?.SelectedValues ?? System.Array.Empty<long>(); _internalSelectedIds = values.Where(id => id > 0).Distinct().ToHashSet();
_internalSelectedIds = current.Where(id => id > 0).Distinct().ToHashSet();
SelectedIds = _internalSelectedIds.ToList(); SelectedIds = _internalSelectedIds.ToList();
if (SelectedIdsChanged.HasDelegate) if (SelectedIdsChanged.HasDelegate)
@@ -73,5 +72,27 @@ public partial class CategoryMultiSelectCombo
await SelectedIdsChanged.InvokeAsync(SelectedIds); 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,17 @@
@using BackOffice.Services.DiscountCategory
<MudSelect @ref="_selectRef"
T="long"
MultiSelection="true"
MultiSelectionTextFunc="@GetSelectedText"
SelectedValues="_internalSelectedIds"
SelectedValuesChanged="OnSelectedValuesChangedAsync"
Label="@Label"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Disabled="@Disabled">
@foreach (var item in _items)
{
<MudSelectItem T="long" Value="@item.Id">@item.Title</MudSelectItem>
}
</MudSelect>
@@ -0,0 +1,119 @@
using BackOffice.Services.DiscountCategory;
using Microsoft.AspNetCore.Components;
using MudBlazor;
namespace BackOffice.Pages.AutoComplete;
public partial class DiscountCategoryMultiSelectCombo
{
[Inject] public IDiscountCategoryService CategoryService { get; set; } = default!;
[Parameter] public List<long> SelectedIds { get; set; } = new();
[Parameter] public EventCallback<List<long>> SelectedIdsChanged { get; set; }
[Parameter] public string? Label { get; set; } = "انتخاب دسته‌بندی‌ها";
[Parameter] public bool Disabled { get; set; }
private List<DiscountCategoryDto> _items = new();
private HashSet<long> _internalSelectedIds = new();
private MudSelect<long>? _selectRef;
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
await LoadCategoriesAsync();
SyncInternalFromParameter();
}
protected override Task OnParametersSetAsync()
{
SyncInternalFromParameter();
return base.OnParametersSetAsync();
}
private async Task LoadCategoriesAsync()
{
try
{
var tree = await CategoryService.GetCategoriesAsync(isActive: true);
_items = FlattenCategories(tree ?? new List<DiscountCategoryDto>());
// Log first few items
if (_items.Count > 0)
{
foreach (var item in _items.Take(5))
{
}
}
}
catch (Exception ex)
{
if (ex.InnerException != null)
{
}
_items = new List<DiscountCategoryDto>();
}
}
private List<DiscountCategoryDto> FlattenCategories(List<DiscountCategoryDto> categories, string prefix = "")
{
var result = new List<DiscountCategoryDto>();
foreach (var category in categories)
{
var catCopy = new DiscountCategoryDto
{
Id = category.Id,
Title = prefix + category.Title,
Name = category.Name,
ParentCategoryId = category.ParentCategoryId,
IsActive = category.IsActive
};
result.Add(catCopy);
if (category.Children?.Any() == true)
{
result.AddRange(FlattenCategories(category.Children.ToList(), prefix + " "));
}
}
return result;
}
private void SyncInternalFromParameter()
{
_internalSelectedIds = SelectedIds != null
? SelectedIds.Where(id => id > 0).Distinct().ToHashSet()
: new HashSet<long>();
}
private async Task OnSelectedValuesChangedAsync(IEnumerable<long> values)
{
_internalSelectedIds = values.Where(id => id > 0).Distinct().ToHashSet();
SelectedIds = _internalSelectedIds.ToList();
if (SelectedIdsChanged.HasDelegate)
{
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,14 @@
@using CMSMicroservice.Protobuf.Protos.DiscountProduct
<MudAutocomplete T="DiscountProductDto"
Label="@Label"
Value="@_item"
DebounceInterval="700"
ValueChanged="@((e) => OnSelected(e))"
ToStringFunc="@(e => e == null ? null : e.Title)"
SearchFunc="@Search"
Variant="Variant.Outlined"
Clearable="true"
ShowProgressIndicator="true"
CoerceText="false"
OnClearButtonClick="()=> OnSelected(null)" />
@@ -0,0 +1,83 @@
using CMSMicroservice.Protobuf.Protos.DiscountProduct;
using Microsoft.AspNetCore.Components;
namespace BackOffice.Pages.AutoComplete;
public partial class DiscountProductsAutoComplete
{
[Inject] public DiscountProductContract.DiscountProductContractClient DiscountProductService { get; set; } = default!;
[Parameter] public long? Value { get; set; }
[Parameter] public string? Label { get; set; } = "محصول تخفیفی";
[Parameter] public EventCallback<long?> ValueChanged { get; set; }
private List<DiscountProductDto> _items = new();
private DiscountProductDto? _item;
protected override async void OnParametersSet()
{
await base.OnParametersSetAsync();
if (Value.HasValue && Value.Value > 0 && (_item == null || _item.Id != Value.Value))
{
try
{
var getAll = await DiscountProductService.GetDiscountProductsAsync(new GetDiscountProductsRequest
{
PageNumber = 1,
PageSize = 50
});
if (getAll?.Models != null)
{
_item = getAll.Models.FirstOrDefault(x => x.Id == Value.Value);
StateHasChanged();
}
}
catch
{
// Silently handle
}
}
}
private async Task OnSelected(DiscountProductDto? model)
{
if (model == null)
{
_item = null;
await ValueChanged.InvokeAsync(null);
}
else
{
_item = model;
await ValueChanged.InvokeAsync(model.Id);
}
}
private async Task<IEnumerable<DiscountProductDto>> Search(string value, CancellationToken token)
{
var request = new GetDiscountProductsRequest
{
PageNumber = 1,
PageSize = 9
};
if (!string.IsNullOrWhiteSpace(value))
{
request.SearchQuery = value;
}
try
{
var getAll = await DiscountProductService.GetDiscountProductsAsync(request);
_items = getAll?.Models?.ToList() ?? new List<DiscountProductDto>();
}
catch
{
_items = new List<DiscountProductDto>();
}
return _items;
}
}
@@ -0,0 +1,16 @@
@using CMSMicroservice.Protobuf.Protos.Package
<MudSelect T="long?"
Label="@Label"
Value="@SelectedPackageId"
ValueChanged="OnValueChanged"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Clearable="true"
Style="min-width: 160px;">
<MudSelectItem T="long?" Value="@((long?)null)">همه پکیج‌ها</MudSelectItem>
@foreach (var pkg in _packages)
{
<MudSelectItem T="long?" Value="@((long?)pkg.Id)">@pkg.Title</MudSelectItem>
}
</MudSelect>
@@ -0,0 +1,46 @@
using CMSMicroservice.Protobuf.Protos.Package;
using Microsoft.AspNetCore.Components;
namespace BackOffice.Pages.AutoComplete;
public partial class PackageSelect
{
[Inject] public PackageContract.PackageContractClient PackageClient { get; set; }
[Parameter] public string Label { get; set; } = "پکیج";
[Parameter] public long? SelectedPackageId { get; set; }
[Parameter] public EventCallback<long?> SelectedPackageIdChanged { get; set; }
private List<GetAllPackageByFilterResponseModel> _packages = new();
protected override async Task OnInitializedAsync()
{
await LoadPackages();
}
private async Task LoadPackages()
{
try
{
var request = new GetAllPackageByFilterRequest
{
PaginationState = new() { PageNumber = 1, PageSize = 100 },
Filter = new()
};
var result = await PackageClient.GetAllPackageByFilterAsync(request);
_packages = result.Models?.OrderBy(p => p.SortOrder).ToList()
?? new List<GetAllPackageByFilterResponseModel>();
}
catch
{
_packages = new List<GetAllPackageByFilterResponseModel>();
}
}
private async Task OnValueChanged(long? value)
{
SelectedPackageId = value;
await SelectedPackageIdChanged.InvokeAsync(value);
}
}
@@ -1,4 +1,4 @@
@using BackOffice.BFF.Products.Protobuf.Protos.Products @using CMSMicroservice.Protobuf.Protos.Products
<MudAutocomplete T="GetAllProductsByFilterResponseModel" <MudAutocomplete T="GetAllProductsByFilterResponseModel"
Label="@Label" Label="@Label"
@@ -1,4 +1,5 @@
using BackOffice.BFF.Products.Protobuf.Protos.Products; using CMSMicroservice.Protobuf.Protos.Products;
using CMSMicroservice.Protobuf.Protos;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
namespace BackOffice.Pages.AutoComplete; namespace BackOffice.Pages.AutoComplete;
@@ -1,4 +1,4 @@
@using BackOffice.BFF.Role.Protobuf.Protos.Role @using CMSMicroservice.Protobuf.Protos.Role
<MudAutocomplete T="GetAllRoleByFilterResponseModel" <MudAutocomplete T="GetAllRoleByFilterResponseModel"
Label="@Label" Label="@Label"
@@ -1,4 +1,4 @@
using BackOffice.BFF.Role.Protobuf.Protos.Role; using CMSMicroservice.Protobuf.Protos.Role;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop; using Microsoft.JSInterop;
@@ -0,0 +1,15 @@
@using CMSMicroservice.Protobuf.Protos.User
<MudAutocomplete T="GetAllUserByFilterResponseModel"
Label="@Label"
Value="@_item"
DebounceInterval="500"
ValueChanged="@((e) => OnSelected(e))"
ToStringFunc="@(e=> e == null ? null : $"{e.FirstName} {e.LastName} ({e.Mobile})")"
SearchFunc="@Search"
Variant="Variant.Outlined"
Clearable="true"
ShowProgressIndicator="true"
CoerceText="false"
ResetValueOnEmptyText="true"
OnClearButtonClick="()=> OnSelected(null)"/>
@@ -0,0 +1,89 @@
using CMSMicroservice.Protobuf.Protos.User;
using Grpc.Core;
using Microsoft.AspNetCore.Components;
namespace BackOffice.Pages.AutoComplete;
public partial class UserAutoComplete
{
[Inject] public UserContract.UserContractClient UserContract { get; set; }
[Parameter] public string Label { get; set; } = "انتخاب کاربر";
[Parameter] public long? SelectedUserId { get; set; }
[Parameter] public EventCallback<long?> SelectedUserIdChanged { get; set; }
private GetAllUserByFilterResponseModel _item;
protected override async Task OnParametersSetAsync()
{
if (SelectedUserId.HasValue && SelectedUserId.Value > 0 && _item?.Id != SelectedUserId.Value)
{
await LoadUser(SelectedUserId.Value);
}
}
private async Task<IEnumerable<GetAllUserByFilterResponseModel>> Search(string value, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(value) || value.Length < 2)
return new List<GetAllUserByFilterResponseModel>();
try
{
var request = new GetAllUserByFilterRequest
{
PaginationState = new() { PageNumber = 1, PageSize = 100 },
Filter = new()
{
SearchText = value,
}
};
var response = await UserContract.GetAllUserByFilterAsync(request, cancellationToken: cancellationToken);
return response.Models?.ToList() ?? new List<GetAllUserByFilterResponseModel>();
}
catch (RpcException ex) when (ex.StatusCode == StatusCode.Cancelled)
{
return new List<GetAllUserByFilterResponseModel>();
}
catch
{
return new List<GetAllUserByFilterResponseModel>();
}
}
private async Task OnSelected(GetAllUserByFilterResponseModel selected)
{
_item = selected;
var userId = selected?.Id;
SelectedUserId = userId;
await SelectedUserIdChanged.InvokeAsync(userId);
}
private async Task LoadUser(long userId)
{
try
{
var request = new GetAllUserByFilterRequest
{
PaginationState = new() { PageNumber = 1, PageSize = 1 },
Filter = new()
{
Id = userId
}
};
var response = await UserContract.GetAllUserByFilterAsync(request);
if (response.Models != null && response.Models.Count > 0)
{
_item = response.Models[0];
}
}
catch
{
// Silent fail
}
}
}
@@ -0,0 +1,21 @@
@using CMSMicroservice.Protobuf.Protos.Commission
<MudAutocomplete T="WeekInfo"
Label="@Label"
Value="@_selectedWeek"
DebounceInterval="300"
ValueChanged="@OnSelected"
ToStringFunc="@(e => e?.DisplayName ?? string.Empty)"
SearchFunc="@Search"
Variant="Variant.Outlined"
Clearable="true"
ShowProgressIndicator="true"
CoerceText="@CoerceText"
ResetValueOnEmptyText="true"
OnClearButtonClick="@OnClear">
<ItemTemplate Context="week">
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText>@week.DisplayText</MudText>
</MudStack>
</ItemTemplate>
</MudAutocomplete>
@@ -0,0 +1,129 @@
using BackOffice.Services;
using CMSMicroservice.Protobuf.Protos.Commission;
using Grpc.Core;
using Microsoft.AspNetCore.Components;
namespace BackOffice.Pages.AutoComplete;
public partial class WeekNumberPicker
{
[Inject] public CommissionContract.CommissionContractClient CommissionContract { get; set; } = default!;
[Inject] public IPersianDateTimeService PersianDateTime { get; set; } = default!;
[Parameter] public string Label { get; set; } = "انتخاب هفته";
// WeekDefinitionId based binding (preferred)
[Parameter] public long? SelectedWeekDefinitionId { get; set; }
[Parameter] public EventCallback<long?> SelectedWeekDefinitionIdChanged { get; set; }
// Legacy string-based binding (deprecated, kept for compatibility)
// [Parameter] public long? SelectedWeekNumber { get; set; }
// [Parameter] public EventCallback<long?> SelectedWeekNumberChanged { get; set; }
[Parameter] public bool CoerceText { get; set; } = true; // اجازه ورود دستی
[Parameter] public int FutureWeeksCount { get; set; } = 4;
[Parameter] public int PastWeeksCount { get; set; } = 12;
private WeekInfo? _selectedWeek;
private List<WeekInfo> _allWeeks = new();
private bool _isLoaded = false;
protected override async Task OnInitializedAsync()
{
await LoadWeeks();
if (SelectedWeekDefinitionId.HasValue && SelectedWeekDefinitionId.Value > 0 && _allWeeks.Any())
{
_selectedWeek = _allWeeks.FirstOrDefault(w => w.WeekDefinitionId == SelectedWeekDefinitionId.Value);
}
// else if (SelectedWeekNumber!=null && SelectedWeekNumber>0 && _allWeeks.Any())
// {
// _selectedWeek = _allWeeks.FirstOrDefault(w => w.WeekDefinitionId == SelectedWeekNumber);
// }
}
protected override async Task OnParametersSetAsync()
{
if (SelectedWeekDefinitionId.HasValue && SelectedWeekDefinitionId.Value > 0 &&
_selectedWeek?.WeekDefinitionId != SelectedWeekDefinitionId.Value &&
_isLoaded)
{
_selectedWeek = _allWeeks.FirstOrDefault(w => w.WeekDefinitionId == SelectedWeekDefinitionId.Value);
}
// else if (SelectedWeekNumber!=null && SelectedWeekNumber>0 &&
// _selectedWeek?.WeekDefinitionId != SelectedWeekNumber &&
// _isLoaded)
// {
// _selectedWeek = _allWeeks.FirstOrDefault(w => w.WeekDefinitionId == SelectedWeekNumber);
// }
}
private async Task LoadWeeks()
{
try
{
var request = new GetAvailableWeeksRequest
{
FutureWeeksCount = FutureWeeksCount,
PastWeeksCount = PastWeeksCount
};
var response = await CommissionContract.GetAvailableWeeksAsync(request);
_allWeeks = new List<WeekInfo>();
// ترتیب: هفته جاری، محاسبه شده، در انتظار، آینده
if (response.CurrentWeek != null)
_allWeeks.Add(response.CurrentWeek);
if (response.CalculatedWeeks != null)
_allWeeks.AddRange(response.CalculatedWeeks);
if (response.PendingWeeks != null)
_allWeeks.AddRange(response.PendingWeeks);
if (response.FutureWeeks != null)
_allWeeks.AddRange(response.FutureWeeks);
_isLoaded = true;
}
catch
{
_allWeeks = new List<WeekInfo>();
_isLoaded = true;
}
}
private async Task<IEnumerable<WeekInfo>> Search(string value, CancellationToken cancellationToken)
{
if (!_isLoaded)
await LoadWeeks();
if (string.IsNullOrWhiteSpace(value))
return _allWeeks;
// جستجو در DisplayName یا DisplayText
return _allWeeks
.Where(w => w.DisplayName.Contains(value, StringComparison.OrdinalIgnoreCase) ||
w.DisplayText.Contains(value, StringComparison.OrdinalIgnoreCase))
.ToList();
}
private async Task OnSelected(WeekInfo? selected)
{
_selectedWeek = selected;
SelectedWeekDefinitionId = selected?.WeekDefinitionId;
// SelectedWeekNumber = selected.WeekDefinitionId;
await SelectedWeekDefinitionIdChanged.InvokeAsync(SelectedWeekDefinitionId);
// await SelectedWeekNumberChanged.InvokeAsync(SelectedWeekNumber);
}
private async Task OnClear()
{
_selectedWeek = null;
SelectedWeekDefinitionId = null;
// SelectedWeekNumber = null;
await SelectedWeekDefinitionIdChanged.InvokeAsync(null);
// await SelectedWeekNumberChanged.InvokeAsync(null);
}
}
@@ -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,98 @@
@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.Slug"
Label="نشانی URL (slug)"
Variant="Variant.Outlined"
HelperText="اختیاری — خالی بگذارید تا خودکار از عنوان ساخته شود. فقط حروف کوچک انگلیسی، عدد و خط تیره." />
<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();
}
}
@@ -1,27 +1,31 @@
@attribute [Route(RouteConstance.Category)] @attribute [Route(RouteConstance.Category)]
@using BackOffice.BFF.Category.Protobuf.Protos.Category @using CMSMicroservice.Protobuf.Protos.Category
@using BackOffice.Common.BaseComponents @using BackOffice.Common.BaseComponents
@using BackOffice.Pages.AutoComplete @using BackOffice.Pages.AutoComplete
@using DataModel = BackOffice.BFF.Category.Protobuf.Protos.Category.GetAllCategoryByFilterResponseModel @using DataModel = CMSMicroservice.Protobuf.Protos.Category.GetAllCategoryByFilterResponseModel
<BasePageComponent @ref="_basePage" OnClearFilterClick="OnFilterCleared" OnSubmitClick="OnFilterSubmit"> <BasePageComponent @ref="_basePage" OnClearFilterClick="OnFilterCleared" OnSubmitClick="OnFilterSubmit">
<Filters> <Filters>
<MudItem xs="12" sm="6" md="3">
<MudTextField T="string" Clearable="true" Label="عنوان" @bind-Value="@_request.Filter.Title" Variant="Variant.Outlined" Margin="Margin.Dense" /> <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" <CategoryAutoComplete Value="_parentFilterId"
ValueChanged="OnParentFilterChanged" ValueChanged="OnParentFilterChanged"
Label="فیلتر بر اساس دسته‌بندی والد" /> Label="فیلتر بر اساس دسته‌بندی والد" />
</MudItem>
</Filters> </Filters>
<Content> <Content>
<MudGrid> <MudGrid>
<MudItem xs="12" md="3"> <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> <MudText Typo="Typo.subtitle2" Class="mb-2">درخت دسته‌بندی‌ها</MudText>
<MudList T="string" Dense="true"> <MudList T="string" Dense="true">
<MudListItem T="string" <MudListItem T="string"
OnClick="@(() => OnTreeNodeSelected(null))" OnClick="@(() => OnTreeNodeSelected(null))"
Selected="@(_parentFilterId == null)" Selected="@(_parentFilterId == null)"
Style="cursor:pointer;"> >
<MudText Typo="Typo.body2">همه دسته‌بندی‌ها</MudText> <MudText Typo="Typo.body2">همه دسته‌بندی‌ها</MudText>
</MudListItem> </MudListItem>
@foreach (var node in _treeNodes) @foreach (var node in _treeNodes)
@@ -29,7 +33,7 @@
<MudListItem T="string" <MudListItem T="string"
OnClick="@(() => OnTreeNodeSelected(node.Id))" OnClick="@(() => OnTreeNodeSelected(node.Id))"
Selected="@(_parentFilterId == node.Id)" Selected="@(_parentFilterId == node.Id)"
Style="cursor:pointer;"> >
<MudStack Row="true" AlignItems="AlignItems.Center"> <MudStack Row="true" AlignItems="AlignItems.Center">
<div style="width:@(node.Level * 16)px;"></div> <div style="width:@(node.Level * 16)px;"></div>
<MudText Typo="Typo.body2">@node.Title</MudText> <MudText Typo="Typo.body2">@node.Title</MudText>
@@ -44,13 +48,14 @@
ServerData="@(new Func<GridState<DataModel>, Task<GridData<DataModel>>>(ServerReload))" ServerData="@(new Func<GridState<DataModel>, Task<GridData<DataModel>>>(ServerReload))"
Hover="true" Hover="true"
@ref="_gridData" @ref="_gridData"
Height="72vh"> Height="calc(100vh - 240px)">
<ColGroup> <ColGroup>
<col /> <col />
<col /> <col />
<col /> <col />
<col /> <col />
<col /> <col />
<col />
<col style="width: 80px;" /> <col style="width: 80px;" />
</ColGroup> </ColGroup>
<ToolBarContent> <ToolBarContent>
@@ -62,7 +67,7 @@
Size="Size.Large" Size="Size.Large"
ButtonType="ButtonType.Button" ButtonType="ButtonType.Button"
OnClick="CreateNew" OnClick="CreateNew"
Style="cursor:pointer;"> >
افزودن افزودن
</MudButton> </MudButton>
</MudStack> </MudStack>
@@ -85,6 +90,8 @@
<MudText Typo="Typo.body2">@parentTitle</MudText> <MudText Typo="Typo.body2">@parentTitle</MudText>
</CellTemplate> </CellTemplate>
</TemplateColumn> </TemplateColumn>
<PropertyColumn Property="x => x.SortOrder" Title="ترتیب" />
<PropertyColumn Property="x => x.ProductCount" Title="تعداد محصولات" />
<TemplateColumn Title="فعال؟"> <TemplateColumn Title="فعال؟">
<CellTemplate> <CellTemplate>
<MudChip Color="@(context.Item.IsActive ? Color.Success : Color.Error)" <MudChip Color="@(context.Item.IsActive ? Color.Success : Color.Error)"
@@ -102,15 +109,16 @@
Size="Size.Small" Size="Size.Small"
ButtonType="ButtonType.Button" ButtonType="ButtonType.Button"
OnClick="@(() => Update(context.Item))" OnClick="@(() => Update(context.Item))"
Style="cursor:pointer;" /> />
</MudTooltip> </MudTooltip>
<MudTooltip Text="حذف"> <MudTooltip Text="حذف">
<MudIconButton Icon="@Icons.Material.Filled.DeleteOutline" <MudIconButton Icon="@Icons.Material.Filled.DeleteOutline"
Size="Size.Small" Size="Size.Small"
ButtonType="ButtonType.Button" ButtonType="ButtonType.Button"
Disabled="@HasChildren(context.Item.Id)"
OnClick="@(() => OnDelete(context.Item))" OnClick="@(() => OnDelete(context.Item))"
Style="cursor:pointer;" /> />
</MudTooltip> </MudTooltip>
<MudTooltip Text="مدیریت محصولات این دسته (درگ و دراپ)"> <MudTooltip Text="مدیریت محصولات این دسته (درگ و دراپ)">
@@ -118,17 +126,29 @@
Size="Size.Small" Size="Size.Small"
ButtonType="ButtonType.Button" ButtonType="ButtonType.Button"
OnClick="@(() => OpenCategoryProducts(context.Item))" 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> </MudTooltip>
</MudStack> </MudStack>
</CellTemplate> </CellTemplate>
</TemplateColumn> </TemplateColumn>
</Columns> </Columns>
<NoRecordsContent>
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Text" Class="my-4">موردی یافت نشد.</MudAlert>
</NoRecordsContent>
<PagerContent> <PagerContent>
<MudDataGridPager T="DataModel" <MudDataGridPager T="DataModel"
PageSizeOptions="@(new int[] { 30, 60, 90 })" PageSizeOptions="@(new int[] { 20, 50, 100 })"
InfoFormat="سطر {first_item} تا {last_item} از {all_items}" InfoFormat="سطر {first_item} تا {last_item} از {all_items}"
RowsPerPageString="تعداد سطرهای صفحه" /> RowsPerPageString="تعداد در صفحه" />
</PagerContent> </PagerContent>
</MudDataGrid> </MudDataGrid>
</MudItem> </MudItem>
@@ -1,11 +1,12 @@
using System.Text.Json; using System.Text.Json;
using BackOffice.BFF.Category.Protobuf.Protos.Category; using CMSMicroservice.Protobuf.Protos;
using CMSMicroservice.Protobuf.Protos.Category;
using BackOffice.Common.BaseComponents; using BackOffice.Common.BaseComponents;
using BackOffice.Common.Utilities; using BackOffice.Common.Utilities;
using Mapster; using Mapster;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using MudBlazor; using MudBlazor;
using DataModel = BackOffice.BFF.Category.Protobuf.Protos.Category.GetAllCategoryByFilterResponseModel; using DataModel = CMSMicroservice.Protobuf.Protos.Category.GetAllCategoryByFilterResponseModel;
namespace BackOffice.Pages.Category; namespace BackOffice.Pages.Category;
@@ -151,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) public async Task Update(DataModel model)
{ {
var getResponse = await CategoryContract.GetCategoryAsync(new GetCategoryRequest { Id = model.Id }); var getResponse = await CategoryContract.GetCategoryAsync(new GetCategoryRequest { Id = model.Id });
var editModel = getResponse.Adapt<UpdateCategoryRequest>(); var editModel = getResponse.Adapt<UpdateCategoryRequest>();
// Console.WriteLine(JsonSerializer.Serialize(editModel));
var dialog = await DialogService.ShowAsync<CreateOrUpdateCategoryDialog>( var dialog = await DialogService.ShowAsync<CreateOrUpdateCategoryDialog>(
"ویرایش دسته‌بندی", "ویرایش دسته‌بندی",
new DialogParameters<CreateOrUpdateCategoryDialog> new DialogParameters<CreateOrUpdateCategoryDialog>
@@ -179,7 +198,7 @@ public partial class CategoryMainPage
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small }; var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small };
bool? result = await DialogService.ShowMessageBox( bool? result = await DialogService.ShowMessageBox(
"اخطار", "اخطار",
"آیا از حذف این دسته‌بندی مطمئن هستید؟", $"آیا از حذف دسته‌بندی «{model.Title}» مطمئن هستید؟",
yesText: "حذف", yesText: "حذف",
cancelText: "لغو", cancelText: "لغو",
options: options); options: options);
@@ -237,4 +256,9 @@ public partial class CategoryMainPage
{ {
Navigation.NavigateTo($"{RouteConstance.CategoryProducts}{model.Id}"); Navigation.NavigateTo($"{RouteConstance.CategoryProducts}{model.Id}");
} }
private bool HasChildren(long categoryId)
{
return _treeNodes.Any(n => n.ParentId == categoryId);
}
} }
@@ -1,6 +1,6 @@
@attribute [Route(RouteConstance.CategoryProducts + "{CategoryId:long?}")] @attribute [Route(RouteConstance.CategoryProducts + "{CategoryId:long?}")]
@using BackOffice.BFF.Products.Protobuf.Protos.Products @using CMSMicroservice.Protobuf.Protos.Products
@using BackOffice.Common.BaseComponents @using BackOffice.Common.BaseComponents
@using BackOffice.Pages.AutoComplete @using BackOffice.Pages.AutoComplete
@@ -1,5 +1,5 @@
using BackOffice.BFF.Products.Protobuf.Protos.Products; using CMSMicroservice.Protobuf.Protos.Products;
using BackOffice.BFF.Category.Protobuf.Protos.Category; using CMSMicroservice.Protobuf.Protos.Category;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.Web;
using MudBlazor; using MudBlazor;
@@ -1,19 +1,30 @@
@using BackOffice.BFF.Category.Protobuf.Protos.Category @using CMSMicroservice.Protobuf.Protos.Category
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using BackOffice.Pages.AutoComplete @using BackOffice.Pages.AutoComplete
<MudDialog> <MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">
<MudIcon Icon="@(IsEdit ? Icons.Material.Filled.Edit : Icons.Material.Filled.Add)" Class="ml-2" />
@(IsEdit ? "ویرایش دسته‌بندی" : "ایجاد دسته‌بندی جدید")
</MudText>
</TitleContent>
<DialogContent> <DialogContent>
<MudForm @ref="_form" @bind-IsValid="@_isValid">
<MudStack Spacing="2"> <MudStack Spacing="2">
<MudTextField T="string" <MudTextField T="string"
@bind-Value="Name" @bind-Value="Name"
Label="نام لاتین" Label="نام لاتین"
Required="true"
RequiredError="نام لاتین الزامی است"
Variant="Variant.Outlined" Variant="Variant.Outlined"
Margin="Margin.Dense" /> Margin="Margin.Dense" />
<MudTextField T="string" <MudTextField T="string"
@bind-Value="Title" @bind-Value="Title"
Label="عنوان" Label="عنوان"
Required="true"
RequiredError="عنوان الزامی است"
Variant="Variant.Outlined" Variant="Variant.Outlined"
Margin="Margin.Dense" /> Margin="Margin.Dense" />
@@ -25,22 +36,40 @@
Lines="3" Lines="3"
TextArea="true" /> TextArea="true" />
<MudTextField T="string"
@bind-Value="ImagePath"
Label="آدرس تصویر"
Variant="Variant.Outlined"
Margin="Margin.Dense" />
<CategoryAutoComplete Value="ParentId" <CategoryAutoComplete Value="ParentId"
ValueChanged="OnParentChanged" ValueChanged="OnParentChanged"
Label="دسته‌بندی والد (اختیاری)" /> Label="دسته‌بندی والد (اختیاری)" />
<MudText Typo="Typo.caption" Color="Color.Secondary">برای دسته اصلی خالی بگذارید</MudText>
<MudNumericField T="int" <MudNumericField T="int"
@bind-Value="SortOrder" @bind-Value="SortOrder"
HideSpinButtons="true" HideSpinButtons="true"
Label="ترتیب نمایش" Label="ترتیب نمایش"
Variant="Variant.Outlined" Variant="Variant.Outlined"
Margin="Margin.Dense" /> Margin="Margin.Dense"
HelperText="عدد کمتر = اولویت بالاتر" />
<MudCheckBox T="bool" @bind-Value="IsActive" Label="فعال" /> <MudSwitch T="bool" @bind-Value="IsActive" Label="فعال" Color="Color.Success" />
</MudStack> </MudStack>
</MudForm>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<MudButton OnClick="Cancel">لغو</MudButton> <MudButton OnClick="Cancel" Variant="Variant.Text">لغو</MudButton>
<MudButton Color="Color.Primary" OnClick="Save">ثبت</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> </DialogActions>
</MudDialog> </MudDialog>
@@ -1,4 +1,4 @@
using BackOffice.BFF.Category.Protobuf.Protos.Category; using CMSMicroservice.Protobuf.Protos.Category;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using MudBlazor; using MudBlazor;
@@ -14,9 +14,14 @@ public partial class CreateOrUpdateCategoryDialog
private bool IsEdit => EditModel != null; private bool IsEdit => EditModel != null;
private MudForm? _form;
private bool _isValid;
private bool _isSaving;
private string Name { get; set; } = string.Empty; private string Name { get; set; } = string.Empty;
private string Title { get; set; } = string.Empty; private string Title { get; set; } = string.Empty;
private string? Description { get; set; } private string? Description { get; set; }
private string? ImagePath { get; set; }
private long? ParentId { get; set; } private long? ParentId { get; set; }
private bool IsActive { get; set; } = true; private bool IsActive { get; set; } = true;
private int SortOrder { get; set; } private int SortOrder { get; set; }
@@ -28,9 +33,9 @@ public partial class CreateOrUpdateCategoryDialog
Name = EditModel.Name; Name = EditModel.Name;
Title = EditModel.Title; Title = EditModel.Title;
Description = EditModel.Description; Description = EditModel.Description;
ImagePath = EditModel.ImagePath;
ParentId = EditModel.ParentId; ParentId = EditModel.ParentId;
IsActive = EditModel.IsActive; IsActive = EditModel.IsActive;
Console.WriteLine(EditModel.IsActive);
SortOrder = EditModel.SortOrder; SortOrder = EditModel.SortOrder;
} }
else if (Model != null) else if (Model != null)
@@ -38,6 +43,7 @@ public partial class CreateOrUpdateCategoryDialog
Name = Model.Name; Name = Model.Name;
Title = Model.Title; Title = Model.Title;
Description = Model.Description; Description = Model.Description;
ImagePath = Model.ImagePath;
ParentId = Model.ParentId; ParentId = Model.ParentId;
IsActive = Model.IsActive; IsActive = Model.IsActive;
SortOrder = Model.SortOrder; SortOrder = Model.SortOrder;
@@ -45,6 +51,17 @@ public partial class CreateOrUpdateCategoryDialog
} }
private async Task Save() private async Task Save()
{
if (_form != null)
{
await _form.Validate();
if (!_isValid) return;
}
_isSaving = true;
StateHasChanged();
try
{ {
if (IsEdit && EditModel != null) if (IsEdit && EditModel != null)
{ {
@@ -54,6 +71,7 @@ public partial class CreateOrUpdateCategoryDialog
Name = Name, Name = Name,
Title = Title, Title = Title,
Description = Description, Description = Description,
ImagePath = ImagePath,
ParentId = ParentId, ParentId = ParentId,
IsActive = IsActive, IsActive = IsActive,
SortOrder = SortOrder SortOrder = SortOrder
@@ -68,6 +86,7 @@ public partial class CreateOrUpdateCategoryDialog
Name = Name, Name = Name,
Title = Title, Title = Title,
Description = Description, Description = Description,
ImagePath = ImagePath,
ParentId = ParentId, ParentId = ParentId,
IsActive = IsActive, IsActive = IsActive,
SortOrder = SortOrder SortOrder = SortOrder
@@ -78,6 +97,16 @@ public partial class CreateOrUpdateCategoryDialog
MudDialog.Close(DialogResult.Ok(true)); MudDialog.Close(DialogResult.Ok(true));
} }
catch (Exception ex)
{
Snackbar.Add($"خطا: {ex.Message}", Severity.Error);
}
finally
{
_isSaving = false;
StateHasChanged();
}
}
private Task OnParentChanged(long? parentId) private Task OnParentChanged(long? parentId)
{ {
@@ -0,0 +1,202 @@
@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>
@* ── G9: راهنمای ماتریس فیچر (Q28) ── *@
<MudAlert Severity="Severity.Info" Variant="Variant.Text" Dense="true" Class="mb-3" Icon="@Icons.Material.Filled.Info">
فیچرهای تیک‌خورده برای خریداران این پکیج فعال می‌شود. تغییر فیچرها روی کاربران فعلی بلافاصله اعمال می‌شود.
</MudAlert>
<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);
}
}
}
+53
View File
@@ -0,0 +1,53 @@
@page "/club"
@page "/club/members"
@page "/club/purchases"
@page "/club/customer-packages"
@page "/club/cycles"
@page "/club/history"
@page "/club/statistics"
@attribute [Authorize]
@using BackOffice.Pages.Club.Reports
<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.ShoppingBag">
<ClubPackagePurchasesReport />
</MudTabPanel>
<MudTabPanel Text="گزارش مشتریان پکیج" Icon="@Icons.Material.Filled.PeopleAlt">
<CustomerPackagePurchasesReport />
</MudTabPanel>
<MudTabPanel Text="گزارش دوره‌ها" Icon="@Icons.Material.Filled.Loop">
<ClubCyclesReport />
</MudTabPanel>
<MudTabPanel Text="گزارش تاریخچه" Icon="@Icons.Material.Filled.History">
<ClubHistoriesReport />
</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/purchases" => 1,
"club/customer-packages" => 2,
"club/cycles" => 3,
"club/history" => 4,
"club/statistics" => 5,
_ => 0
};
}
}
+26 -10
View File
@@ -1,23 +1,30 @@
@page "/club/members" @* Merged into /club *@
@attribute [Authorize] @attribute [Authorize]
@using BackOffice.BFF.ClubMembership.Protobuf
@using Google.Protobuf.WellKnownTypes @using Google.Protobuf.WellKnownTypes
@using BackOffice.Pages.Club.Components @using BackOffice.Pages.Club.Components
@using BackOffice.Pages.AutoComplete
@using CMSMicroservice.Protobuf.Protos.ClubMembership
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="mt-4"> <div>
<MudText Typo="Typo.h4" Class="mb-4">مدیریت اعضای باشگاه</MudText> <MudText Typo="Typo.h4" Class="mb-4">مدیریت اعضای باشگاه</MudText>
@* ── G13: راهنمای اعضای باشگاه (Q28) ── *@
<MudAlert Severity="Severity.Info" Variant="Variant.Text" Dense="true" Class="mb-3" Icon="@Icons.Material.Filled.Info">
فیلتر بر اساس آخرین پکیج خریداری‌شده اعمال می‌شود. هر کاربر می‌تواند چندین دوره پکیج داشته باشد.
</MudAlert>
<MudDataGrid T="ClubMembershipModel" <MudDataGrid T="ClubMembershipModel"
ServerData="@(new Func<GridState<ClubMembershipModel>, Task<GridData<ClubMembershipModel>>>(ServerReload))" ServerData="@(new Func<GridState<ClubMembershipModel>, Task<GridData<ClubMembershipModel>>>(ServerReload))"
Hover="true" Hover="true"
@ref="_gridData" @ref="_gridData"
Height="75vh"> Height="calc(100vh - 240px)">
<ToolBarContent> <ToolBarContent>
<MudText Typo="Typo.h6">لیست اعضا</MudText> <MudText Typo="Typo.h6">لیست اعضا</MudText>
<MudSpacer /> <MudSpacer />
<MudStack Row="true" Spacing="2"> <MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center">
<MudSwitch @bind-Value="_filterIsActive" <UserAutoComplete Label="جستجوی کاربر" @bind-SelectedUserId="_filterUserId" />
<MudSwitch T="bool" @bind-Value="_filterIsActive"
Color="Color.Success" Color="Color.Success"
Label="فقط فعال‌ها" /> Label="فقط فعال‌ها" />
<MudButton Variant="Variant.Filled" <MudButton Variant="Variant.Filled"
@@ -32,6 +39,12 @@
StartIcon="@Icons.Material.Filled.Add"> StartIcon="@Icons.Material.Filled.Add">
فعال‌سازی عضو جدید فعال‌سازی عضو جدید
</MudButton> </MudButton>
<MudButton Variant="Variant.Outlined"
Color="Color.Info"
OnClick="ExportToExcel"
StartIcon="@Icons.Material.Filled.FileDownload">
خروجی Excel
</MudButton>
</MudStack> </MudStack>
</ToolBarContent> </ToolBarContent>
<Columns> <Columns>
@@ -58,14 +71,14 @@
<PropertyColumn Property="x => x.ActivatedAt" Title="تاریخ فعال‌سازی"> <PropertyColumn Property="x => x.ActivatedAt" Title="تاریخ فعال‌سازی">
<CellTemplate> <CellTemplate>
@context.Item.ActivatedAt.ToDateTime().ToLocalTime().ToString("yyyy/MM/dd") @(context.Item.ActivatedAt != null ? context.Item.ActivatedAt.ToDateTime().ToLocalTime().MiladiToJalali() : "—")
</CellTemplate> </CellTemplate>
</PropertyColumn> </PropertyColumn>
<PropertyColumn Property="x => x.ExpiresAt" Title="تاریخ انقضا"> <PropertyColumn Property="x => x.ExpiresAt" Title="تاریخ انقضا">
<CellTemplate> <CellTemplate>
<MudText Color="@(context.Item.IsExpired ? Color.Error : Color.Success)"> <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> </MudText>
</CellTemplate> </CellTemplate>
</PropertyColumn> </PropertyColumn>
@@ -102,11 +115,14 @@
</CellTemplate> </CellTemplate>
</TemplateColumn> </TemplateColumn>
</Columns> </Columns>
<NoRecordsContent>
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Text" Class="my-4">موردی یافت نشد.</MudAlert>
</NoRecordsContent>
<PagerContent> <PagerContent>
<MudDataGridPager T="ClubMembershipModel" <MudDataGridPager T="ClubMembershipModel"
PageSizeOptions="@(new int[] { 20, 50, 100 })" PageSizeOptions="@(new int[] { 20, 50, 100 })"
InfoFormat="سطر {first_item} تا {last_item} از {all_items}" InfoFormat="سطر {first_item} تا {last_item} از {all_items}"
RowsPerPageString="تعداد سطرهای صفحه" /> RowsPerPageString="تعداد در صفحه" />
</PagerContent> </PagerContent>
</MudDataGrid> </MudDataGrid>
</MudContainer> </div>
+69 -6
View File
@@ -1,17 +1,22 @@
using BackOffice.BFF.ClubMembership.Protobuf;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using MudBlazor; using MudBlazor;
using Google.Protobuf.WellKnownTypes; using Google.Protobuf.WellKnownTypes;
using BackOffice.Pages.Club.Components; using BackOffice.Pages.Club.Components;
using CMSMicroservice.Protobuf.Protos.ClubMembership;
using Microsoft.JSInterop;
using System.Text;
using DateTimeConverterCL;
namespace BackOffice.Pages.Club; namespace BackOffice.Pages.Club;
public partial class ClubMembers public partial class ClubMembers
{ {
[Inject] public ClubMembershipContract.ClubMembershipContractClient ClubContract { get; set; } [Inject] public ClubMembershipContract.ClubMembershipContractClient ClubContract { get; set; }
[Inject] public IJSRuntime JsRuntime { get; set; }
private MudDataGrid<ClubMembershipModel> _gridData; private MudDataGrid<ClubMembershipModel> _gridData;
private bool? _filterIsActive = true; private bool _filterIsActive = true;
private long? _filterUserId;
private async Task<GridData<ClubMembershipModel>> ServerReload(GridState<ClubMembershipModel> state) private async Task<GridData<ClubMembershipModel>> ServerReload(GridState<ClubMembershipModel> state)
{ {
@@ -23,10 +28,11 @@ public partial class ClubMembers
PageSize = state.PageSize PageSize = state.PageSize
}; };
if (_filterIsActive.HasValue) // Always use the filter value since it's now a bool (not nullable)
{ request.IsActive = _filterIsActive;
request.IsActive = _filterIsActive.Value;
} if (_filterUserId.HasValue && _filterUserId.Value > 0)
request.UserId = _filterUserId.Value;
var result = await ClubContract.GetAllClubMembershipsAsync(request); var result = await ClubContract.GetAllClubMembershipsAsync(request);
@@ -110,4 +116,61 @@ public partial class ClubMembers
await ApplyFilter(); 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;
}
} }
@@ -1,32 +1,39 @@
@using BackOffice.BFF.ClubMembership.Protobuf @using CMSMicroservice.Protobuf.Protos.ClubMembership
@using CMSMicroservice.Protobuf.Protos.Package
@using BackOffice.Pages.AutoComplete
<MudDialog> <MudDialog>
<DialogContent> <DialogContent>
<MudForm @ref="_form" @bind-IsValid="_formValid"> <MudForm @ref="_form" @bind-IsValid="_formValid">
<MudStack Spacing="3"> <MudStack Spacing="3">
<MudNumericField @bind-Value="_model.UserId" <UserAutoComplete Label="انتخاب کاربر" @bind-SelectedUserId="_model.UserId" />
Label="شناسه کاربر"
Required="true"
RequiredError="شناسه کاربر الزامی است"
Variant="Variant.Outlined" />
<MudNumericField @bind-Value="_model.PackageId" @if (_isLoadingPackages)
Label="شناسه پکیج" {
<MudSkeleton SkeletonType="SkeletonType.Rectangle" Height="56px" />
}
else
{
<MudSelect T="long" @bind-Value="_model.PackageId"
Label="انتخاب پکیج"
Required="true" Required="true"
RequiredError="شناسه پکیج الزامی است" RequiredError="انتخاب پکیج الزامی است"
Variant="Variant.Outlined" /> Variant="Variant.Outlined"
AnchorOrigin="Origin.BottomCenter">
<MudNumericField @bind-Value="_model.DurationMonths" @foreach (var pkg in _packages)
Label="مدت زمان (ماه)" {
Required="true" <MudSelectItem T="long" Value="pkg.Id">
RequiredError="مدت زمان الزامی است" @pkg.Title — @($"{pkg.Price:N0}") ریال
Min="1" </MudSelectItem>
Max="12" }
Variant="Variant.Outlined" /> </MudSelect>
}
<MudAlert Severity="Severity.Info" Dense="true"> <MudAlert Severity="Severity.Info" Dense="true">
<MudText Typo="Typo.body2"> <MudText Typo="Typo.body2">
با تایید، عضویت باشگاه برای کاربر فعال می‌شود. با تایید، عضویت باشگاه برای کاربر فعال می‌شود.
<br/>
<small>عضویت باشگاه بدون محدودیت زمانی است.</small>
</MudText> </MudText>
</MudAlert> </MudAlert>
</MudStack> </MudStack>
@@ -37,7 +44,7 @@
<MudButton Color="Color.Success" <MudButton Color="Color.Success"
Variant="Variant.Filled" Variant="Variant.Filled"
OnClick="Submit" OnClick="Submit"
Disabled="@(!_formValid || _isSubmitting)"> Disabled="@(!_formValid || _isSubmitting || _isLoadingPackages)">
@if (_isSubmitting) @if (_isSubmitting)
{ {
<MudProgressCircular Size="Size.Small" Indeterminate="true" /> <MudProgressCircular Size="Size.Small" Indeterminate="true" />
@@ -54,12 +61,49 @@
@code { @code {
[CascadingParameter] IMudDialogInstance MudDialog { get; set; } [CascadingParameter] IMudDialogInstance MudDialog { get; set; }
[Inject] public ClubMembershipContract.ClubMembershipContractClient ClubContract { get; set; } [Inject] public ClubMembershipContract.ClubMembershipContractClient ClubContract { get; set; }
[Inject] public PackageContract.PackageContractClient PackageClient { get; set; }
private MudForm _form; private MudForm _form;
private bool _formValid; private bool _formValid;
private bool _isSubmitting; private bool _isSubmitting;
private bool _isLoadingPackages = true;
private List<GetAllPackageByFilterResponseModel> _packages = new();
private ActivateModel _model = new(); private ActivateModel _model = new();
protected override async Task OnInitializedAsync()
{
await LoadPackages();
}
private async Task LoadPackages()
{
_isLoadingPackages = true;
try
{
var request = new GetAllPackageByFilterRequest
{
PaginationState = new CMSMicroservice.Protobuf.Protos.PaginationState
{
PageNumber = 1,
PageSize = 50
}
};
var result = await PackageClient.GetAllPackageByFilterAsync(request);
if (result?.Models != null)
{
_packages = result.Models.ToList();
}
}
catch (Exception ex)
{
Snackbar.Add($"خطا در بارگذاری پکیج‌ها: {ex.Message}", Severity.Error);
}
finally
{
_isLoadingPackages = false;
}
}
private void Cancel() => MudDialog.Cancel(); private void Cancel() => MudDialog.Cancel();
private async Task Submit() private async Task Submit()
@@ -67,19 +111,31 @@
await _form.Validate(); await _form.Validate();
if (!_formValid) return; if (!_formValid) return;
if (!_model.UserId.HasValue || _model.UserId.Value <= 0)
{
Snackbar.Add("لطفاً کاربر را انتخاب کنید.", Severity.Warning);
return;
}
if (_model.PackageId <= 0)
{
Snackbar.Add("لطفاً پکیج را انتخاب کنید.", Severity.Warning);
return;
}
_isSubmitting = true; _isSubmitting = true;
try try
{ {
var request = new ActivateClubMembershipRequest var request = new ActivateClubMembershipRequest
{ {
UserId = _model.UserId, UserId = _model.UserId.Value,
PackageId = _model.PackageId, PackageId = _model.PackageId,
DurationMonths = _model.DurationMonths ForceActivation = true
}; };
var response = await ClubContract.ActivateClubMembershipAsync(request); var response = await ClubContract.ActivateClubMembershipAsync(request);
Snackbar.Add($"عضویت با موفقیت فعال شد.", Severity.Success); Snackbar.Add("عضویت با موفقیت فعال شد.", Severity.Success);
MudDialog.Close(DialogResult.Ok(true)); MudDialog.Close(DialogResult.Ok(true));
} }
catch (Exception ex) catch (Exception ex)
@@ -94,8 +150,7 @@
private class ActivateModel private class ActivateModel
{ {
public long UserId { get; set; } public long? UserId { get; set; }
public long PackageId { get; set; } public long PackageId { get; set; }
public int DurationMonths { get; set; } = 1;
} }
} }
@@ -1,4 +1,4 @@
@using BackOffice.BFF.ClubMembership.Protobuf @using CMSMicroservice.Protobuf.Protos.ClubMembership
<MudDialog> <MudDialog>
<DialogContent> <DialogContent>
@@ -1,4 +1,4 @@
@using BackOffice.BFF.ClubMembership.Protobuf @using CMSMicroservice.Protobuf.Protos.ClubMembership
<MudDialog> <MudDialog>
<DialogContent> <DialogContent>
@@ -33,23 +33,11 @@
</tr> </tr>
<tr> <tr>
<td><strong>پکیج:</strong></td> <td><strong>پکیج:</strong></td>
<td>@Member.PackageName</td> <td>@(string.IsNullOrEmpty(Member.PackageName) ? "—" : Member.PackageName)</td>
</tr>
<tr>
<td><strong>کد فعال‌سازی:</strong></td>
<td dir="ltr">@Member.ActivationCode</td>
</tr> </tr>
<tr> <tr>
<td><strong>تاریخ فعال‌سازی:</strong></td> <td><strong>تاریخ فعال‌سازی:</strong></td>
<td>@Member.ActivatedAt.ToDateTime().ToLocalTime().ToString("yyyy/MM/dd HH:mm")</td> <td>@(Member.ActivatedAt != null ? 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")
</MudText>
</td>
</tr> </tr>
<tr> <tr>
<td><strong>وضعیت:</strong></td> <td><strong>وضعیت:</strong></td>
@@ -62,31 +50,16 @@
</td> </td>
</tr> </tr>
<tr> <tr>
<td><strong>منقضی شده:</strong></td> <td><strong>تاریخ ایجاد:</strong></td>
<td> <td>@Member.Created.ToDateTime().ToLocalTime().MiladiToJalaliWithTime()</td>
<MudChip T="string"
Color="@(Member.IsExpired ? Color.Warning : Color.Default)"
Size="Size.Small">
@(Member.IsExpired ? "بله" : "خیر")
</MudChip>
</td>
</tr> </tr>
</tbody> </tbody>
</MudSimpleTable> </MudSimpleTable>
</MudPaper> </MudPaper>
<MudPaper Class="pa-3" Elevation="0"> <MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Text">
<MudText Typo="Typo.h6" Color="Color.Primary">تاریخچه</MudText> تاریخچه تغییرات عضویت در تب «گزارش تاریخچه» باشگاه قابل مشاهده است.
<MudDivider Class="my-2" /> </MudAlert>
<MudSimpleTable Dense="true">
<tbody>
<tr>
<td><strong>تاریخ ایجاد:</strong></td>
<td>@Member.Created.ToDateTime().ToLocalTime().ToString("yyyy/MM/dd HH:mm:ss")</td>
</tr>
</tbody>
</MudSimpleTable>
</MudPaper>
</MudStack> </MudStack>
} }
</DialogContent> </DialogContent>
@@ -98,6 +71,5 @@
@code { @code {
[CascadingParameter] IMudDialogInstance MudDialog { get; set; } [CascadingParameter] IMudDialogInstance MudDialog { get; set; }
[Parameter] public ClubMembershipModel Member { get; set; } [Parameter] public ClubMembershipModel Member { get; set; }
private void Close() => MudDialog.Close(); private void Close() => MudDialog.Close();
} }
@@ -0,0 +1,153 @@
@using BackOffice.Common.BaseComponents
@using BackOffice.Pages.AutoComplete
@using CMSMicroservice.Protobuf.Protos.ClubMembershipCycle
@using Google.Protobuf.WellKnownTypes
@inject ClubMembershipCycleContract.ClubMembershipCycleContractClient CycleClient
<MudAlert Severity="Severity.Info" Variant="Variant.Text" Dense="true" Class="mb-3">
گزارش دوره‌های عضویت باشگاه — هر خرید پکیج یک دور جدید ایجاد می‌کند.
</MudAlert>
<BasePageComponent @ref="_basePage" OnSubmitClick="OnFilterSubmit" OnClearFilterClick="OnFilterCleared">
<Filters>
<MudItem xs="12" sm="6" md="3">
<UserAutoComplete Label="کاربر" @bind-SelectedUserId="_userId" />
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudSelect T="bool?" Clearable="true" Label="دوره جاری" Variant="Variant.Outlined"
Margin="Margin.Dense" @bind-Value="_isCurrent">
<MudSelectItem T="bool?" Value="@((bool?)true)">فقط دوره جاری</MudSelectItem>
<MudSelectItem T="bool?" Value="@((bool?)false)">دوره‌های قبلی</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudSelect T="int?" Clearable="true" Label="وضعیت جادویی" Variant="Variant.Outlined"
Margin="Margin.Dense" @bind-Value="_magicState">
<MudSelectItem T="int?" Value="0">عادی</MudSelectItem>
<MudSelectItem T="int?" Value="1">در حالت جادویی</MudSelectItem>
<MudSelectItem T="int?" Value="2">جادویی تکمیل‌شده</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12" sm="6" md="4">
<DateRangePicker @bind-From="_fromDate" @bind-To="_toDate" Label="بازه خرید پکیج" />
</MudItem>
</Filters>
<Content>
<MudGrid Class="mb-3" Spacing="2">
<MudItem xs="4">
<MudPaper Class="pa-3" Outlined="true">
<MudText Typo="Typo.caption" Color="Color.Secondary">کل دوره‌ها</MudText>
<MudText Typo="Typo.h6">@_totalCount.ToString("N0")</MudText>
</MudPaper>
</MudItem>
<MudItem xs="4">
<MudPaper Class="pa-3" Outlined="true">
<MudText Typo="Typo.caption" Color="Color.Secondary">دوره جاری</MudText>
<MudText Typo="Typo.h6">@_currentCount.ToString("N0")</MudText>
</MudPaper>
</MudItem>
<MudItem xs="4">
<MudPaper Class="pa-3" Outlined="true">
<MudText Typo="Typo.caption" Color="Color.Secondary">جادویی فعال</MudText>
<MudText Typo="Typo.h6">@_magicActive.ToString("N0")</MudText>
</MudPaper>
</MudItem>
</MudGrid>
<MudDataGrid @ref="_grid" T="ClubMembershipCycleModel" ServerData="LoadData" ReadOnly="true"
Hover="true" Dense="true" Height="calc(100vh - 420px)">
<Columns>
<PropertyColumn Property="x => x.Id" Title="شناسه" />
<PropertyColumn Property="x => x.UserName" Title="کاربر" />
<PropertyColumn Property="x => x.CycleNumber" Title="شماره دور" />
<PropertyColumn Property="x => x.PackageName" Title="پکیج" />
<TemplateColumn Title="مبلغ (ریال)">
<CellTemplate>@context.Item.PackageAmount.ToString("N0")</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="روش خرید">
<CellTemplate>@ReportLabels.GetPurchaseMethodLabel(context.Item.PurchaseMethod)</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="تاریخ خرید">
<CellTemplate>@context.Item.PackagePurchasedAt.ToDateTime().ToLocalTime().MiladiToJalaliWithTime()</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="وضعیت جادویی">
<CellTemplate>
@ReportLabels.GetMagicStateLabel(null,
context.Item.MagicStartedAt?.ToDateTime(),
context.Item.MagicCompletedAt?.ToDateTime())
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="دوره جاری">
<CellTemplate>
<MudChip T="string" Size="Size.Small" Color="@(context.Item.IsCurrentCycle ? Color.Success : Color.Default)">
@(context.Item.IsCurrentCycle ? "بله" : "خیر")
</MudChip>
</CellTemplate>
</TemplateColumn>
</Columns>
<NoRecordsContent>
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Text" Class="my-4">موردی یافت نشد.</MudAlert>
</NoRecordsContent>
<PagerContent>
<MudDataGridPager T="ClubMembershipCycleModel" PageSizeOptions="@(new int[] { 20, 50, 100 })"
InfoFormat="سطر {first_item} تا {last_item} از {all_items}"
RowsPerPageString="تعداد در صفحه" />
</PagerContent>
</MudDataGrid>
</Content>
</BasePageComponent>
@code {
private BasePageComponent? _basePage;
private MudDataGrid<ClubMembershipCycleModel>? _grid;
private long? _userId;
private bool? _isCurrent;
private int? _magicState;
private DateTime? _fromDate, _toDate;
private long _totalCount, _currentCount, _magicActive;
private GetAllClubMembershipCycleByFilterFilter BuildFilter() => new()
{
UserId = _userId > 0 ? _userId : null,
IsCurrentCycle = _isCurrent,
MagicState = _magicState,
PackagePurchasedFrom = _fromDate.HasValue ? Timestamp.FromDateTime(DateTime.SpecifyKind(_fromDate.Value, DateTimeKind.Utc)) : null,
PackagePurchasedTo = _toDate.HasValue ? Timestamp.FromDateTime(DateTime.SpecifyKind(_toDate.Value.Date.AddDays(1).AddTicks(-1), DateTimeKind.Utc)) : null
};
private async Task<GridData<ClubMembershipCycleModel>> LoadData(GridState<ClubMembershipCycleModel> state)
{
try
{
var filter = BuildFilter();
var summary = await CycleClient.GetClubMembershipCycleSummaryAsync(new GetClubMembershipCycleSummaryRequest { Filter = filter });
_totalCount = summary.TotalCount;
_currentCount = summary.CurrentCycleCount;
_magicActive = summary.MagicActiveCount;
var result = await CycleClient.GetAllClubMembershipCycleByFilterAsync(new GetAllClubMembershipCycleByFilterRequest
{
PaginationState = new CMSMicroservice.Protobuf.Protos.PaginationState { PageNumber = state.Page + 1, PageSize = state.PageSize },
Filter = filter
});
return new GridData<ClubMembershipCycleModel>
{
Items = result?.Models?.ToList() ?? new(),
TotalItems = (int)(result?.MetaData?.TotalCount ?? 0)
};
}
catch (Exception ex)
{
Snackbar.Add($"خطا: {ex.Message}", Severity.Error);
return new GridData<ClubMembershipCycleModel>();
}
}
private async Task OnFilterSubmit() { if (_grid != null) await _grid.ReloadServerData(); }
private async Task OnFilterCleared()
{
_userId = null; _isCurrent = null; _magicState = null; _fromDate = _toDate = null;
if (_grid != null) await _grid.ReloadServerData();
}
}
@@ -0,0 +1,153 @@
@using BackOffice.Common.BaseComponents
@using BackOffice.Pages.AutoComplete
@using CMSMicroservice.Protobuf.Protos.ClubMembership
@using CMSMicroservice.Protobuf.Protos.ClubMembershipCycleHistory
@using Google.Protobuf.WellKnownTypes
@inject ClubMembershipContract.ClubMembershipContractClient ClubClient
@inject ClubMembershipCycleHistoryContract.ClubMembershipCycleHistoryContractClient CycleHistoryClient
<MudTabs Elevation="0" Rounded="true" Border="true" ApplyEffectsToContainer="true" PanelClass="pt-4">
<MudTabPanel Text="تغییرات عضویت" Icon="@Icons.Material.Filled.History">
<MudAlert Severity="Severity.Info" Variant="Variant.Text" Dense="true" Class="mb-3">
تاریخچه فعال/غیرفعال شدن عضویت باشگاه.
</MudAlert>
<MudStack Row="true" Spacing="2" Class="mb-3">
<UserAutoComplete Label="کاربر" @bind-SelectedUserId="_memUserId" />
<MudSelect T="int?" Clearable="true" Label="عملیات" Variant="Variant.Outlined" Margin="Margin.Dense"
@bind-Value="_memAction" Style="max-width:200px;">
<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>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="ReloadMembership">جستجو</MudButton>
</MudStack>
<MudDataGrid @ref="_memGrid" T="ClubMembershipHistoryModel" ServerData="LoadMembershipHistory" ReadOnly="true"
Hover="true" Dense="true" Height="calc(100vh - 400px)">
<Columns>
<PropertyColumn Property="x => x.Id" Title="شناسه" />
<PropertyColumn Property="x => x.UserName" Title="کاربر" />
<TemplateColumn Title="وضعیت">
<CellTemplate>@ReportLabels.FormatActiveChange(context.Item.OldIsActive, context.Item.NewIsActive)</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="مشارکت اولیه">
<CellTemplate>
@(context.Item.OldInitialContribution != null ? context.Item.OldInitialContribution.Value.ToString("N0") : "—")
→ @(context.Item.NewInitialContribution != null ? context.Item.NewInitialContribution.Value.ToString("N0") : "—")
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="عملیات">
<CellTemplate>@ReportLabels.GetClubMembershipActionLabel(context.Item.Action)</CellTemplate>
</TemplateColumn>
<PropertyColumn Property="x => x.PerformedBy" Title="انجام‌دهنده" />
<PropertyColumn Property="x => x.Reason" Title="دلیل" />
<TemplateColumn Title="تاریخ">
<CellTemplate>@context.Item.CreatedAt.ToDateTime().ToLocalTime().MiladiToJalaliWithTime()</CellTemplate>
</TemplateColumn>
</Columns>
<NoRecordsContent>
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Text" Class="my-4">موردی یافت نشد.</MudAlert>
</NoRecordsContent>
<PagerContent>
<MudDataGridPager T="ClubMembershipHistoryModel" PageSizeOptions="@(new int[] { 20, 50, 100 })" />
</PagerContent>
</MudDataGrid>
</MudTabPanel>
<MudTabPanel Text="تغییرات دوره" Icon="@Icons.Material.Filled.Timeline">
<MudAlert Severity="Severity.Warning" Variant="Variant.Text" Dense="true" Class="mb-3">
ردیابی تغییرات دوره از زمان deploy فعال شد. رکوردهای قدیمی‌تر ممکن است خالی باشند.
</MudAlert>
<MudStack Row="true" Spacing="2" Class="mb-3">
<UserAutoComplete Label="کاربر" @bind-SelectedUserId="_cycleUserId" />
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="ReloadCycle">جستجو</MudButton>
</MudStack>
<MudDataGrid @ref="_cycleGrid" T="ClubMembershipCycleHistoryModel" ServerData="LoadCycleHistory" ReadOnly="true"
Hover="true" Dense="true" Height="calc(100vh - 400px)">
<Columns>
<PropertyColumn Property="x => x.Id" Title="شناسه" />
<PropertyColumn Property="x => x.UserName" Title="کاربر" />
<PropertyColumn Property="x => x.CycleNumber" Title="دور" />
<TemplateColumn Title="دوره جاری">
<CellTemplate>@ReportLabels.FormatBoolChange(context.Item.OldIsCurrentCycle, context.Item.NewIsCurrentCycle)</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="عملیات">
<CellTemplate>@ReportLabels.GetClubCycleActionLabel(context.Item.Action)</CellTemplate>
</TemplateColumn>
<PropertyColumn Property="x => x.PerformedBy" Title="انجام‌دهنده" />
<PropertyColumn Property="x => x.Reason" Title="دلیل" />
<TemplateColumn Title="تاریخ">
<CellTemplate>@context.Item.CreatedAt.ToDateTime().ToLocalTime().MiladiToJalaliWithTime()</CellTemplate>
</TemplateColumn>
</Columns>
<NoRecordsContent>
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Text" Class="my-4">موردی یافت نشد.</MudAlert>
</NoRecordsContent>
<PagerContent>
<MudDataGridPager T="ClubMembershipCycleHistoryModel" PageSizeOptions="@(new int[] { 20, 50, 100 })" />
</PagerContent>
</MudDataGrid>
</MudTabPanel>
</MudTabs>
@code {
private MudDataGrid<ClubMembershipHistoryModel>? _memGrid;
private MudDataGrid<ClubMembershipCycleHistoryModel>? _cycleGrid;
private long? _memUserId, _cycleUserId;
private int? _memAction;
private async Task<GridData<ClubMembershipHistoryModel>> LoadMembershipHistory(GridState<ClubMembershipHistoryModel> state)
{
try
{
var result = await ClubClient.GetClubMembershipHistoryAsync(new GetClubMembershipHistoryRequest
{
PaginationState = new CMSMicroservice.Protobuf.Protos.PaginationState { PageNumber = state.Page + 1, PageSize = state.PageSize },
Filter = new GetClubMembershipHistoryFilter
{
UserId = _memUserId > 0 ? _memUserId : null,
Action = _memAction
}
});
return new GridData<ClubMembershipHistoryModel>
{
Items = result?.Models?.ToList() ?? new(),
TotalItems = (int)(result?.MetaData?.TotalCount ?? 0)
};
}
catch (Exception ex)
{
Snackbar.Add($"خطا: {ex.Message}", Severity.Error);
return new GridData<ClubMembershipHistoryModel>();
}
}
private async Task<GridData<ClubMembershipCycleHistoryModel>> LoadCycleHistory(GridState<ClubMembershipCycleHistoryModel> state)
{
try
{
var result = await CycleHistoryClient.GetAllClubMembershipCycleHistoryByFilterAsync(new GetAllClubMembershipCycleHistoryByFilterRequest
{
PaginationState = new CMSMicroservice.Protobuf.Protos.PaginationState { PageNumber = state.Page + 1, PageSize = state.PageSize },
Filter = new GetAllClubMembershipCycleHistoryByFilterFilter
{
UserId = _cycleUserId > 0 ? _cycleUserId : null
}
});
return new GridData<ClubMembershipCycleHistoryModel>
{
Items = result?.Models?.ToList() ?? new(),
TotalItems = (int)(result?.MetaData?.TotalCount ?? 0)
};
}
catch (Exception ex)
{
Snackbar.Add($"خطا: {ex.Message}", Severity.Error);
return new GridData<ClubMembershipCycleHistoryModel>();
}
}
private async Task ReloadMembership() { if (_memGrid != null) await _memGrid.ReloadServerData(); }
private async Task ReloadCycle() { if (_cycleGrid != null) await _cycleGrid.ReloadServerData(); }
}
@@ -0,0 +1,131 @@
@using BackOffice.Common.BaseComponents
@using BackOffice.Pages.AutoComplete
@using CMSMicroservice.Protobuf.Protos.UserPackagePurchase
@using Google.Protobuf.WellKnownTypes
@inject UserPackagePurchaseContract.UserPackagePurchaseContractClient PurchaseClient
<MudAlert Severity="Severity.Info" Variant="Variant.Text" Dense="true" Class="mb-3">
گزارش read-only خریدهای پکیج — فقط مشاهده و فیلتر.
</MudAlert>
<BasePageComponent @ref="_basePage" OnSubmitClick="OnFilterSubmit" OnClearFilterClick="OnFilterCleared">
<Filters>
<MudItem xs="12" sm="6" md="3">
<UserAutoComplete Label="کاربر" @bind-SelectedUserId="_userId" />
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudSelect T="int?" Clearable="true" Label="روش خرید" Variant="Variant.Outlined"
Margin="Margin.Dense" @bind-Value="_purchaseMethod">
<MudSelectItem T="int?" Value="1">دایا</MudSelectItem>
<MudSelectItem T="int?" Value="3">دستی</MudSelectItem>
<MudSelectItem T="int?" Value="2">درگاه</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12" sm="6" md="4">
<DateRangePicker @bind-From="_fromDate" @bind-To="_toDate" Label="بازه خرید" />
</MudItem>
</Filters>
<Content>
<MudGrid Class="mb-3" Spacing="2">
<MudItem xs="6" sm="4">
<MudPaper Class="pa-3" Outlined="true">
<MudText Typo="Typo.caption" Color="Color.Secondary">تعداد</MudText>
<MudText Typo="Typo.h6">@_totalCount.ToString("N0")</MudText>
</MudPaper>
</MudItem>
<MudItem xs="6" sm="4">
<MudPaper Class="pa-3" Outlined="true">
<MudText Typo="Typo.caption" Color="Color.Secondary">جمع مبلغ (ریال)</MudText>
<MudText Typo="Typo.h6">@_totalAmount.ToString("N0")</MudText>
</MudPaper>
</MudItem>
</MudGrid>
<MudDataGrid @ref="_grid" T="UserPackagePurchaseModel" ServerData="LoadData" ReadOnly="true"
Hover="true" Dense="true" Height="calc(100vh - 420px)">
<Columns>
<PropertyColumn Property="x => x.Id" Title="شناسه" />
<TemplateColumn Title="کاربر">
<CellTemplate>
<MudStack Spacing="0">
<MudText Typo="Typo.body2">@context.Item.UserName</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">@context.Item.UserMobile</MudText>
</MudStack>
</CellTemplate>
</TemplateColumn>
<PropertyColumn Property="x => x.PackageName" Title="پکیج" />
<TemplateColumn Title="روش خرید">
<CellTemplate>@ReportLabels.GetPurchaseMethodLabel(context.Item.PurchaseMethod)</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="مبلغ (ریال)">
<CellTemplate>@context.Item.Amount.ToString("N0")</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="تاریخ خرید">
<CellTemplate>@context.Item.PurchasedAt.ToDateTime().ToLocalTime().MiladiToJalaliWithTime()</CellTemplate>
</TemplateColumn>
<PropertyColumn Property="x => x.OrderId" Title="OrderId" />
<PropertyColumn Property="x => x.TransactionId" Title="TransactionId" />
</Columns>
<NoRecordsContent>
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Text" Class="my-4">موردی یافت نشد.</MudAlert>
</NoRecordsContent>
<PagerContent>
<MudDataGridPager T="UserPackagePurchaseModel" PageSizeOptions="@(new int[] { 20, 50, 100 })"
InfoFormat="سطر {first_item} تا {last_item} از {all_items}"
RowsPerPageString="تعداد در صفحه" />
</PagerContent>
</MudDataGrid>
</Content>
</BasePageComponent>
@code {
private BasePageComponent? _basePage;
private MudDataGrid<UserPackagePurchaseModel>? _grid;
private long? _userId;
private int? _purchaseMethod;
private DateTime? _fromDate, _toDate;
private long _totalCount, _totalAmount;
private GetAllUserPackagePurchaseByFilterFilter BuildFilter() => new()
{
UserId = _userId > 0 ? _userId : null,
PurchaseMethod = _purchaseMethod,
PurchasedFrom = _fromDate.HasValue ? Timestamp.FromDateTime(DateTime.SpecifyKind(_fromDate.Value, DateTimeKind.Utc)) : null,
PurchasedTo = _toDate.HasValue ? Timestamp.FromDateTime(DateTime.SpecifyKind(_toDate.Value.Date.AddDays(1).AddTicks(-1), DateTimeKind.Utc)) : null
};
private async Task<GridData<UserPackagePurchaseModel>> LoadData(GridState<UserPackagePurchaseModel> state)
{
try
{
var filter = BuildFilter();
var summary = await PurchaseClient.GetUserPackagePurchaseSummaryAsync(new GetUserPackagePurchaseSummaryRequest { Filter = filter });
_totalCount = summary.TotalCount;
_totalAmount = summary.TotalAmount;
var result = await PurchaseClient.GetAllUserPackagePurchaseByFilterAsync(new GetAllUserPackagePurchaseByFilterRequest
{
PaginationState = new CMSMicroservice.Protobuf.Protos.PaginationState { PageNumber = state.Page + 1, PageSize = state.PageSize },
Filter = filter
});
return new GridData<UserPackagePurchaseModel>
{
Items = result?.Models?.ToList() ?? new(),
TotalItems = (int)(result?.MetaData?.TotalCount ?? 0)
};
}
catch (Exception ex)
{
Snackbar.Add($"خطا: {ex.Message}", Severity.Error);
return new GridData<UserPackagePurchaseModel>();
}
}
private async Task OnFilterSubmit() { if (_grid != null) await _grid.ReloadServerData(); }
private async Task OnFilterCleared()
{
_userId = null; _purchaseMethod = null; _fromDate = _toDate = null;
if (_grid != null) await _grid.ReloadServerData();
}
}
@@ -0,0 +1,105 @@
@using CMSMicroservice.Protobuf.Protos.UserPackagePurchase
@using Google.Protobuf.WellKnownTypes
@using DateTimeConverterCL
@inject UserPackagePurchaseContract.UserPackagePurchaseContractClient PurchaseClient
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">
جزئیات خرید پکیج — @UserName
</MudText>
</TitleContent>
<DialogContent>
@if (_loading)
{
<MudProgressLinear Indeterminate="true" Color="Color.Primary" Class="my-4" />
}
else if (_items.Count == 0)
{
<MudAlert Severity="Severity.Info" Dense="true">خریدی برای این کاربر یافت نشد.</MudAlert>
}
else
{
<MudTable Items="_items" Dense="true" Hover="true" Breakpoint="Breakpoint.Sm">
<HeaderContent>
<MudTh>شناسه</MudTh>
<MudTh>پکیج</MudTh>
<MudTh>روش</MudTh>
<MudTh>مبلغ (ریال)</MudTh>
<MudTh>تاریخ خرید</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="شناسه">@context.Id</MudTd>
<MudTd DataLabel="پکیج">@context.PackageName</MudTd>
<MudTd DataLabel="روش">
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined"
Color="@GetMethodColor(context.PurchaseMethod)">
@ReportLabels.GetPurchaseMethodLabel(context.PurchaseMethod)
</MudChip>
</MudTd>
<MudTd DataLabel="مبلغ">@context.Amount.ToString("N0")</MudTd>
<MudTd DataLabel="تاریخ">
@(context.PurchasedAt != null
? context.PurchasedAt.ToDateTime().ToLocalTime().MiladiToJalaliWithTime()
: "-")
</MudTd>
</RowTemplate>
</MudTable>
}
</DialogContent>
<DialogActions>
<MudButton OnClick="Close" Variant="Variant.Text">بستن</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter] public long UserId { get; set; }
[Parameter] public string UserName { get; set; } = string.Empty;
private bool _loading = true;
private List<UserPackagePurchaseModel> _items = new();
protected override async Task OnInitializedAsync()
{
try
{
var result = await PurchaseClient.GetAllUserPackagePurchaseByFilterAsync(
new GetAllUserPackagePurchaseByFilterRequest
{
PaginationState = new CMSMicroservice.Protobuf.Protos.PaginationState
{
PageNumber = 1,
PageSize = 500
},
Filter = new GetAllUserPackagePurchaseByFilterFilter
{
UserId = UserId
}
});
_items = result?.Models?
.OrderBy(x => x.PurchasedAt?.ToDateTime() ?? DateTime.MinValue)
.ToList() ?? new();
}
catch (Exception ex)
{
Snackbar.Add($"خطا در بارگذاری جزئیات: {ex.Message}", Severity.Error);
}
finally
{
_loading = false;
}
}
private static Color GetMethodColor(int method) => method switch
{
1 => Color.Info, // دایا
2 => Color.Primary, // درگاه
3 => Color.Secondary, // دستی
_ => Color.Default
};
private void Close() => MudDialog.Close();
}
@@ -0,0 +1,282 @@
@using System.Text
@using BackOffice.Common.BaseComponents
@using BackOffice.Common.Utilities
@using BackOffice.Pages.AutoComplete
@using BackOffice.Pages.Club.Reports.Components
@using CMSMicroservice.Protobuf.Protos.UserPackagePurchase
@using Google.Protobuf.WellKnownTypes
@using DateTimeConverterCL
@using Microsoft.JSInterop
@inject UserPackagePurchaseContract.UserPackagePurchaseContractClient PurchaseClient
@inject IDialogService DialogService
@inject IJSRuntime JsRuntime
<MudAlert Severity="Severity.Info" Variant="Variant.Text" Dense="true" Class="mb-3">
گزارش تجمیعی مشتریان — اولین/آخرین پکیج، تفکیک دایا / دستی / درگاه. خروجی Excel مطابق فیلتر فعال است.
</MudAlert>
<BasePageComponent @ref="_basePage" OnSubmitClick="OnFilterSubmit" OnClearFilterClick="OnFilterCleared">
<Filters>
<MudItem xs="12" sm="6" md="3">
<UserAutoComplete Label="کاربر" @bind-SelectedUserId="_userId" />
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudSelect T="int?" Clearable="true" Label="روش خرید" Variant="Variant.Outlined"
Margin="Margin.Dense" @bind-Value="_purchaseMethod">
<MudSelectItem T="int?" Value="1">دایا</MudSelectItem>
<MudSelectItem T="int?" Value="3">دستی</MudSelectItem>
<MudSelectItem T="int?" Value="2">درگاه</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12" sm="6" md="4">
<DateRangePicker @bind-From="_fromDate" @bind-To="_toDate" Label="بازه خرید" />
</MudItem>
</Filters>
<Content>
<MudStack Row="true" Justify="Justify.FlexEnd" Class="mb-3">
<MudButton Variant="Variant.Outlined" Color="Color.Success"
StartIcon="@Icons.Material.Filled.FileDownload"
OnClick="ExportToExcel" Disabled="_exporting">
خروجی Excel
</MudButton>
</MudStack>
<MudDataGrid @ref="_grid" T="CustomerPackagePurchaseRollupModel" ServerData="LoadData" ReadOnly="true"
Hover="true" Dense="true" Height="calc(100vh - 420px)">
<Columns>
<TemplateColumn Title="مشتری">
<CellTemplate>
<MudStack Spacing="0">
<MudText Typo="Typo.body2">@context.Item.UserName</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">@context.Item.UserMobile</MudText>
</MudStack>
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="اولین پکیج">
<CellTemplate>
<MudStack Spacing="0">
<MudText Typo="Typo.body2">@context.Item.FirstPackageName</MudText>
<MudText Typo="Typo.caption">@context.Item.FirstAmount.ToString("N0") ریال</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">
@(context.Item.FirstPurchasedAt?.ToDateTime().ToLocalTime().MiladiToJalaliWithTime() ?? "-")
</MudText>
</MudStack>
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="آخرین پکیج">
<CellTemplate>
<MudStack Spacing="0">
<MudText Typo="Typo.body2">@context.Item.LastPackageName</MudText>
<MudText Typo="Typo.caption">@context.Item.LastAmount.ToString("N0") ریال</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">
@(context.Item.LastPurchasedAt?.ToDateTime().ToLocalTime().MiladiToJalaliWithTime() ?? "-")
</MudText>
<MudChip T="string" Size="Size.Small" Class="mt-1" Variant="Variant.Filled"
Color="@GetMethodColor(context.Item.LastPurchaseMethod)">
@ReportLabels.GetPurchaseMethodLabel(context.Item.LastPurchaseMethod)
</MudChip>
</MudStack>
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="تعداد / جمع">
<CellTemplate>
<MudStack Spacing="0">
<MudText>@context.Item.PurchaseCount خرید</MudText>
<MudText Typo="Typo.caption">@context.Item.TotalAmount.ToString("N0") ریال</MudText>
</MudStack>
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="دایا">
<CellTemplate>
<MudText Typo="Typo.caption">@context.Item.DayaCount عدد</MudText>
<MudText Typo="Typo.caption">@context.Item.DayaAmount.ToString("N0")</MudText>
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="دستی">
<CellTemplate>
<MudText Typo="Typo.caption">@context.Item.ManualCount عدد</MudText>
<MudText Typo="Typo.caption">@context.Item.ManualAmount.ToString("N0")</MudText>
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="درگاه">
<CellTemplate>
<MudText Typo="Typo.caption">@context.Item.GatewayCount عدد</MudText>
<MudText Typo="Typo.caption">@context.Item.GatewayAmount.ToString("N0")</MudText>
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="عملیات">
<CellTemplate>
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Primary"
OnClick="() => OpenDetails(context.Item)">
جزئیات
</MudButton>
</CellTemplate>
</TemplateColumn>
</Columns>
<NoRecordsContent>
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Text" Class="my-4">موردی یافت نشد.</MudAlert>
</NoRecordsContent>
<PagerContent>
<MudDataGridPager T="CustomerPackagePurchaseRollupModel" PageSizeOptions="@(new int[] { 20, 50, 100 })"
InfoFormat="سطر {first_item} تا {last_item} از {all_items}"
RowsPerPageString="تعداد در صفحه" />
</PagerContent>
</MudDataGrid>
</Content>
</BasePageComponent>
@code {
private BasePageComponent? _basePage;
private MudDataGrid<CustomerPackagePurchaseRollupModel>? _grid;
private long? _userId;
private int? _purchaseMethod;
private DateTime? _fromDate, _toDate;
private bool _exporting;
private GetCustomerPackagePurchaseRollupFilter BuildFilter()
{
var filter = new GetCustomerPackagePurchaseRollupFilter();
if (_userId is > 0)
filter.UserId = _userId;
if (_purchaseMethod.HasValue)
filter.PurchaseMethod = _purchaseMethod.Value;
if (_fromDate.HasValue)
filter.PurchasedFrom = Timestamp.FromDateTime(DateTime.SpecifyKind(_fromDate.Value.Date, DateTimeKind.Utc));
if (_toDate.HasValue)
filter.PurchasedTo = Timestamp.FromDateTime(DateTime.SpecifyKind(_toDate.Value.Date.AddDays(1).AddTicks(-1), DateTimeKind.Utc));
return filter;
}
private async Task<GridData<CustomerPackagePurchaseRollupModel>> LoadData(GridState<CustomerPackagePurchaseRollupModel> state)
{
try
{
var result = await PurchaseClient.GetCustomerPackagePurchaseRollupAsync(
new GetCustomerPackagePurchaseRollupRequest
{
PaginationState = new CMSMicroservice.Protobuf.Protos.PaginationState
{
PageNumber = state.Page + 1,
PageSize = state.PageSize
},
Filter = BuildFilter()
});
return new GridData<CustomerPackagePurchaseRollupModel>
{
Items = result?.Models?.ToList() ?? new(),
TotalItems = (int)(result?.MetaData?.TotalCount ?? 0)
};
}
catch (Exception ex)
{
Snackbar.Add($"خطا: {ex.Message}", Severity.Error);
return new GridData<CustomerPackagePurchaseRollupModel>();
}
}
private async Task OnFilterSubmit()
{
if (_grid != null) await _grid.ReloadServerData();
}
private async Task OnFilterCleared()
{
_userId = null;
_purchaseMethod = null;
_fromDate = _toDate = null;
if (_grid != null) await _grid.ReloadServerData();
}
private async Task OpenDetails(CustomerPackagePurchaseRollupModel row)
{
var parameters = new DialogParameters
{
{ nameof(CustomerPackagePurchaseDetailsDialog.UserId), row.UserId },
{ nameof(CustomerPackagePurchaseDetailsDialog.UserName), row.UserName }
};
var options = new DialogOptions { MaxWidth = MaxWidth.Medium, FullWidth = true, CloseButton = true };
await DialogService.ShowAsync<CustomerPackagePurchaseDetailsDialog>("جزئیات خرید پکیج", parameters, options);
}
private async Task ExportToExcel()
{
_exporting = true;
try
{
// PageSize=0 → همهٔ ردیف‌های مطابق فیلتر، مستقل از صفحه‌بندی گرید
var result = await PurchaseClient.GetCustomerPackagePurchaseRollupAsync(
new GetCustomerPackagePurchaseRollupRequest
{
PaginationState = new CMSMicroservice.Protobuf.Protos.PaginationState
{
PageNumber = 1,
PageSize = 0
},
Filter = BuildFilter()
});
var all = result?.Models?.ToList() ?? new();
if (all.Count == 0)
{
Snackbar.Add("داده‌ای برای خروجی با فیلتر فعلی وجود ندارد.", Severity.Info);
return;
}
var headers = new[]
{
"شناسه کاربر", "نام مشتری", "موبایل",
"اولین پکیج", "مبلغ اولین", "تاریخ اولین",
"آخرین پکیج", "مبلغ آخرین", "تاریخ آخرین", "روش آخرین",
"تعداد خرید", "جمع مبلغ",
"تعداد دایا", "مبلغ دایا",
"تعداد دستی", "مبلغ دستی",
"تعداد درگاه", "مبلغ درگاه"
};
var rows = all.Select(o => new object?[]
{
o.UserId,
o.UserName,
o.UserMobile,
o.FirstPackageName,
o.FirstAmount,
o.FirstPurchasedAt?.ToDateTime().ToLocalTime().MiladiToJalaliWithTime() ?? "-",
o.LastPackageName,
o.LastAmount,
o.LastPurchasedAt?.ToDateTime().ToLocalTime().MiladiToJalaliWithTime() ?? "-",
ReportLabels.GetPurchaseMethodLabel(o.LastPurchaseMethod),
o.PurchaseCount,
o.TotalAmount,
o.DayaCount,
o.DayaAmount,
o.ManualCount,
o.ManualAmount,
o.GatewayCount,
o.GatewayAmount
});
var filename = $"customer-packages-{DateTime.Now:yyyyMMddHHmmss}.xls";
await JsRuntime.InvokeVoidAsync("jsSaveAsFile", filename,
CsvExportHelper.ToExcelXmlBase64("مشتریان پکیج", headers, rows));
Snackbar.Add($"خروجی Excel آماده شد ({all.Count} مشتری).", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"خطا در خروجی Excel: {ex.Message}", Severity.Error);
}
finally
{
_exporting = false;
}
}
private static Color GetMethodColor(int method) => method switch
{
1 => Color.Info,
2 => Color.Primary,
3 => Color.Secondary,
_ => Color.Default
};
}

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