Browse Source

feat: add application layout, UI component library, and management features for billing, tours, and services

master
parent
commit
a8e0ce7823
  1. 249
      guide/aqila_panel_ui_design_system_standard.md
  2. 7
      src/app/router.tsx
  3. 31
      src/components/layout/AppSidebar.tsx
  4. 16
      src/components/layout/Topbar.tsx
  5. 94
      src/components/shared/FilterBar.tsx
  6. 137
      src/components/shared/FilterSelect.tsx
  7. 133
      src/components/shared/Shimmer.tsx
  8. 69
      src/components/shared/SortDropdown.tsx
  9. 2
      src/components/ui/alert-dialog.tsx
  10. 4
      src/components/ui/command.tsx
  11. 24
      src/components/ui/dropdown-menu.tsx
  12. 6
      src/components/ui/multi-select.tsx
  13. 2
      src/components/ui/popover.tsx
  14. 77
      src/components/ui/select.tsx
  15. 2
      src/components/ui/shimmer.tsx
  16. 6
      src/components/ui/single-select.tsx
  17. 18
      src/config/navigation.ts
  18. 690
      src/features/billing/BillingPage.tsx
  19. 472
      src/features/billing/components/BillingDetailModal.tsx
  20. 70
      src/features/billing/components/BillingServiceBadge.tsx
  21. 70
      src/features/billing/components/BillingStatusBadge.tsx
  22. 140
      src/features/billing/components/ReceiptLightboxDialog.tsx
  23. 105
      src/features/billing/components/RejectFactorDialog.tsx
  24. 71
      src/features/billing/services/billing-api.ts
  25. 107
      src/features/billing/types.ts
  26. 57
      src/features/global-search/GlobalSearchDialog.tsx
  27. 967
      src/features/services/ServicesPage.tsx
  28. 831
      src/features/tours/OngoingToursPage.tsx
  29. 47
      src/features/tours/TourOrdersPage.tsx
  30. 438
      src/features/tours/ToursPage.tsx
  31. 29
      src/features/tours/components/TourDetailSheet.tsx
  32. 6
      src/features/tours/components/TourTranslationsDialog.tsx
  33. 3
      src/features/tours/types.ts
  34. 23
      src/icons.tsx
  35. 18
      src/services/http.ts
  36. 4
      vite.config.ts

249
guide/aqila_panel_ui_design_system_standard.md

@ -0,0 +1,249 @@
# سند جامع سیستم طراحی بصری و استانداردهای رابط کاربری (UI Design System & Typography Standard)
**سامانه مدیریت و پلتفرم عقیله (Aqila)**
**مرجع سیستم:** کلاینت وب و پنل مدیریت (`aqila_panel`) و راهنمای سراسری هوش مصنوعی (AI Agent Directive)
**نسخه:** 1.0.0
**وضعیت:** مصوب و الزامی (Mandatory Reference)
---
## ۱. هدف و دامنه کاربرد (Objective & Scope)
این سند، **مرجع واحد و غیرقابل‌تغییر (Single Source of Truth)** برای تمامی استانداردهای بصری، تایپوگرافی، رنگ‌بندی، چیدمان، فاصله‌گذاری، کامپوننت‌های تعاملی و رفتارهای چندزبانه در رابط کاربری سامانه عقیله است.
> [!IMPORTANT]
> **قانون الزام‌آور هوش مصنوعی (AI Agent Rule):**
> هوش مصنوعی (AI Agent) در زمان ایجاد هر صفحه جدید، ساخت هر کامپوننت، ویرایش صفحات موجود، بازطراحی فرم‌ها، فیلترها، جداول، تگ‌ها و وضعیت‌های بارگذاری (Loading)، **باید ابتدا و بدون استثنا این سند را به عنوان مرجع قطعی رعایت نماید.** هرگونه کدنویسی بصری سلیقه‌ای، استفاده از رنگ‌های رنگین‌کمانی مدادرنگی (Crayon-like)، فونت‌های متفرقه یا ساختارهای ناهمگون اکیداً ممنوع است.
---
## ۲. زبان، تایپوگرافی و نگارش چندزبانه (Language & Typography)
سامانه عقیله یک سیستم بین‌المللی با تمرکز بر سه زبان اصلی **فارسی (FA)**، **عربی (AR)** و **انگلیسی (EN)** است.
```
┌────────────────────────────────────────────────────────────────────────┐
│ مقیاس تایپوگرافی عقیله │
├───────────────────┬────────────┬─────────┬──────────────┬──────────────┤
│ سطح متنی (Role) │ سایز (Size)│ وزن (Wt)│ Line-Height │ کلاس Tailwind│
├───────────────────┼────────────┼─────────┼──────────────┼──────────────┤
│ Display / H1 │ 24px (1.5r)│ Black/900│ 1.35 (32px) │ text-2xl │
│ Page Title / H2 │ 20px (1.25)│ Bold/800│ 1.4 (28px) │ text-xl │
│ Section Title / H3│ 16px (1.0r)│ Bold/700│ 1.45 (24px) │ text-base │
│ Card Title / H4 │ 14px (.875)│ Bold/700│ 1.5 (21px) │ text-sm │
│ Body Text (اصلی) │ 13px-14px │ Medium/500│ 1.6 (22px) │ text-xs/sm │
│ Secondary / Muted │ 12px (.75r)│ Regular │ 1.5 (18px) │ text-xs │
│ Caption / Footnote│ 11px (.687)│ Medium │ 1.4 (16px) │ text-[11px] │
│ Badge / Tag Label │ 10px-11px │ Bold/700│ 1.0 (12px) │ text-[10px] │
│ Monospace / Stats │ 12px-20px │ Bold/900│ 1.2 (Tabular)│ font-mono │
└───────────────────┴────────────┴─────────┴──────────────┴──────────────┘
```
### ۲.۱. خانواده فونت‌ها (Font Families)
1. **فارسی و عربی (RTL):**
- فونت استاندارد: `Vazirmatn` (وزن‌های 400, 500, 700, 800, 900)
- فونت فال‌بک: `system-ui, -apple-system, 'Segoe UI', Tahoma, sans-serif`
2. **انگلیسی و ارقام/داده‌های فنی (LTR):**
- متون انگلیسی: `Inter, system-ui, sans-serif`
- ارقام مالی، شناسه‌ها، شماره سفارش، تاریخ‌های میلادی و ساعت: `font-mono` (فونت مونو اسپیس با Tabular Figures جهت تراز دقیق در جداول و کارت‌ها).
### ۲.۲. ترکیب متون دوزبانه و اعداد (Mixed-Language Content)
- **ارقام فارسی در برابر انگلیسی:**
- در متون توضیحی و عناوین فارسی از ارقام بومی (`toFa(number)`) استفاده شود.
- در کد رهگیری، شماره کارت، شناسه دیتابیس (`#1240`)، مبالغ ارزی دلاری/دیناری (`$1,200` یا `15,000 IQD`) و تاریخ‌های لاگ، منحصراً از ارقام انگلیسی درون بلوک `font-mono` و با جهت `dir="ltr"` استفاده شود.
- **اصطلاحات فنی و لاتین در متن فارسی:**
- کلیه اسلاگ‌ها، نام فایل‌ها و عبارت‌های انگلیسی درون متون فارسی باید درون محفظه مجزا یا با `dir="ltr"` قرار گیرند تا نظم کلمات به هم نخورد.
- **پرهیز از Letter-Spacing غیرمجاز:**
- در خطوط فارسی و عربی به هیچ وجه از `letter-spacing` (کلاس‌های `tracking-widest` و...) استفاده نشود؛ زیرا باعث تکه‌تکه شدن حروف متصل می‌شود.
---
## ۳. جهت‌چینی، ترازبندی و قواعد RTL / LTR
### ۳.۱. اصول جهت‌چینی (Directionality Rules)
- رابط کاربری به صورت پیش‌فرض **راست‌به‌چپ (`dir="rtl"`)** است.
- **ویژگی‌های منطقی (CSS Logical Properties):**
- به جای `left` و `right`، همواره از ویژگی‌های منطقی استفاده شود:
- پدینگ و مارجین: `ps-` (padding-start)، `pe-` (padding-end)، `ms-` (margin-start)، `me-` (margin-end).
- گوشه‌ها: `rounded-s-` (start) و `rounded-e-` (end).
- ترازبندی متن: `text-start` و `text-end` به جای `text-left` و `text-right`.
### ۳.۲. آیکون‌ها در RTL
- آیکون‌های هدایتی (مانند پیکان بازگشت، بعدی/قبلی، Breadcrumbs) باید در حالت RTL متناسب با جهت جریان اطلاعات بچرخند یا به صورت طبیعی بر اساس `start/end` قرار گیرند.
- آیکون‌های متقارن (مانند جستجو، تقویم، تنظیمات، سبد خرید) نیاز به چرخش ندارند.
---
## ۴. پالت رنگی خنثی، مینیمال و معنادار (Neutral & Semantic Color System)
> [!CAUTION]
> **پرهیز جدی از رابط کاربری مدادرنگی (No "Crayon-like" Overly Colorful UI):**
> بیش از **۹۰٪ رابط کاربری** باید از رنگ‌های خنثی (سفید، خاکستری، اسلیت، تیره) تشکیل شود. استفاده از رنگ‌های تند (آبی روشن، نارنجی فسفری، بنفش تیره، سبز فسفری) به عنوان پس‌زمینه کارت‌ها، بج‌ها و کادرها کاملاً ممنوع است مگر اینکه معنای وضعیتی مشخصی داشته باشد.
```
┌────────────────────────────────────────────────────────────────────────┐
│ معماری پالت رنگی عقیله │
├───────────────────┬──────────────────────┬─────────────────────────────┤
│ توکن معنایی │ تم دارک (Dark Mode) │ تم لایت (Light Mode) │
├───────────────────┼──────────────────────┼─────────────────────────────┤
│ background │ #18191D (تیره مات) │ #F8FAFC (سفید-خاکستری محو) │
│ surface / card │ #22252C (کارت پایه) │ #FFFFFF (سفید خالص) │
│ surface-muted │ #1E2128 (پس‌زمینه فرعی)│ #F1F5F9 (خاکستری بسیار روشن)│
│ border │ rgba(255,255,255,0.1)│ rgba(15,23,42,0.10) │
│ border-soft │ rgba(255,255,255,0.06)│ rgba(15,23,42,0.06) │
│ primary (هویت برنز)│ #D09460#D09460
│ primary-muted │ rgba(208,148,96,0.12)│ rgba(208,148,96,0.10) │
│ text-foreground │ #FFFFFF (خوانایی کامل)│ #0F172A (سرمه‌ای-مشکی پرکنتراست)│
│ text-muted │ #8B8B8B (خاکستری متوسط)│ #64748B (خاکستری خوانا) │
│ text-subtle │ #666666#94A3B8
└───────────────────┴──────────────────────┴─────────────────────────────┘
```
### ۴.۱. رنگ‌های معنایی و وضعیت‌ها (Semantic Status Colors)
رنگ‌های اشباع منحصراً برای اعلام وضعیت و فیدبک‌های مهم سیستم رزرو شده‌اند:
1. **موفقیت / تایید (Success):**
- کارکرد: پرداخت موفق، تور تکمیل‌شده، رزرو فعال.
- رنگ: `Emerald` (لایت: `text-emerald-700 bg-emerald-50 border-emerald-200` | دارک: `text-emerald-400 bg-emerald-500/10 border-emerald-500/20`).
2. **هشدار / در انتظار (Warning / Pending):**
- کارکرد: در انتظار پرداخت، بررسی فیش، نزدیک به ظرفیت.
- رنگ: `Amber` (لایت: `text-amber-700 bg-amber-50 border-amber-200` | دارک: `text-amber-400 bg-amber-500/10 border-amber-500/20`).
3. **خطا / رد شده (Error / Destructive):**
- کارکرد: فیش نامعتبر، سفارش لغوشده، حذف آیتم.
- رنگ: `Rose / Red` (لایت: `text-rose-700 bg-rose-50 border-rose-200` | دارک: `text-rose-400 bg-rose-500/10 border-rose-500/20`).
4. **اطلاعات عمومی (Informational / Neutral):**
- کارکرد: دسته‌بندی‌ها، تعداد بازدید، مشخصات عادی، تگ‌های عمومی.
- رنگ: **کاملاً خنثی (Neutral / Slate)** (`bg-muted/40 text-muted-foreground border-border-soft`). هرگز برای تگ‌های معمولی از رنگ‌های رنگین‌کمانی استفاده نشود.
---
## ۵. مقیاس فواصل، ابعاد و شعاع انحناها (Spacing, Sizing & Radii)
سامانه از سیستم فاصله‌گذاری مضرب ۴ و ۸ پیکسلی تبعیت می‌کند:
```
┌────────────────────────────────────────────────────────────────────────┐
│ مقیاس فواصل استاندارد (Spacing) │
├─────────────┬─────────────┬────────────────────────────────────────────┤
│ توکن │ مقدار (px) │ کاربرد و موارد مصرف │
├─────────────┼─────────────┼────────────────────────────────────────────┤
│ space-xs │ 4px │ فاصله بین آیکون و متن، پدینگ میکرو │
│ space-sm │ 8px │ فاصله عناصر درون یک کامپوننت، گپ دکمه‌ها │
│ space-md │ 12px │ فاصله آیتم‌های لیست، پدینگ فیلدها │
│ space-lg │ 16px │ پدینگ افقی صفحات، پدینگ پیش‌فرض کارت‌ها │
│ space-xl │ 20px │ فاصله بین بخش‌ها و ستون‌های فرم‌ها │
│ space-2xl │ 24px │ گپ بین کارت‌های اصلی و سکشن‌های داشبورد │
│ space-3xl │ 32px │ فاصله عمودی سکشن‌های بزرگ │
└─────────────┴─────────────┴────────────────────────────────────────────┘
```
### ۵.۱. شعاع انحناها (Border Radii)
- **تگ‌ها، بج‌ها و المان‌های کوچک:** `rounded-lg` (8px الی 10px)
- **فیلدهای ورودی و دکمه‌ها:** `rounded-xl` (12px)
- **کارت‌ها، جداول و بخش‌ها:** `rounded-2xl` (16px)
- **دیالوگ‌ها، پاپ‌آپ‌ها و مودال‌ها:** `rounded-2xl` الی `rounded-3xl` (20px - 24px)
- **بج‌های کپسولی / قرصی:** `rounded-full` (9999px)
---
## ۶. استاندارد جامع تگ‌ها و بج‌های وضعیتی (Tags & Status Badges)
کلیه تگ‌ها باید ساختاری منسجم و یکدست داشته باشند:
```tsx
// ۱. تگ خنثی استاندارد (اطلاعات عمومی، دسته‌بندی، کشور، نسخه)
<Badge variant="outline" className="border-border-soft bg-surface-base text-muted-foreground text-[11px] font-medium px-2.5 py-0.5 rounded-lg">
عراق
</Badge>
// ۲. بج وضعیتی تایید شده / موفق (Approved / Success)
<span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-500/10 border border-emerald-500/20 px-2.5 py-0.5 text-[11px] font-bold text-emerald-600 dark:text-emerald-400">
<span className="size-1.5 rounded-full bg-emerald-500" />
تأیید شده
</span>
// ۳. بج وضعیتی در انتظار / بررسی (Pending / Warning)
<span className="inline-flex items-center gap-1.5 rounded-full bg-amber-500/10 border border-amber-500/20 px-2.5 py-0.5 text-[11px] font-bold text-amber-600 dark:text-amber-400">
<span className="size-1.5 rounded-full bg-amber-500 animate-pulse" />
در انتظار پرداخت
</span>
// ۴. بج وضعیتی رد شده / خطا (Rejected / Error)
<span className="inline-flex items-center gap-1.5 rounded-full bg-rose-500/10 border border-rose-500/20 px-2.5 py-0.5 text-[11px] font-bold text-rose-600 dark:text-rose-400">
<span className="size-1.5 rounded-full bg-rose-500" />
رد شده
</span>
```
---
## ۷. استانداردهای کامپوننت‌های پایه (Core Components Standard)
### ۷.۱. دکمه‌ها (Buttons)
- **دکمه اصلی (Primary):** زمینه برنز عقیله (`bg-primary text-primary-foreground`)، ارتفاع 36px (`h-9`)، متن ضخیم (`font-bold text-xs`)، گوشه‌های `rounded-xl`.
- **دکمه ثانویه (Secondary / Outline):** زمینه خنثی یا بوردر ملایم (`border border-border-soft hover:bg-card/70 text-foreground`).
- **دکمه شبح (Ghost / Icon):** بدون کادر برای آیکون‌های عملیاتی داخل جدول با هاور لطیف.
- **دکمه خطرناک (Destructive):** رنگ رز ملایم (`text-rose-500 hover:bg-rose-500/10`).
### ۷.۲. فیلدهای ورودی و جستجو (Inputs & Filters)
- ارتفاع استاندارد: `h-9` (36px).
- رنگ زمینه: `bg-surface-base` یا `bg-card/60`.
- بوردر: `border border-border-soft focus:border-primary/50 focus:ring-1 focus:ring-primary/30`.
- سایز متن ورودی و Placeholder: `text-xs`.
### ۷.۳. جداول داده‌ای (Data Tables)
- هدر جدول: زمینه ملایم `bg-card/60`، متن کم‌رنگ `text-muted-foreground text-xs font-bold`.
- ردیف‌ها: ارتفاع مناسب، بوردر جداکننده `divide-y divide-border-soft`، هاور روان `hover:bg-card/50`.
- سلول‌ها: پدینگ یکدست `p-3.5` یا `p-4`، اعداد به صورت `font-mono font-bold`.
### ۷.۴. کارت‌های شاخص آماری (KPI Metric Cards)
- ساختار: کانتینر خنثی با کادر ملایم، عنوان کوچک در بالا (`text-xs text-muted-foreground`)، عدد بزرگ مونو در مرکز (`text-2xl font-black font-mono`) و توضیح کوتاه در زیر.
- رنگ‌آمیزی: زمینه ملایم بدون اشباع شدید رنگی.
---
## ۸. استاندارد لودینگ و اسکلتون (Shimmer / Loading Skeleton Standard)
> [!IMPORTANT]
> **الگوی واحد لودینگ:**
> مطابق مصوبه سیستم، لودینگ تمامی صفحات و جداول پنل باید منحصراً از کامپوننت بازاستفاده‌پذیر **`Shimmer`** (برگرفته از صفحه صورت‌حساب‌ها) استفاده کنند و از قراردادن اسپینرهای ناهماهنگ خودداری شود.
### مشخصات استاندارد Shimmer:
- **کانتینر:** `p-8 space-y-3` (یا درون `Card` با بوردر استاندارد).
- **ردیف‌ها:** `h-12 bg-muted/30 animate-pulse rounded-lg`.
- **رفتار در تم لایت و دارک:** پالس ملایم با کنتراست طبیعی متناسب با توکن `--muted`.
```tsx
// نحوه فراخوانی استاندارد در صفحات
import { Shimmer } from '@/components/shared/Shimmer'
{isLoading ? (
<Shimmer withCard cardHeaderTitle="عنوان لیست" rows={5} />
) : (
<TableContent />
)}
```
---
## ۹. دسترسی‌پذیری و تم‌های تاریک و روشن (Accessibility, Light & Dark Mode)
1. **حداقل کنتراست متنی (WCAG AA Compliance):**
- تمامی متون اصلی باید دارای نسبت کنتراست حداقل `4.5:1` با پس‌زمینه باشند.
- از قرار دادن متن خاکستری روشن روی پس‌زمینه سفید یا خاکستری تیره روی پس‌زمینه مشکی اکیداً خودداری شود.
2. **حالت غیرفعال (Disabled States):**
- شفافیت `opacity-50` همراه با `cursor-not-allowed` بدون ناخوانا شدن متن.
3. **وضوح فوکوس (Focus Rings):**
- تمامی فیلدها و دکمه‌ها باید دارای `focus-visible:ring-2 focus-visible:ring-primary/40` باشند تا ناوبری با کیبورد به درستی کار کند.
---
## ۱۰. چک‌لیست طلایی هوش مصنوعی قبل از تحویل هر تسک UI
قبل از تحویل هرگونه خروجی یا ثبت تغییرات در رابط کاربری، Agent باید این چک‌لیست را بررسی کند:
- [ ] **بررسی عدم استفاده از رنگ‌های مدادرنگی:** آیا از رنگ‌های تند غیرضروری برای کارت‌ها و تگ‌های عادی پرهیز شده است؟
- [ ] **بررسی تایپوگرافی چندزبانه:** آیا متون فارسی از `Vazirmatn` و اعداد/کدها از `font-mono` تبعیت می‌کنند؟
- [ ] **بررسی ابعاد و فواصل:** آیا پدینگ‌ها و مارجین‌ها مضرب ۴ و ۸ بوده و پدینگ افقی صفحات `16px` است؟
- [ ] **بررسی وضعیت لودینگ:** آیا لودینگ از کامپوننت استاندارد `Shimmer` با انیمیشن پالس استفاده می‌کند؟
- [ ] **بررسی دوگانگی تم:** آیا صفحه در هر دو حالت Light Mode و Dark Mode کنتراست و خوانایی کامل دارد؟
- [ ] **بررسی اصطلاحات و کلیدها:** آیا منطق برنامه صرفاً با کلیدهای انگلیسی کار کرده و هیچ متنی هاردکد نشده است؟

7
src/app/router.tsx

@ -7,7 +7,10 @@ import RequireAuth from '@/features/auth/RequireAuth'
const LoginPage = lazy(() => import('@/features/auth/LoginPage'))
const DashboardHome = lazy(() => import('@/features/dashboard/DashboardHome'))
const ToursPage = lazy(() => import('@/features/tours/ToursPage'))
const OngoingToursPage = lazy(() => import('@/features/tours/OngoingToursPage'))
const TourOrdersPage = lazy(() => import('@/features/tours/TourOrdersPage'))
const ServicesPage = lazy(() => import('@/features/services/ServicesPage'))
const BillingPage = lazy(() => import('@/features/billing/BillingPage'))
export default function AppRouter() {
return (
@ -22,7 +25,11 @@ export default function AppRouter() {
<Route element={<RequireAuth><Outlet /></RequireAuth>}>
<Route path="/" element={<PageReady><DashboardHome /></PageReady>} />
<Route path="/tours" element={<PageReady><ToursPage /></PageReady>} />
<Route path="/tours/ongoing" element={<PageReady><OngoingToursPage /></PageReady>} />
<Route path="/tours/orders" element={<PageReady><TourOrdersPage /></PageReady>} />
<Route path="/services" element={<PageReady><ServicesPage /></PageReady>} />
<Route path="/services/:serviceKey" element={<PageReady><ServicesPage /></PageReady>} />
<Route path="/billing" element={<PageReady><BillingPage /></PageReady>} />
{/* هدایت مسیرهای ناشناخته به صفحه اصلی */}
<Route path="*" element={<Navigate to="/" replace />} />

31
src/components/layout/AppSidebar.tsx

@ -37,8 +37,12 @@ function Tail({ badge, soon }: { badge?: string; soon?: boolean }) {
function NavItem({ item }: { item: NavItemConfig }) {
const { pathname } = useLocation()
const hasChildren = Boolean(item.children && item.children.length > 0)
const isChildActive = hasChildren && item.children?.some((c) => pathname.startsWith(c.to))
const [isOpen, setIsOpen] = React.useState<boolean>(Boolean(isChildActive || pathname.startsWith('/tours')))
const isChildActive =
hasChildren &&
item.children?.some((c) =>
c.to === '/tours' || c.to === '/services' ? pathname === c.to : pathname.startsWith(c.to)
)
const [isOpen, setIsOpen] = React.useState<boolean>(Boolean(isChildActive))
React.useEffect(() => {
if (isChildActive) {
@ -57,15 +61,15 @@ function NavItem({ item }: { item: NavItemConfig }) {
)}
tooltip={item.title}
>
<div className="flex items-center gap-2 min-w-0">
<Ic name={item.icon} />
<span className="truncate">{item.title}</span>
<div className="flex items-center gap-2 flex-1 min-w-0">
<Ic name={item.icon} className="size-4 shrink-0" />
<span className="whitespace-nowrap">{item.title}</span>
</div>
<div className="flex items-center gap-1.5 ms-auto">
<div className="flex items-center gap-1.5 ms-auto shrink-0">
<Tail badge={item.badge} soon={item.soon} />
<Ic
name={isOpen ? 'chevUp' : 'chevDown'}
className="size-3.5 text-grey-3 transition-transform"
className="size-3.5 text-grey-3 transition-transform shrink-0"
/>
</div>
</SidebarMenuButton>
@ -73,7 +77,10 @@ function NavItem({ item }: { item: NavItemConfig }) {
{isOpen && (
<div className="mr-5 mt-1 flex flex-col gap-1 border-r border-border/40 pr-2">
{item.children.map((sub) => {
const isSubActive = sub.to === '/tours' ? pathname === '/tours' : pathname.startsWith(sub.to)
const isSubActive =
sub.to === '/tours' || sub.to === '/services'
? pathname === sub.to
: pathname.startsWith(sub.to)
return (
<NavLink
key={sub.to}
@ -86,7 +93,7 @@ function NavItem({ item }: { item: NavItemConfig }) {
)}
>
<Ic name={sub.icon} className="size-3.5 shrink-0" />
<span className="truncate">{sub.title}</span>
<span className="whitespace-nowrap">{sub.title}</span>
{sub.badge && (
<span className="ms-auto rounded-full bg-primary/20 px-1.5 py-0.2 text-[10px] text-primary">
{sub.badge}
@ -104,8 +111,8 @@ function NavItem({ item }: { item: NavItemConfig }) {
const isActive = item.to ? (item.to === '/' ? pathname === '/' : pathname.startsWith(item.to)) : false
const inner = (
<>
<Ic name={item.icon} />
<span className="truncate">{item.title}</span>
<Ic name={item.icon} className="size-4 shrink-0" />
<span className="whitespace-nowrap">{item.title}</span>
<Tail badge={item.badge} soon={item.soon} />
</>
)
@ -155,7 +162,7 @@ function SidebarUserFooter() {
onClick={logout}
className="text-grey-2 hover:bg-rose-500/10 hover:text-rose-400"
>
<Ic name="logout" />
<Ic name="logout" className="size-4 shrink-0" />
<span className="truncate">خروج از سیستم</span>
</SidebarMenuButton>
</SidebarMenuItem>

16
src/components/layout/Topbar.tsx

@ -11,10 +11,18 @@ export default function Topbar() {
// پیدا کردن عنوان صفحه بر اساس مسیر از کانفیگ
let currentTitle = siteConfig.name
for (const group of navigationConfig) {
const found = group.items.find((it) => it.to === pathname)
if (found) {
currentTitle = found.title
break
for (const it of group.items) {
if (it.to === pathname) {
currentTitle = it.title
break
}
if (it.children) {
const sub = it.children.find((c) => c.to === pathname)
if (sub) {
currentTitle = sub.title
break
}
}
}
}
if (pathname === '/') currentTitle = 'داشبورد مدیریت'

94
src/components/shared/FilterBar.tsx

@ -0,0 +1,94 @@
import React from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Ic } from '@/icons'
import { cn, toFa } from '@/lib/utils'
export interface FilterBarProps {
searchQuery?: string
onSearchChange?: (query: string) => void
searchPlaceholder?: string
hasActiveFilters?: boolean
activeFilterCount?: number
onResetFilters?: () => void
children?: React.ReactNode
endContent?: React.ReactNode
className?: string
}
export default function FilterBar({
searchQuery,
onSearchChange,
searchPlaceholder = 'جستجو...',
hasActiveFilters = false,
activeFilterCount,
onResetFilters,
children,
endContent,
className,
}: FilterBarProps) {
return (
<div
className={cn(
'flex flex-col gap-3 rounded-2xl border border-border-soft bg-card/30 p-3.5 backdrop-blur-xs lg:flex-row lg:items-center lg:justify-between shadow-xs',
className,
)}
>
{/* Search Input and Primary Filters */}
<div className="flex flex-1 flex-wrap items-center gap-2.5">
{onSearchChange !== undefined && (
<div className="relative w-full max-w-xs shrink-0">
<Ic
name="search"
className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none"
/>
<Input
value={searchQuery ?? ''}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={searchPlaceholder}
className="h-9 pr-9 pl-8 text-xs bg-card/70 border-border/70 focus-visible:border-primary focus-visible:ring-1 focus-visible:ring-primary rounded-xl"
/>
{Boolean(searchQuery) && (
<button
type="button"
onClick={() => onSearchChange('')}
className="absolute left-2.5 top-1/2 -translate-y-1/2 flex size-4 items-center justify-center rounded-full text-muted-foreground hover:text-foreground transition-colors"
title="پاک‌کردن جستجو"
>
<Ic name="x" className="size-3" />
</button>
)}
</div>
)}
{/* Filter Dropdowns */}
{children}
{/* Reset Filters Button */}
{hasActiveFilters && onResetFilters && (
<Button
variant="ghost"
size="sm"
onClick={onResetFilters}
className="h-9 px-2.5 text-xs text-rose-500 hover:bg-rose-500/10 hover:text-rose-600 dark:hover:bg-rose-500/15 rounded-xl font-bold gap-1.5 transition-colors shrink-0"
>
<Ic name="x" className="size-3.5" />
<span>پاکسازی فیلترها</span>
{activeFilterCount !== undefined && activeFilterCount > 0 && (
<span className="flex size-4.5 items-center justify-center rounded-full bg-rose-500/15 text-[10px] font-mono text-rose-500">
{toFa(activeFilterCount)}
</span>
)}
</Button>
)}
</div>
{/* Optional End Content (e.g. SortDropdown, ViewToggle, etc.) */}
{endContent && (
<div className="flex flex-wrap items-center gap-2 border-t border-border-soft pt-2 lg:border-t-0 lg:pt-0 shrink-0">
{endContent}
</div>
)}
</div>
)
}

137
src/components/shared/FilterSelect.tsx

@ -0,0 +1,137 @@
import React from 'react'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Ic, type IconName } from '@/icons'
import { cn } from '@/lib/utils'
export interface FilterOption<T extends string = string> {
value: T
label: string
icon?: IconName | React.ReactNode
badge?: string | number
disabled?: boolean
}
export interface FilterSelectProps<T extends string = string> {
value: T
onChange: (value: T) => void
options: FilterOption<T>[]
placeholder?: string
label?: string
icon?: IconName
allValue?: T
size?: 'sm' | 'default'
disabled?: boolean
clearable?: boolean
className?: string
triggerClassName?: string
contentClassName?: string
}
export default function FilterSelect<T extends string = string>({
value,
onChange,
options,
placeholder = 'انتخاب کنید...',
label,
icon,
allValue,
size = 'default',
disabled = false,
clearable = false,
className,
triggerClassName,
contentClassName,
}: FilterSelectProps<T>) {
const isFiltered = allValue !== undefined ? value !== allValue : Boolean(value)
const selectedOption = options.find((opt) => opt.value === value)
const handleClear = (e: React.MouseEvent) => {
e.stopPropagation()
if (allValue !== undefined) {
onChange(allValue)
}
}
return (
<div className={cn('flex flex-col gap-1', className)}>
{label && (
<span className="text-[11px] font-semibold text-muted-foreground px-0.5">
{label}
</span>
)}
<div className="relative flex items-center">
<Select value={value} onValueChange={onChange} disabled={disabled}>
<SelectTrigger
size={size}
className={cn(
'transition-all duration-150',
isFiltered &&
'border-primary/60 bg-primary/5 text-primary font-semibold hover:border-primary/80 hover:bg-primary/10 shadow-xs',
clearable && isFiltered && 'pe-8',
triggerClassName,
)}
>
<div className="flex items-center gap-2 truncate">
{icon && (
<Ic
name={icon}
className={cn(
'size-3.5 shrink-0 transition-colors',
isFiltered ? 'text-primary' : 'text-muted-foreground',
)}
/>
)}
{selectedOption?.icon && typeof selectedOption.icon === 'string' ? (
<Ic name={selectedOption.icon as IconName} className="size-3.5 shrink-0" />
) : (
selectedOption?.icon
)}
<SelectValue placeholder={placeholder} />
</div>
</SelectTrigger>
<SelectContent className={cn('min-w-[10rem]', contentClassName)}>
{options.map((option) => (
<SelectItem
key={option.value}
value={option.value}
disabled={option.disabled}
>
<div className="flex items-center gap-2 truncate">
{option.icon && typeof option.icon === 'string' ? (
<Ic name={option.icon as IconName} className="size-3.5 shrink-0 text-muted-foreground" />
) : (
option.icon
)}
<span>{option.label}</span>
</div>
{option.badge !== undefined && (
<span className="ms-2 rounded-full bg-secondary px-1.5 py-0.2 text-[10px] font-mono text-muted-foreground">
{option.badge}
</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
{clearable && isFiltered && (
<button
type="button"
onClick={handleClear}
className="absolute left-6 z-10 flex size-4 items-center justify-center rounded-full text-muted-foreground hover:bg-primary/10 hover:text-primary transition-colors"
title="حذف این فیلتر"
>
<Ic name="x" className="size-2.5" />
</button>
)}
</div>
</div>
)
}

133
src/components/shared/Shimmer.tsx

@ -0,0 +1,133 @@
import React from 'react'
import { cn } from '@/lib/utils'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import { Ic, IconName } from '@/icons'
export interface ShimmerProps {
/** تعداد ردیف‌ها یا آیتم‌های اسکلتون (پیش‌فرض: ۵) */
rows?: number
count?: number
/** ارتفاع هر ردیف (پیش‌فرض: h-12) */
rowHeight?: string
/** کلاس‌های سفارشی برای بدنه و والد */
className?: string
/** کلاس‌های سفارشی برای هر آیتم */
itemClassName?: string
/** آیا درون یک Card با کادر و سایه استاندارد قرار گیرد؟ */
withCard?: boolean
/** عنوان هدر کارت در صورت استفاده از withCard */
cardHeaderTitle?: string
/** آیکون هدر کارت */
cardHeaderIcon?: IconName
/** متن سمت چپ هدر کارت */
cardHeaderSub?: React.ReactNode
/** نوع نمایش: ردیفی (پیش‌فرض) یا کارت‌های گرید */
variant?: 'list' | 'grid' | 'kpi'
/** تعداد ستون‌ها در حالت grid (پیش‌فرض: 3) */
gridCols?: number
}
/**
* کامپوننت یکپارچه و استاندارد Shimmer برای Loadingهای پنل مدیریت عقیله
* الگوی اصلی برگرفته از صفحه صورتحسابها (Billing)
*/
export function Shimmer({
rows,
count = 5,
rowHeight = 'h-12',
className,
itemClassName,
withCard = false,
cardHeaderTitle,
cardHeaderIcon = 'list',
cardHeaderSub,
variant = 'list',
gridCols = 3,
}: ShimmerProps) {
const itemCount = rows !== undefined ? rows : count
let content: React.ReactNode
if (variant === 'grid') {
const colClasses: Record<number, string> = {
1: 'grid-cols-1',
2: 'grid-cols-1 sm:grid-cols-2',
3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4',
}
content = (
<div className={cn('grid gap-4 p-6 sm:p-8', colClasses[gridCols] || 'grid-cols-1 sm:grid-cols-3', className)}>
{Array.from({ length: itemCount }).map((_, i) => (
<div
key={i}
className={cn(
'h-36 rounded-2xl bg-muted/30 animate-pulse border border-border-soft/40',
itemClassName
)}
/>
))}
</div>
)
} else if (variant === 'kpi') {
content = (
<div className={cn('grid grid-cols-2 gap-3.5 sm:grid-cols-4', className)}>
{Array.from({ length: itemCount }).map((_, i) => (
<div
key={i}
className={cn(
'h-24 rounded-2xl bg-muted/30 animate-pulse border border-border-soft/40',
itemClassName
)}
/>
))}
</div>
)
} else {
// ردیفی استاندارد (مطابق ۱۰۰٪ با صورت‌حساب‌ها)
content = (
<div className={cn('p-8 space-y-3', className)}>
{Array.from({ length: itemCount }).map((_, i) => (
<div
key={i}
className={cn(
rowHeight,
'bg-muted/30 animate-pulse rounded-lg transition-all duration-300',
itemClassName
)}
/>
))}
</div>
)
}
if (withCard) {
return (
<Card className="border-border/50 shadow-xs overflow-hidden">
{(cardHeaderTitle || cardHeaderSub) && (
<CardHeader className="py-3 px-4 border-b border-border/40 bg-muted/20 flex flex-row items-center justify-between">
{cardHeaderTitle && (
<CardTitle className="text-xs font-bold flex items-center gap-2">
<Ic name={cardHeaderIcon} className="size-4 text-primary" />
<span>{cardHeaderTitle}</span>
</CardTitle>
)}
{cardHeaderSub && (
<span className="text-xs text-muted-foreground font-mono">
{cardHeaderSub}
</span>
)}
</CardHeader>
)}
<CardContent className="p-0">
{content}
</CardContent>
</Card>
)
}
return <>{content}</>
}
export const shimmer = Shimmer
export default Shimmer

69
src/components/shared/SortDropdown.tsx

@ -0,0 +1,69 @@
import React from 'react'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Ic } from '@/icons'
import { cn } from '@/lib/utils'
export interface SortOption<T extends string = string> {
value: T
label: string
}
export interface SortDropdownProps<T extends string = string> {
value: T
onChange: (value: T) => void
options: SortOption<T>[]
label?: string
placeholder?: string
size?: 'sm' | 'default'
className?: string
triggerClassName?: string
}
export default function SortDropdown<T extends string = string>({
value,
onChange,
options,
label = 'مرتب‌سازی:',
placeholder = 'مرتب‌سازی بر اساس...',
size = 'default',
className,
triggerClassName,
}: SortDropdownProps<T>) {
return (
<div className={cn('flex items-center gap-2', className)}>
{label && (
<span className="text-[11.5px] font-semibold text-muted-foreground whitespace-nowrap">
{label}
</span>
)}
<Select value={value} onValueChange={onChange}>
<SelectTrigger
size={size}
className={cn(
'min-w-[9.5rem] bg-card/60 hover:bg-card border-border/60 transition-colors',
triggerClassName,
)}
>
<div className="flex items-center gap-1.5 truncate">
<Ic name="arrowUpDown" className="size-3.5 shrink-0 text-primary" />
<SelectValue placeholder={placeholder} />
</div>
</SelectTrigger>
<SelectContent className="min-w-[10rem]">
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
<span>{option.label}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}

2
src/components/ui/alert-dialog.tsx

@ -23,7 +23,7 @@ function AlertDialogContent({ className, children, ...props }: React.ComponentPr
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
className={cn(
'fixed left-1/2 top-1/2 z-50 w-full max-w-[420px] -translate-x-1/2 -translate-y-1/2 rounded-xl border border-border-soft bg-[#27282e] shadow-[0_30px_80px_rgba(0,0,0,.55)] anim-pop outline-none p-6',
'fixed left-1/2 top-1/2 z-50 w-full max-w-[420px] -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-border bg-card text-card-foreground shadow-2xl anim-pop outline-none p-6',
className,
)}
{...props}

4
src/components/ui/command.tsx

@ -14,7 +14,7 @@ function Command({ className, ...props }: React.ComponentProps<typeof CommandPri
return (
<CommandPrimitive
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-xl bg-[#27282e] text-foreground',
'flex h-full w-full flex-col overflow-hidden rounded-xl bg-popover text-popover-foreground',
className,
)}
{...props}
@ -45,7 +45,7 @@ function CommandDialog({
<DialogContent
className={cn(
'left-1/2 top-[12vh] w-[calc(100vw-2rem)] max-w-[800px] -translate-x-1/2 translate-y-0',
'overflow-hidden border-border-soft bg-[#27282e] p-0 shadow-[0_30px_90px_rgba(0,0,0,.6)]',
'overflow-hidden border-border bg-popover p-0 shadow-2xl backdrop-blur-md',
className,
)}
>

24
src/components/ui/dropdown-menu.tsx

@ -4,14 +4,19 @@ import { cn } from '@/lib/utils'
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
function DropdownMenuContent({ className, sideOffset = 6, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
function DropdownMenuContent({
className,
sideOffset = 6,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[10rem] overflow-hidden rounded-md border border-border-soft bg-popover p-1.5 text-popover-foreground shadow-[0_18px_44px_rgba(0,0,0,.5)] anim-pop',
'z-50 min-w-[10rem] overflow-hidden rounded-xl border border-border bg-popover p-1.5 text-popover-foreground shadow-xl backdrop-blur-md anim-pop',
className,
)}
{...props}
@ -20,11 +25,16 @@ function DropdownMenuContent({ className, sideOffset = 6, ...props }: React.Comp
)
}
function DropdownMenuItem({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Item>) {
function DropdownMenuItem({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item>) {
return (
<DropdownMenuPrimitive.Item
className={cn(
'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-3 py-2 text-[12.5px] text-grey-2 outline-none transition-colors data-[highlighted]:bg-white/8 data-[highlighted]:text-foreground',
'relative flex cursor-pointer select-none items-center gap-2 rounded-lg px-3 py-2 text-xs font-medium text-foreground outline-none transition-colors',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground',
className,
)}
{...props}
@ -38,7 +48,7 @@ function DropdownMenuLabel({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label>) {
return (
<DropdownMenuPrimitive.Label
className={cn('px-2 py-1.5 text-xs font-semibold text-grey-3', className)}
className={cn('px-2.5 py-1.5 text-[11px] font-bold text-muted-foreground', className)}
{...props}
/>
)
@ -50,7 +60,7 @@ function DropdownMenuSeparator({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
className={cn('-mx-1 my-1 h-px bg-border-soft', className)}
className={cn('-mx-1 my-1 h-px bg-border/50', className)}
{...props}
/>
)
@ -63,5 +73,5 @@ export {
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuGroup,
}

6
src/components/ui/multi-select.tsx

@ -129,19 +129,19 @@ export default function MultiSelect({
align="start"
sideOffset={6}
className={cn(
'z-[100] max-h-56 w-[var(--radix-popover-trigger-width)] overflow-y-hidden rounded-lg border border-border-soft bg-[#27282e] p-0 shadow-[0_16px_48px_rgba(0,0,0,.5)]',
'z-[100] max-h-56 w-[var(--radix-popover-trigger-width)] overflow-y-hidden rounded-xl border border-border bg-popover text-popover-foreground p-0 shadow-xl backdrop-blur-md',
contentClassName,
)}
>
{searchable && (
<div className="border-b border-border-soft p-2">
<div className="border-b border-border p-2">
<input
ref={searchRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={searchPlaceholder}
className="w-full rounded-md border border-border-soft bg-white/5 px-3 py-1.5 text-[12.5px] text-grey-2 outline-none transition-colors placeholder:text-grey-4 focus:border-red-mid/50"
className="w-full rounded-lg border border-border bg-background px-3 py-1.5 text-[12.5px] text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary"
/>
</div>
)}

2
src/components/ui/popover.tsx

@ -12,7 +12,7 @@ function PopoverContent({ className, align = 'center', sideOffset = 4, ...props
<PopoverPrimitive.Content
align={align}
className={cn(
'z-50 w-72 rounded-lg border border-border-soft bg-[#27282e] p-3 shadow-[0_16px_48px_rgba(0,0,0,.5)] anim-pop outline-none',
'z-50 w-72 rounded-xl border border-border bg-popover text-popover-foreground p-3 shadow-xl backdrop-blur-md anim-pop outline-none',
className,
)}
sideOffset={sideOffset}

77
src/components/ui/select.tsx

@ -7,37 +7,61 @@ const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
function SelectTrigger({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Trigger>) {
interface SelectTriggerProps extends React.ComponentProps<typeof SelectPrimitive.Trigger> {
size?: 'sm' | 'default' | 'lg'
}
function SelectTrigger({ className, children, size = 'default', ...props }: SelectTriggerProps) {
return (
<SelectPrimitive.Trigger
className={cn(
'flex h-10 w-full items-center justify-between gap-2 rounded-lg border border-border-soft bg-white/5 px-3 py-2 text-[13px] text-grey-2 outline-none transition-colors hover:bg-white/9 focus-visible:border-red-mid/50 data-[placeholder]:text-grey-4',
'group flex w-full items-center justify-between gap-2 rounded-xl border border-border/70 bg-card px-3 text-xs font-medium text-foreground outline-none transition-all duration-150',
'hover:border-border hover:bg-card/80',
'focus-visible:border-primary focus-visible:ring-1 focus-visible:ring-primary',
'disabled:cursor-not-allowed disabled:opacity-50',
'data-[placeholder]:text-muted-foreground shadow-2xs',
size === 'sm' && 'h-8 px-2.5 text-[11.5px]',
size === 'default' && 'h-9 px-3 text-xs',
size === 'lg' && 'h-10 px-3.5 text-sm',
className,
)}
{...props}
>
{children}
<div className="flex items-center gap-2 truncate">{children}</div>
<SelectPrimitive.Icon asChild>
<Ic name="chevDown" className="size-4 shrink-0 opacity-60" />
<Ic
name="chevDown"
className="size-3.5 shrink-0 text-muted-foreground transition-transform duration-200 group-data-[state=open]:rotate-180"
/>
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({ className, children, position = 'popper', ...props }: React.ComponentProps<typeof SelectPrimitive.Content>) {
function SelectContent({
className,
children,
position = 'popper',
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
className={cn(
'relative z-50 max-h-64 min-w-[8rem] overflow-hidden rounded-lg border border-border-soft bg-[#27282e] shadow-[0_16px_48px_rgba(0,0,0,.5)] anim-pop',
position === 'popper' && 'translate-y-1',
'relative z-50 max-h-72 min-w-[8.5rem] overflow-hidden rounded-xl border border-border bg-popover p-1 text-popover-foreground shadow-xl backdrop-blur-md anim-pop',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
)}
position={position}
{...props}
>
<SelectPrimitive.Viewport
className={cn('p-1', position === 'popper' && 'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]')}
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
)}
>
{children}
</SelectPrimitive.Viewport>
@ -50,14 +74,47 @@ function SelectItem({ className, children, ...props }: React.ComponentProps<type
return (
<SelectPrimitive.Item
className={cn(
'relative flex w-full cursor-pointer select-none items-center rounded-sm px-3 py-2 text-[13px] text-grey-2 outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[highlighted]:bg-white/8 data-[highlighted]:text-foreground',
'relative flex w-full cursor-pointer select-none items-center justify-between rounded-lg px-2.5 py-2 text-xs text-popover-foreground outline-none transition-colors',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground',
'data-[state=checked]:bg-primary/10 data-[state=checked]:text-primary data-[state=checked]:font-bold',
className,
)}
{...props}
>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator className="ms-2 flex items-center justify-center">
<Ic name="check" className="size-3.5 text-primary" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectItem }
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
className={cn('px-2.5 py-1.5 text-[11px] font-bold text-muted-foreground', className)}
{...props}
/>
)
}
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
className={cn('-mx-1 my-1 h-px bg-border/50', className)}
{...props}
/>
)
}
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectItem,
SelectLabel,
SelectSeparator,
}

2
src/components/ui/shimmer.tsx

@ -0,0 +1,2 @@
export * from '@/components/shared/Shimmer'
export { default } from '@/components/shared/Shimmer'

6
src/components/ui/single-select.tsx

@ -98,19 +98,19 @@ export default function SingleSelect({
align="start"
sideOffset={6}
className={cn(
'z-[100] max-h-56 w-[var(--radix-popover-trigger-width)] overflow-y-hidden rounded-lg border border-border-soft bg-[#27282e] p-0 shadow-[0_16px_48px_rgba(0,0,0,.5)]',
'z-[100] max-h-56 w-[var(--radix-popover-trigger-width)] overflow-y-hidden rounded-xl border border-border bg-popover text-popover-foreground p-0 shadow-xl backdrop-blur-md',
contentClassName,
)}
>
{searchable && (
<div className="border-b border-border-soft p-2">
<div className="border-b border-border p-2">
<input
ref={searchRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={searchPlaceholder}
className="w-full rounded-md border border-border-soft bg-white/5 px-3 py-1.5 text-[12.5px] text-grey-2 outline-none transition-colors placeholder:text-grey-4 focus:border-red-mid/50"
className="w-full rounded-lg border border-border bg-background px-3 py-1.5 text-[12.5px] text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary"
/>
</div>
)}

18
src/config/navigation.ts

@ -30,10 +30,26 @@ export const navigationConfig: NavGroupConfig[] = [
title: 'تورها',
icon: 'map',
children: [
{ title: 'فهرست تورها', to: '/tours', icon: 'list' },
{ title: 'فهرست همه تورها', to: '/tours', icon: 'map' },
{ title: 'تورهای در حال برگزاری', to: '/tours/ongoing', icon: 'calendar' },
{ title: 'سفارشات و رزروها', to: '/tours/orders', icon: 'clipboardCheck' },
],
},
{
title: 'خدمات سامانه',
icon: 'sparkles',
children: [
{ title: 'مرکز خدمات (همه)', to: '/services', icon: 'grid' },
{ title: 'سیم‌کارت (Simcard)', to: '/services/simcard', icon: 'simcard' },
{ title: 'راهنمای زائر (Zayer Guide)', to: '/services/zayer-guide', icon: 'guide' },
{ title: 'صرافی آنلاین (Exchange)', to: '/services/exchange', icon: 'exchange' },
{ title: 'آداب سفر (Etiquette)', to: '/services/trip-etiquette', icon: 'etiquette' },
{ title: 'مشاوره (Consultant)', to: '/services/consultant', icon: 'headset' },
{ title: 'فروشگاه معصومه (Store)', to: '/services/store', icon: 'store' },
{ title: 'تخمین هزینه (Cost Estimate)', to: '/services/cost-estimate', icon: 'calculator' },
],
},
{ title: 'صورت‌حساب‌ها و مالی', to: '/billing', icon: 'receipt' },
],
},
]

690
src/features/billing/BillingPage.tsx

@ -0,0 +1,690 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Avatar } from '@/components/ui/avatar'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import FilterSelect from '@/components/shared/FilterSelect'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Ic } from '@/icons'
import { Shimmer } from '@/components/shared/Shimmer'
import { fetchFactors, reviewFactor, clearFactorReceipt } from './services/billing-api'
import type { FactorItem, FactorStatus, FactorService, BillingStats } from './types'
import { BillingStatusBadge } from './components/BillingStatusBadge'
import { BillingServiceBadge } from './components/BillingServiceBadge'
import { BillingDetailModal } from './components/BillingDetailModal'
import { RejectFactorDialog } from './components/RejectFactorDialog'
import { ReceiptLightboxDialog } from './components/ReceiptLightboxDialog'
const PAGE_SIZE = 15
export default function BillingPage() {
const [factors, setFactors] = useState<FactorItem[]>([])
const [totalCount, setTotalCount] = useState<number>(0)
const [isLoading, setIsLoading] = useState<boolean>(true)
const [error, setError] = useState<string | null>(null)
const [actionSuccessMessage, setActionSuccessMessage] = useState<string | null>(null)
// Filters & Pagination
const [searchQuery, setSearchQuery] = useState<string>('')
const [selectedStatus, setSelectedStatus] = useState<string>('all')
const [selectedService, setSelectedService] = useState<string>('all')
const [currentPage, setCurrentPage] = useState<number>(1)
// Modals state
const [selectedFactorForDetail, setSelectedFactorForDetail] = useState<FactorItem | null>(null)
const [selectedFactorForReject, setSelectedFactorForReject] = useState<FactorItem | null>(null)
const [selectedFactorForLightbox, setSelectedFactorForLightbox] = useState<FactorItem | null>(null)
// Updating action state
const [updatingId, setUpdatingId] = useState<number | null>(null)
const [isSubmittingReject, setIsSubmittingReject] = useState<boolean>(false)
// Load factors from API
const loadFactors = useCallback(async () => {
setIsLoading(true)
setError(null)
try {
const offset = (currentPage - 1) * PAGE_SIZE
const res = await fetchFactors({
status: selectedStatus !== 'all' ? selectedStatus : undefined,
service: selectedService !== 'all' ? selectedService : undefined,
search: searchQuery || undefined,
limit: PAGE_SIZE,
offset,
})
setFactors(res.results || [])
setTotalCount(res.count || 0)
} catch (err: any) {
setError(err?.message || 'خطا در برقراری ارتباط با سرور و بارگذاری صورت‌حساب‌ها.')
} finally {
setIsLoading(false)
}
}, [selectedStatus, selectedService, searchQuery, currentPage])
useEffect(() => {
loadFactors()
}, [loadFactors])
// Reset page when filters change
const handleFilterChange = (setter: (val: string) => void) => (val: string) => {
setter(val)
setCurrentPage(1)
}
const handleResetFilters = () => {
setSearchQuery('')
setSelectedStatus('all')
setSelectedService('all')
setCurrentPage(1)
}
// KPI Statistics calculations
const stats = useMemo<BillingStats>(() => {
const total = totalCount
let awaitingPayment = 0
let pendingReview = 0
let approved = 0
let rejectedOrExpired = 0
let totalApprovedAmount = 0
factors.forEach((f) => {
const val = parseFloat(f.amount || '0')
const numVal = isNaN(val) ? 0 : val
if (f.status === 'awaiting_payment') awaitingPayment++
else if (f.status === 'pending') pendingReview++
else if (f.status === 'approved') {
approved++
totalApprovedAmount += numVal
} else if (f.status === 'rejected' || f.status === 'expired') {
rejectedOrExpired++
}
})
return {
total,
awaitingPayment,
pendingReview,
approved,
rejectedOrExpired,
totalApprovedAmount,
}
}, [factors, totalCount])
// Actions
const handleApprove = async (factorId: number) => {
setUpdatingId(factorId)
try {
const updated = await reviewFactor(factorId, { status: 'approved' })
setFactors((prev) => prev.map((f) => (f.id === factorId ? updated : f)))
if (selectedFactorForDetail?.id === factorId) {
setSelectedFactorForDetail(updated)
}
if (selectedFactorForLightbox?.id === factorId) {
setSelectedFactorForLightbox(updated)
}
showToastMessage(`صورت‌حساب شماره #${factorId} با موفقیت تایید شد.`)
} catch (err: any) {
alert(`خطا در تایید صورت‌حساب: ${err?.message || 'مشکل غیرمنتظره'}`)
} finally {
setUpdatingId(null)
}
}
const handleConfirmReject = async (factorId: number, reason: string) => {
setIsSubmittingReject(true)
setUpdatingId(factorId)
try {
const updated = await reviewFactor(factorId, {
status: 'rejected',
rejected_description: reason,
})
setFactors((prev) => prev.map((f) => (f.id === factorId ? updated : f)))
if (selectedFactorForDetail?.id === factorId) {
setSelectedFactorForDetail(updated)
}
if (selectedFactorForLightbox?.id === factorId) {
setSelectedFactorForLightbox(updated)
}
showToastMessage(`صورت‌حساب شماره #${factorId} با ثبت دلیل رد شد.`)
} catch (err: any) {
alert(`خطا در رد صورت‌حساب: ${err?.message || 'مشکل غیرمنتظره'}`)
} finally {
setIsSubmittingReject(false)
setUpdatingId(null)
}
}
const handleClearReceipt = async (factorId: number) => {
if (!window.confirm('آیا از حذف تصویر فیش بارگذاری‌شده برای این صورت‌حساب اطمینان دارید؟')) {
return
}
setUpdatingId(factorId)
try {
await clearFactorReceipt(factorId)
setFactors((prev) =>
prev.map((f) => (f.id === factorId ? { ...f, transaction_receipt: null } : f))
)
if (selectedFactorForDetail?.id === factorId) {
setSelectedFactorForDetail((prev) => (prev ? { ...prev, transaction_receipt: null } : null))
}
if (selectedFactorForLightbox?.id === factorId) {
setSelectedFactorForLightbox((prev) => (prev ? { ...prev, transaction_receipt: null } : null))
}
showToastMessage('فیش واریزی با موفقیت پاک شد.')
} catch (err: any) {
alert(`خطا در حذف فیش: ${err?.message || 'مشکل غیرمنتظره'}`)
} finally {
setUpdatingId(null)
}
}
const showToastMessage = (msg: string) => {
setActionSuccessMessage(msg)
setTimeout(() => setActionSuccessMessage(null), 4000)
}
const totalPages = Math.ceil(totalCount / PAGE_SIZE) || 1
return (
<div className="p-4 sm:p-6 lg:p-8 space-y-6 max-w-7xl mx-auto">
{/* پیام بازخورد عملیات */}
{actionSuccessMessage && (
<div className="bg-emerald-500/15 border border-emerald-500/30 text-emerald-700 dark:text-emerald-300 px-4 py-3 rounded-xl flex items-center justify-between text-xs font-medium animate-in fade-in slide-in-from-top-2">
<div className="flex items-center gap-2">
<Ic name="checkCircle" className="size-4 text-emerald-500" />
<span>{actionSuccessMessage}</span>
</div>
<button
onClick={() => setActionSuccessMessage(null)}
className="hover:opacity-70 text-muted-foreground"
>
<Ic name="x" className="size-3.5" />
</button>
</div>
)}
{/* هدر صفحه */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<div className="flex items-center gap-2.5">
<div className="size-9 rounded-xl bg-primary/15 text-primary flex items-center justify-center font-bold">
<Ic name="receipt" className="size-5" />
</div>
<h1 className="text-xl font-black tracking-tight">مدیریت صورتحسابها و فاکتورها</h1>
<Badge variant="secondary" className="font-mono text-xs">
{totalCount} فاکتور
</Badge>
</div>
<p className="text-xs text-muted-foreground mt-1">
بررسی فیشهای بانکی، تایید یا رد پرداختها و پیگیری ریز فاکتورهای ۵ سرویس سامانه عقیله
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={loadFactors}
disabled={isLoading}
className="gap-1.5 text-xs h-9"
>
<Ic name="refresh" className={`size-3.5 ${isLoading ? 'animate-spin' : ''}`} />
<span>بهروزرسانی</span>
</Button>
</div>
</div>
{/* کارت‌های KPI شاخص‌های کلیدی */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3.5">
{/* کل فاکتورها */}
<Card className="border-border/50 shadow-xs">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] text-muted-foreground font-medium">کل صورتحسابها</p>
<p className="text-xl font-black mt-1 font-mono">{stats.total}</p>
</div>
<div className="size-9 rounded-xl bg-muted/60 flex items-center justify-center text-muted-foreground">
<Ic name="receipt" className="size-4" />
</div>
</CardContent>
</Card>
{/* در انتظار بررسی فیش */}
<Card className="border-blue-500/20 bg-blue-500/5 shadow-xs">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] text-blue-600 dark:text-blue-400 font-medium">
در انتظار بررسی فیش
</p>
<p className="text-xl font-black mt-1 font-mono text-blue-600 dark:text-blue-400">
{stats.pendingReview}
</p>
</div>
<div className="size-9 rounded-xl bg-blue-500/15 text-blue-600 dark:text-blue-400 flex items-center justify-center">
<Ic name="clock" className="size-4" />
</div>
</CardContent>
</Card>
{/* در انتظار پرداخت */}
<Card className="border-amber-500/20 bg-amber-500/5 shadow-xs">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] text-amber-600 dark:text-amber-400 font-medium">
در انتظار پرداخت مشتری
</p>
<p className="text-xl font-black mt-1 font-mono text-amber-600 dark:text-amber-400">
{stats.awaitingPayment}
</p>
</div>
<div className="size-9 rounded-xl bg-amber-500/15 text-amber-600 dark:text-amber-400 flex items-center justify-center">
<Ic name="creditCard" className="size-4" />
</div>
</CardContent>
</Card>
{/* تایید شده */}
<Card className="border-emerald-500/20 bg-emerald-500/5 shadow-xs">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 font-medium">
پرداختهای تایید شده
</p>
<p className="text-xl font-black mt-1 font-mono text-emerald-600 dark:text-emerald-400">
{stats.approved}
</p>
</div>
<div className="size-9 rounded-xl bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 flex items-center justify-center">
<Ic name="checkCircle" className="size-4" />
</div>
</CardContent>
</Card>
{/* رد شده یا منقضی */}
<Card className="border-rose-500/20 bg-rose-500/5 shadow-xs col-span-2 sm:col-span-1">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] text-rose-600 dark:text-rose-400 font-medium">
رد شده / منقضی
</p>
<p className="text-xl font-black mt-1 font-mono text-rose-600 dark:text-rose-400">
{stats.rejectedOrExpired}
</p>
</div>
<div className="size-9 rounded-xl bg-rose-500/15 text-rose-600 dark:text-rose-400 flex items-center justify-center">
<Ic name="xCircle" className="size-4" />
</div>
</CardContent>
</Card>
</div>
{/* نوار جستجو و فیلترها */}
<Card className="border-border/50 shadow-xs">
<CardContent className="p-4">
<div className="flex flex-col md:flex-row items-center gap-3">
{/* جستجوی متنی */}
<div className="relative flex-1 w-full">
<Ic
name="search"
className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground"
/>
<Input
placeholder="جستجو در فاکتورها (نام کاربر، ایمیل، شماره کارت، شماره فاکتور)..."
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value)
setCurrentPage(1)
}}
className="pr-9 h-9 text-xs"
/>
{searchQuery && (
<button
onClick={() => {
setSearchQuery('')
setCurrentPage(1)
}}
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<Ic name="x" className="size-3" />
</button>
)}
</div>
{/* فیلتر وضعیت */}
<div className="w-full md:w-48">
<FilterSelect
value={selectedStatus}
onChange={handleFilterChange(setSelectedStatus)}
options={[
{ value: 'all', label: 'همه وضعیت‌ها' },
{ value: 'pending', label: 'در انتظار بررسی فیش' },
{ value: 'awaiting_payment', label: 'در انتظار پرداخت' },
{ value: 'approved', label: 'پرداخت تایید شده' },
{ value: 'rejected', label: 'رد شده' },
{ value: 'expired', label: 'منقضی شده' },
]}
allValue="all"
placeholder="وضعیت فاکتور"
size="sm"
/>
</div>
{/* فیلتر سرویس */}
<div className="w-full md:w-48">
<FilterSelect
value={selectedService}
onChange={handleFilterChange(setSelectedService)}
options={[
{ value: 'all', label: 'همه سرویس‌ها' },
{ value: 'tour', label: 'تورهای زیارتی' },
{ value: 'simcard', label: 'سیم‌کارت' },
{ value: 'shop', label: 'فروشگاه سوغات' },
{ value: 'tasrif', label: 'صرافی (تسریف)' },
{ value: 'custom_trip', label: 'سفر اختصاصی' },
]}
allValue="all"
placeholder="نوع سرویس"
size="sm"
/>
</div>
{/* بازنشانی فیلترها */}
{(searchQuery || selectedStatus !== 'all' || selectedService !== 'all') && (
<Button
variant="ghost"
size="sm"
onClick={handleResetFilters}
className="h-9 text-xs text-muted-foreground hover:text-foreground shrink-0"
>
<Ic name="x" className="size-3.5 me-1" />
<span>پاکسازی فیلترها</span>
</Button>
)}
</div>
</CardContent>
</Card>
{/* جدول داده‌ای فاکتورها */}
<Card className="border-border/50 shadow-xs overflow-hidden">
<CardHeader className="py-3 px-4 border-b border-border/40 bg-muted/20 flex flex-row items-center justify-between">
<CardTitle className="text-xs font-bold flex items-center gap-2">
<Ic name="list" className="size-4 text-primary" />
<span>فهرست فاکتورها</span>
</CardTitle>
<span className="text-xs text-muted-foreground font-mono">
صفحه {currentPage} از {totalPages}
</span>
</CardHeader>
<CardContent className="p-0">
{isLoading ? (
<Shimmer rows={5} />
) : error ? (
<div className="p-8 text-center text-rose-500 space-y-2">
<Ic name="alert" className="size-8 mx-auto" />
<p className="text-xs font-bold">{error}</p>
<Button variant="outline" size="sm" onClick={loadFactors} className="mt-2 text-xs">
تلاش مجدد
</Button>
</div>
) : factors.length === 0 ? (
<div className="p-12 text-center text-muted-foreground space-y-3">
<div className="size-14 rounded-2xl bg-muted/40 flex items-center justify-center mx-auto text-muted-foreground">
<Ic name="inbox" className="size-7" />
</div>
<p className="text-sm font-bold text-foreground">صورتحسابی یافت نشد.</p>
<p className="text-xs text-muted-foreground max-w-sm mx-auto">
{searchQuery || selectedStatus !== 'all' || selectedService !== 'all'
? 'با فیلترهای جستجوی فعلی موردی پیدا نشد. می‌توانید فیلترها را پاکسازی نمایید.'
: 'هنوز هیچ صورت‌حسابی در سیستم صادر نشده است.'}
</p>
{(searchQuery || selectedStatus !== 'all' || selectedService !== 'all') && (
<Button variant="outline" size="sm" onClick={handleResetFilters} className="text-xs">
پاکسازی فیلترها
</Button>
)}
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent text-xs">
<TableHead className="w-16 text-center">شناسه</TableHead>
<TableHead>مشتری</TableHead>
<TableHead>سرویس</TableHead>
<TableHead>مبلغ فاکتور</TableHead>
<TableHead>وضعیت</TableHead>
<TableHead className="text-center">فیش واریزی</TableHead>
<TableHead>تاریخ صدور</TableHead>
<TableHead className="text-end pl-4">عملیات</TableHead>
</TableRow>
</TableHeader>
<TableBody className="text-xs">
{factors.map((factor) => {
const isPending = factor.status === 'pending'
const hasReceipt = Boolean(factor.transaction_receipt)
const user = factor.user_info
return (
<TableRow
key={factor.id}
className={`transition-colors ${
isPending ? 'bg-blue-500/[0.03] hover:bg-blue-500/[0.07]' : ''
}`}
>
{/* شناسه */}
<TableCell className="text-center font-mono font-bold text-muted-foreground">
#{factor.id}
</TableCell>
{/* مشخصات مشتری */}
<TableCell>
<div className="flex items-center gap-2.5">
<Avatar
className="size-8 border border-border/40 shrink-0 text-[10px] font-bold"
src={user?.avatar || undefined}
letter={user?.fullname ? user.fullname.slice(0, 2) : 'ک'}
/>
<div className="min-w-0">
<p className="font-bold text-foreground truncate max-w-[140px] sm:max-w-[180px]">
{user?.fullname || 'نامشخص'}
</p>
<p className="text-[11px] text-muted-foreground truncate max-w-[140px] sm:max-w-[180px]">
{user?.email || user?.phone_number || '—'}
</p>
</div>
</div>
</TableCell>
{/* سرویس */}
<TableCell>
<BillingServiceBadge service={factor.service} />
</TableCell>
{/* مبلغ */}
<TableCell>
<span className="font-bold font-mono text-sm text-foreground">
{Number(factor.amount).toLocaleString()} $
</span>
</TableCell>
{/* وضعیت */}
<TableCell>
<BillingStatusBadge status={factor.status} />
</TableCell>
{/* پیش‌نمایش تصویر فیش */}
<TableCell className="text-center">
{hasReceipt ? (
<button
onClick={() => setSelectedFactorForLightbox(factor)}
className="relative group size-9 rounded-lg overflow-hidden border border-border/60 mx-auto block hover:scale-105 transition-transform"
title="مشاهده فیش واریزی"
>
<img
src={factor.transaction_receipt!}
alt="فیش"
className="size-full object-cover"
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 flex items-center justify-center text-white transition-opacity">
<Ic name="eye" className="size-3" />
</div>
</button>
) : (
<span
className="text-[10px] text-muted-foreground block text-center"
title="فیش واریزی بارگذاری نشده"
>
ندارد
</span>
)}
</TableCell>
{/* تاریخ صدور */}
<TableCell className="text-muted-foreground whitespace-nowrap">
{new Date(factor.created_at).toLocaleDateString('fa-IR')}
</TableCell>
{/* دکمه‌های عملیات */}
<TableCell className="text-end pl-4">
<div className="flex items-center justify-end gap-1.5">
{/* دکمه مشاهده جزئیات */}
<Button
variant="outline"
size="sm"
className="h-8 px-2.5 text-xs gap-1"
onClick={() => setSelectedFactorForDetail(factor)}
title="مشاهده ریز فاکتور"
>
<Ic name="eye" className="size-3.5" />
<span className="hidden sm:inline">جزئیات</span>
</Button>
{/* تایید سریع فیش */}
{factor.status !== 'approved' && (
<Button
variant="ghost"
size="icon"
className="size-8 text-emerald-600 hover:text-emerald-700 hover:bg-emerald-500/10"
onClick={() => handleApprove(factor.id)}
disabled={updatingId === factor.id}
title="تایید پرداخت"
>
<Ic name="checkCircle" className="size-4" />
</Button>
)}
{/* رد سریع فیش */}
{factor.status !== 'rejected' && (
<Button
variant="ghost"
size="icon"
className="size-8 text-rose-600 hover:text-rose-700 hover:bg-rose-500/10"
onClick={() => setSelectedFactorForReject(factor)}
disabled={updatingId === factor.id}
title="رد فاکتور"
>
<Ic name="xCircle" className="size-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)}
{/* صفحه‌بندی */}
{totalPages > 1 && (
<div className="p-3 border-t border-border/40 flex items-center justify-between text-xs">
<span className="text-muted-foreground">
نمایش {(currentPage - 1) * PAGE_SIZE + 1} تا{' '}
{Math.min(currentPage * PAGE_SIZE, totalCount)} از {totalCount} فاکتور
</span>
<div className="flex items-center gap-1.5">
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1 || isLoading}
>
قبلی
</Button>
<span className="px-2 font-mono font-bold">
{currentPage} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages || isLoading}
>
بعدی
</Button>
</div>
</div>
)}
</CardContent>
</Card>
{/* ۱. مودال جزئیات جامع صورت‌حساب */}
<BillingDetailModal
factor={selectedFactorForDetail}
open={Boolean(selectedFactorForDetail)}
onClose={() => setSelectedFactorForDetail(null)}
onApprove={handleApprove}
onOpenRejectDialog={(f) => {
setSelectedFactorForDetail(null)
setSelectedFactorForReject(f)
}}
onOpenReceiptLightbox={(f) => setSelectedFactorForLightbox(f)}
onClearReceipt={handleClearReceipt}
isUpdating={updatingId === selectedFactorForDetail?.id}
/>
{/* ۲. مودال لایت‌باکس پیش‌نمایش بزرگ فیش واریزی */}
<ReceiptLightboxDialog
factor={selectedFactorForLightbox}
open={Boolean(selectedFactorForLightbox)}
onClose={() => setSelectedFactorForLightbox(null)}
onApprove={handleApprove}
onReject={(factorId) => {
setSelectedFactorForLightbox(null)
const f = factors.find((item) => item.id === factorId) || null
setSelectedFactorForReject(f)
}}
isUpdating={updatingId === selectedFactorForLightbox?.id}
/>
{/* ۳. مودال دریافت دلیل رد فاکتور */}
<RejectFactorDialog
factor={selectedFactorForReject}
open={Boolean(selectedFactorForReject)}
onClose={() => setSelectedFactorForReject(null)}
onConfirm={handleConfirmReject}
isSubmitting={isSubmittingReject}
/>
</div>
)
}

472
src/features/billing/components/BillingDetailModal.tsx

@ -0,0 +1,472 @@
import React, { useState } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Avatar } from '@/components/ui/avatar'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Ic } from '@/icons'
import type { FactorItem } from '../types'
import { BillingStatusBadge } from './BillingStatusBadge'
import { BillingServiceBadge } from './BillingServiceBadge'
interface Props {
factor: FactorItem | null
open: boolean
onClose: () => void
onApprove: (factorId: number) => Promise<void>
onOpenRejectDialog: (factor: FactorItem) => void
onOpenReceiptLightbox: (factor: FactorItem) => void
onClearReceipt?: (factorId: number) => Promise<void>
isUpdating?: boolean
}
export function BillingDetailModal({
factor,
open,
onClose,
onApprove,
onOpenRejectDialog,
onOpenReceiptLightbox,
onClearReceipt,
isUpdating,
}: Props) {
const [copiedCard, setCopiedCard] = useState(false)
if (!factor) return null
const handleCopyCard = (cardNumber: string) => {
navigator.clipboard.writeText(cardNumber)
setCopiedCard(true)
setTimeout(() => setCopiedCard(false), 2000)
}
const detail = factor.detail_service
const user = factor.user_info
return (
<Dialog open={open} onOpenChange={(val) => !val && onClose()}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto border border-border/60 bg-background/95 backdrop-blur-md p-0">
{/* هدر مودال */}
<DialogHeader className="p-5 border-b border-border/40 sticky top-0 bg-background/95 backdrop-blur-sm z-10">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2.5">
<div className="size-10 rounded-xl bg-primary/15 text-primary flex items-center justify-center font-bold">
<Ic name="receipt" className="size-5" />
</div>
<div>
<DialogTitle className="text-lg font-bold flex items-center gap-2">
<span>صورتحساب شماره #{factor.id}</span>
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground mt-0.5">
ثبت شده در: {new Date(factor.created_at).toLocaleString('fa-IR')}
</DialogDescription>
</div>
</div>
<div className="flex items-center gap-2">
<BillingServiceBadge service={factor.service} />
<BillingStatusBadge status={factor.status} />
</div>
</div>
</DialogHeader>
<div className="p-5 space-y-5">
{/* کارت وضعیت انقضا یا هشدار رد */}
{factor.status === 'rejected' && factor.rejected_description && (
<div className="bg-rose-500/10 border border-rose-500/20 rounded-xl p-4 flex items-start gap-3 text-rose-700 dark:text-rose-300">
<Ic name="xCircle" className="size-5 shrink-0 mt-0.5 text-rose-500" />
<div>
<h4 className="font-bold text-xs mb-1">دلیل رد صورتحساب:</h4>
<p className="text-xs leading-relaxed">{factor.rejected_description}</p>
</div>
</div>
)}
{factor.expiration_date && (
<div className="bg-muted/40 border border-border/40 rounded-xl p-3 flex items-center justify-between text-xs">
<span className="text-muted-foreground flex items-center gap-1.5">
<Ic name="clock" className="size-4 text-amber-500" />
<span>مهلت پرداخت (تاریخ انقضا):</span>
</span>
<span className="font-semibold">{factor.expiration_date}</span>
</div>
)}
{/* اطلاعات خریدار و اطلاعات مالی در ۲ ستون */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* کارت مشخصات خریدار */}
<Card className="border-border/50 shadow-xs">
<CardHeader className="py-3 px-4 border-b border-border/40">
<CardTitle className="text-xs font-bold text-muted-foreground flex items-center gap-2">
<Ic name="users" className="size-4 text-primary" />
<span>اطلاعات مشتری</span>
</CardTitle>
</CardHeader>
<CardContent className="p-4 space-y-3">
<div className="flex items-center gap-3">
<Avatar
className="size-11 border border-border/40 text-xs font-bold"
src={user?.avatar || undefined}
letter={user?.fullname ? user.fullname.slice(0, 2) : 'ک'}
/>
<div className="min-w-0">
<p className="font-bold text-sm truncate text-foreground">
{user?.fullname || 'نام ثبت نشده'}
</p>
<p className="text-xs text-muted-foreground truncate">{user?.email || '—'}</p>
</div>
</div>
<div className="pt-2 border-t border-border/40 space-y-1.5 text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground">شماره تماس:</span>
<span className="font-mono dir-ltr font-medium text-foreground">
{user?.phone_number || '—'}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">شناسه کاربری:</span>
<span className="font-mono font-medium">{user?.id ?? '—'}</span>
</div>
</div>
</CardContent>
</Card>
{/* کارت مشخصات مالی فاکتور */}
<Card className="border-border/50 shadow-xs">
<CardHeader className="py-3 px-4 border-b border-border/40">
<CardTitle className="text-xs font-bold text-muted-foreground flex items-center gap-2">
<Ic name="creditCard" className="size-4 text-primary" />
<span>مشخصات مالی و واریز</span>
</CardTitle>
</CardHeader>
<CardContent className="p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">مبلغ کل فاکتور:</span>
<span className="text-lg font-black text-primary">
{Number(factor.amount).toLocaleString()} $
</span>
</div>
{factor.card_number && (
<div className="bg-muted/40 p-2.5 rounded-lg border border-border/40 flex items-center justify-between text-xs">
<div className="flex flex-col">
<span className="text-[10px] text-muted-foreground">شماره کارت مقصد:</span>
<span className="font-mono font-bold tracking-wider dir-ltr text-foreground">
{factor.card_number}
</span>
</div>
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-foreground"
onClick={() => handleCopyCard(factor.card_number!)}
title="کپی شماره کارت"
>
<Ic name={copiedCard ? 'check' : 'clipboardCheck'} className="size-3.5" />
</Button>
</div>
)}
<div className="pt-2 border-t border-border/40 space-y-1 text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground">شناسه سفارش مرجع:</span>
<span className="font-mono font-medium">
{factor.order_id || factor.object_id}
</span>
</div>
{factor.description && (
<div className="flex justify-between gap-2">
<span className="text-muted-foreground shrink-0">توضیحات:</span>
<span className="text-foreground text-end truncate">{factor.description}</span>
</div>
)}
</div>
</CardContent>
</Card>
</div>
{/* جزئیات سرویس چندشکلی (Polymorphic Details) */}
<Card className="border-border/50 shadow-xs">
<CardHeader className="py-3 px-4 border-b border-border/40 bg-muted/20">
<CardTitle className="text-xs font-bold flex items-center gap-2">
<Ic name="list" className="size-4 text-primary" />
<span>ریز اقلام و مشخصات سرویس ({factor.service.toUpperCase()})</span>
</CardTitle>
</CardHeader>
<CardContent className="p-4 text-xs space-y-4">
{/* ۱. تور */}
{factor.service === 'tour' && (
<div className="space-y-3">
<div className="flex items-center justify-between pb-2 border-b border-border/40">
<span className="text-muted-foreground">عنوان تور:</span>
<span className="font-bold text-sm text-foreground">{detail?.tour || '—'}</span>
</div>
{detail?.passenger_data ? (
<div className="space-y-3">
<p className="font-bold text-xs text-muted-foreground">اسامی و رده سنی مسافران:</p>
{/* بزرگسالان */}
{(detail.passenger_data.adults?.count ?? 0) > 0 && (
<div className="bg-muted/30 p-2.5 rounded-lg border border-border/30">
<span className="font-bold text-purple-600 dark:text-purple-400">
بزرگسال ({detail.passenger_data.adults?.count} نفر):
</span>
<span className="ms-2 text-foreground">
{detail.passenger_data.adults?.names.join('، ')}
</span>
</div>
)}
{/* کودکان */}
{(detail.passenger_data.children?.count ?? 0) > 0 && (
<div className="bg-muted/30 p-2.5 rounded-lg border border-border/30">
<span className="font-bold text-blue-600 dark:text-blue-400">
کودک ({detail.passenger_data.children?.count} نفر):
</span>
<span className="ms-2 text-foreground">
{detail.passenger_data.children?.names.join('، ')}
</span>
</div>
)}
{/* نوزادان */}
{(detail.passenger_data.infants?.count ?? 0) > 0 && (
<div className="bg-muted/30 p-2.5 rounded-lg border border-border/30">
<span className="font-bold text-pink-600 dark:text-pink-400">
نوزاد ({detail.passenger_data.infants?.count} نفر):
</span>
<span className="ms-2 text-foreground">
{detail.passenger_data.infants?.names.join('، ')}
</span>
</div>
)}
</div>
) : (
<p className="text-muted-foreground">اطلاعات مسافران ثبت نشده است.</p>
)}
</div>
)}
{/* ۲. سیم‌کارت */}
{factor.service === 'simcard' && (
<div className="space-y-3">
<div className="divide-y divide-border/40">
{detail?.items?.map((item, idx) => (
<div key={idx} className="py-2 flex items-center justify-between">
<div>
<span className="font-medium text-foreground">
{item.simcard_title || `سیم‌کارت کد ${item.simcard_id}`}
</span>
<span className="ms-2 text-muted-foreground">
(تعداد: {item.quantity})
</span>
</div>
<span className="font-bold font-mono">
{Number(item.cost).toLocaleString()} $
</span>
</div>
))}
</div>
{detail?.total_cost && (
<div className="flex justify-between pt-2 border-t border-border/40 font-bold">
<span>هزینه کل اقلام:</span>
<span className="text-primary font-mono">{detail.total_cost} $</span>
</div>
)}
</div>
)}
{/* ۳. فروشگاه */}
{factor.service === 'shop' && (
<div className="space-y-3">
<div className="divide-y divide-border/40">
{detail?.items?.map((item, idx) => (
<div key={idx} className="py-2 flex items-center justify-between">
<div>
<span className="font-medium text-foreground">
محصول {item.product_slug || `#${item.product_id}`}
</span>
<span className="ms-2 text-muted-foreground">
({item.quantity} عدد × {item.price} $)
</span>
</div>
<span className="font-bold font-mono">
{Number(item.cost).toLocaleString()} $
</span>
</div>
))}
</div>
{detail?.total_cost && (
<div className="flex justify-between pt-2 border-t border-border/40 font-bold">
<span>جمع اقلام سبد خرید:</span>
<span className="text-primary font-mono">{detail.total_cost} $</span>
</div>
)}
</div>
)}
{/* ۴. صرافی / تسریف */}
{factor.service === 'tasrif' && (
<div className="space-y-2 bg-muted/30 p-3 rounded-lg border border-border/40">
<div className="flex justify-between">
<span className="text-muted-foreground">تبدیل ارز:</span>
<span className="font-bold text-foreground">
از {detail?.from_currency} به {detail?.to_currency}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">مبلغ درخواستی:</span>
<span className="font-mono font-medium">{detail?.amount}</span>
</div>
<div className="flex justify-between pt-2 border-t border-border/40 font-bold">
<span>مبلغ نهایی معادل:</span>
<span className="text-emerald-600 dark:text-emerald-400 font-mono text-sm">
{detail?.total_price} $
</span>
</div>
</div>
)}
{/* ۵. سفر اختصاصی (Custom Trip) */}
{factor.service === 'custom_trip' && (
<div className="space-y-3">
{detail?.destinations && detail.destinations.length > 0 && (
<div>
<span className="text-muted-foreground block mb-1">مسیر سفر:</span>
<div className="flex flex-wrap gap-2">
{detail.destinations.map((d, i) => (
<span
key={i}
className="bg-primary/10 text-primary px-2.5 py-1 rounded-md text-xs font-semibold"
>
{d}
</span>
))}
</div>
</div>
)}
{detail?.detail && (
<div className="bg-muted/30 p-3 rounded-lg border border-border/40">
<span className="text-muted-foreground block mb-1">خلاصه برنامه سفر:</span>
<p className="text-xs leading-relaxed text-foreground whitespace-pre-wrap">
{typeof detail.detail === 'string' ? detail.detail : JSON.stringify(detail.detail)}
</p>
</div>
)}
</div>
)}
</CardContent>
</Card>
{/* تصویر فیش واریزی */}
<Card className="border-border/50 shadow-xs">
<CardHeader className="py-3 px-4 border-b border-border/40 flex flex-row items-center justify-between">
<CardTitle className="text-xs font-bold flex items-center gap-2">
<Ic name="image" className="size-4 text-primary" />
<span>فیش واریزی و رسید بانکی</span>
</CardTitle>
{factor.transaction_receipt && onClearReceipt && (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-muted-foreground hover:text-destructive"
onClick={() => onClearReceipt(factor.id)}
disabled={isUpdating}
>
<Ic name="trash" className="size-3.5 me-1" />
<span>حذف فیش</span>
</Button>
)}
</CardHeader>
<CardContent className="p-4">
{factor.transaction_receipt ? (
<div className="flex flex-col sm:flex-row items-center gap-4 bg-muted/20 p-3 rounded-xl border border-border/40">
<div
onClick={() => onOpenReceiptLightbox(factor)}
className="relative cursor-pointer group overflow-hidden rounded-lg border border-border/40 size-32 shrink-0 bg-black/5"
>
<img
src={factor.transaction_receipt}
alt="فیش پرداختی"
className="size-full object-cover group-hover:scale-105 transition-transform"
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 flex items-center justify-center text-white transition-opacity text-xs font-medium">
<Ic name="eye" className="size-5" />
</div>
</div>
<div className="space-y-2 text-xs flex-1">
<p className="font-bold text-foreground">فیش بانکی توسط مشتری آپلود شده است.</p>
<p className="text-muted-foreground text-[11px]">
جهت بررسی صحت اطلاعات و انطباق با واریز حساب، میتوانید تصویر فیش را در سایز
کامل باز کرده و سپس وضعیت صورتحساب را تایید یا رد نمایید.
</p>
<Button
variant="outline"
size="sm"
className="h-8 gap-1 text-xs"
onClick={() => onOpenReceiptLightbox(factor)}
>
<Ic name="eye" className="size-3.5" />
<span>مشاهده در اندازه بزرگ</span>
</Button>
</div>
</div>
) : (
<div className="flex items-center gap-3 p-4 bg-amber-500/10 rounded-xl border border-amber-500/20 text-amber-700 dark:text-amber-300">
<Ic name="alert" className="size-5 text-amber-500 shrink-0" />
<p className="text-xs">
هنوز تصویر فیش واریزی توسط مشتری در سامانه بارگذاری نشده است.
</p>
</div>
)}
</CardContent>
</Card>
</div>
{/* فوتر مودال و دکمه‌های عملیاتی ادمین */}
<DialogFooter className="p-4 border-t border-border/40 bg-muted/20 sticky bottom-0 z-10 flex items-center justify-between sm:justify-between w-full">
<Button variant="outline" size="sm" onClick={onClose}>
بستن
</Button>
<div className="flex items-center gap-2">
{factor.status !== 'approved' && (
<Button
variant="default"
size="sm"
className="bg-emerald-600 hover:bg-emerald-700 text-white gap-1.5"
disabled={isUpdating}
onClick={() => onApprove(factor.id)}
>
<Ic name="checkCircle" className="size-4" />
<span>تایید پرداخت و فاکتور</span>
</Button>
)}
{factor.status !== 'rejected' && (
<Button
variant="destructive"
size="sm"
className="gap-1.5"
disabled={isUpdating}
onClick={() => onOpenRejectDialog(factor)}
>
<Ic name="xCircle" className="size-4" />
<span>رد صورتحساب</span>
</Button>
)}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

70
src/features/billing/components/BillingServiceBadge.tsx

@ -0,0 +1,70 @@
import React from 'react'
import { Badge } from '@/components/ui/badge'
import { Ic } from '@/icons'
import type { FactorService } from '../types'
interface Props {
service: FactorService
className?: string
}
export function BillingServiceBadge({ service, className }: Props) {
switch (service) {
case 'tour':
return (
<Badge
variant="outline"
className={`bg-purple-500/15 text-purple-700 dark:text-purple-300 border-purple-500/30 gap-1.5 font-medium ${className || ''}`}
>
<Ic name="map" className="size-3.5 text-purple-500 shrink-0" />
<span>تور</span>
</Badge>
)
case 'simcard':
return (
<Badge
variant="outline"
className={`bg-cyan-500/15 text-cyan-700 dark:text-cyan-300 border-cyan-500/30 gap-1.5 font-medium ${className || ''}`}
>
<Ic name="server" className="size-3.5 text-cyan-500 shrink-0" />
<span>سیمکارت</span>
</Badge>
)
case 'shop':
return (
<Badge
variant="outline"
className={`bg-amber-500/15 text-amber-700 dark:text-amber-300 border-amber-500/30 gap-1.5 font-medium ${className || ''}`}
>
<Ic name="tag" className="size-3.5 text-amber-500 shrink-0" />
<span>فروشگاه</span>
</Badge>
)
case 'tasrif':
return (
<Badge
variant="outline"
className={`bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border-emerald-500/30 gap-1.5 font-medium ${className || ''}`}
>
<Ic name="dollarSign" className="size-3.5 text-emerald-500 shrink-0" />
<span>صرافی (تسریف)</span>
</Badge>
)
case 'custom_trip':
return (
<Badge
variant="outline"
className={`bg-indigo-500/15 text-indigo-700 dark:text-indigo-300 border-indigo-500/30 gap-1.5 font-medium ${className || ''}`}
>
<Ic name="sparkles" className="size-3.5 text-indigo-500 shrink-0" />
<span>سفر اختصاصی</span>
</Badge>
)
default:
return (
<Badge variant="outline" className={className}>
{service}
</Badge>
)
}
}

70
src/features/billing/components/BillingStatusBadge.tsx

@ -0,0 +1,70 @@
import React from 'react'
import { Badge } from '@/components/ui/badge'
import { Ic } from '@/icons'
import type { FactorStatus } from '../types'
interface Props {
status: FactorStatus
className?: string
}
export function BillingStatusBadge({ status, className }: Props) {
switch (status) {
case 'awaiting_payment':
return (
<Badge
variant="outline"
className={`bg-amber-500/15 text-amber-700 dark:text-amber-300 border-amber-500/30 gap-1.5 font-medium ${className || ''}`}
>
<Ic name="clock" className="size-3.5 text-amber-500 shrink-0" />
<span>در انتظار پرداخت</span>
</Badge>
)
case 'pending':
return (
<Badge
variant="outline"
className={`bg-blue-500/15 text-blue-700 dark:text-blue-300 border-blue-500/30 gap-1.5 font-medium animate-pulse ${className || ''}`}
>
<Ic name="clock" className="size-3.5 text-blue-500 shrink-0" />
<span>در انتظار بررسی فیش</span>
</Badge>
)
case 'approved':
return (
<Badge
variant="outline"
className={`bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border-emerald-500/30 gap-1.5 font-medium ${className || ''}`}
>
<Ic name="checkCircle" className="size-3.5 text-emerald-500 shrink-0" />
<span>پرداخت تایید شده</span>
</Badge>
)
case 'rejected':
return (
<Badge
variant="outline"
className={`bg-rose-500/15 text-rose-700 dark:text-rose-300 border-rose-500/30 gap-1.5 font-medium ${className || ''}`}
>
<Ic name="xCircle" className="size-3.5 text-rose-500 shrink-0" />
<span>رد شده</span>
</Badge>
)
case 'expired':
return (
<Badge
variant="outline"
className={`bg-slate-500/15 text-slate-600 dark:text-slate-400 border-slate-500/30 gap-1.5 font-medium ${className || ''}`}
>
<Ic name="clock" className="size-3.5 text-slate-500 shrink-0" />
<span>منقضی شده</span>
</Badge>
)
default:
return (
<Badge variant="outline" className={className}>
{status}
</Badge>
)
}
}

140
src/features/billing/components/ReceiptLightboxDialog.tsx

@ -0,0 +1,140 @@
import React from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Ic } from '@/icons'
import type { FactorItem } from '../types'
import { BillingStatusBadge } from './BillingStatusBadge'
interface Props {
factor: FactorItem | null
open: boolean
onClose: () => void
onApprove?: (factorId: number) => void
onReject?: (factorId: number) => void
isUpdating?: boolean
}
export function ReceiptLightboxDialog({
factor,
open,
onClose,
onApprove,
onReject,
isUpdating,
}: Props) {
if (!factor) return null
const hasReceipt = Boolean(factor.transaction_receipt)
return (
<Dialog open={open} onOpenChange={(val) => !val && onClose()}>
<DialogContent className="max-w-2xl sm:max-w-3xl overflow-hidden p-0 border border-border/60 bg-background/95 backdrop-blur-md">
<DialogHeader className="p-4 border-b border-border/40 flex flex-row items-center justify-between">
<div>
<DialogTitle className="text-base font-bold flex items-center gap-2">
<Ic name="receipt" className="size-5 text-primary" />
<span>تصویر فیش پرداختی صورتحساب #{factor.id}</span>
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground mt-0.5">
مشتری: {factor.user_info?.fullname || factor.user_info?.email || 'نامشخص'} مبلغ:{' '}
{Number(factor.amount).toLocaleString()} $
</DialogDescription>
</div>
<div className="flex items-center gap-2 pl-6">
<BillingStatusBadge status={factor.status} />
</div>
</DialogHeader>
<div className="p-4 flex flex-col items-center justify-center min-h-[350px] max-h-[70vh] overflow-auto bg-muted/20">
{hasReceipt ? (
<div className="relative group max-w-full">
<img
src={factor.transaction_receipt!}
alt={`فیش پرداختی فاکتور #${factor.id}`}
className="rounded-lg object-contain max-h-[60vh] max-w-full shadow-md border border-border/40"
/>
<div className="absolute top-2 left-2 flex gap-1.5 opacity-90 group-hover:opacity-100 transition-opacity">
<a
href={factor.transaction_receipt!}
target="_blank"
rel="noopener noreferrer"
className="bg-black/70 hover:bg-black text-white px-2.5 py-1 rounded-md text-xs flex items-center gap-1 backdrop-blur-sm"
>
<Ic name="external" className="size-3.5" />
<span>اندازه اصلی</span>
</a>
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center text-center p-8 text-muted-foreground">
<div className="size-14 rounded-full bg-amber-500/10 flex items-center justify-center text-amber-500 mb-3">
<Ic name="alert" className="size-7" />
</div>
<p className="font-medium text-foreground">هنوز فیش واریزی برای این صورتحساب ثبت نشده است.</p>
<p className="text-xs mt-1 text-muted-foreground">
وضعیت فاکتور در انتظار پرداخت توسط مشتری میباشد.
</p>
</div>
)}
{factor.card_number && (
<div className="mt-4 w-full max-w-md bg-muted/40 p-3 rounded-lg border border-border/40 flex items-center justify-between text-xs">
<span className="text-muted-foreground">شماره کارت مقصد:</span>
<span className="font-mono font-bold tracking-wider dir-ltr">
{factor.card_number}
</span>
</div>
)}
{factor.rejected_description && (
<div className="mt-2 w-full max-w-md bg-rose-500/10 text-rose-700 dark:text-rose-300 p-3 rounded-lg border border-rose-500/20 text-xs">
<span className="font-bold">علت رد فاکتور: </span>
<span>{factor.rejected_description}</span>
</div>
)}
</div>
<DialogFooter className="p-3 border-t border-border/40 bg-muted/30 flex items-center justify-between sm:justify-between w-full">
<Button variant="outline" size="sm" onClick={onClose}>
بستن
</Button>
<div className="flex items-center gap-2">
{factor.status !== 'approved' && onApprove && (
<Button
variant="default"
size="sm"
className="bg-emerald-600 hover:bg-emerald-700 text-white gap-1.5"
disabled={isUpdating}
onClick={() => onApprove(factor.id)}
>
<Ic name="checkCircle" className="size-4" />
<span>تایید پرداخت</span>
</Button>
)}
{factor.status !== 'rejected' && onReject && (
<Button
variant="destructive"
size="sm"
className="gap-1.5"
disabled={isUpdating}
onClick={() => onReject(factor.id)}
>
<Ic name="xCircle" className="size-4" />
<span>رد فاکتور</span>
</Button>
)}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

105
src/features/billing/components/RejectFactorDialog.tsx

@ -0,0 +1,105 @@
import React, { useState } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Ic } from '@/icons'
import type { FactorItem } from '../types'
interface Props {
factor: FactorItem | null
open: boolean
onClose: () => void
onConfirm: (factorId: number, reason: string) => Promise<void>
isSubmitting?: boolean
}
export function RejectFactorDialog({
factor,
open,
onClose,
onConfirm,
isSubmitting,
}: Props) {
const [reason, setReason] = useState<string>('')
const [error, setError] = useState<string | null>(null)
if (!factor) return null
const handleConfirm = async () => {
if (!reason.trim()) {
setError('لطفاً دلیل رد فاکتور را وارد فرمایید تا به مشتری اطلاع داده شود.')
return
}
setError(null)
await onConfirm(factor.id, reason.trim())
setReason('')
onClose()
}
return (
<Dialog open={open} onOpenChange={(val) => !val && onClose()}>
<DialogContent className="max-w-md border border-border/60 bg-background/95 backdrop-blur-md">
<DialogHeader>
<DialogTitle className="text-base font-bold flex items-center gap-2 text-rose-600 dark:text-rose-400">
<Ic name="xCircle" className="size-5" />
<span>رد صورتحساب #{factor.id}</span>
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
مشتری: {factor.user_info?.fullname || factor.user_info?.email || 'نامشخص'} مبلغ:{' '}
{Number(factor.amount).toLocaleString()} $
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2">
<label className="text-xs font-semibold text-foreground">
دلیل رد صورتحساب / فیش پرداختی:
</label>
<Textarea
rows={4}
value={reason}
onChange={(e) => {
setReason(e.target.value)
if (error) setError(null)
}}
placeholder="مثال: فیش ناخوانا است، مبلغ واریزی مغایرت دارد، شماره رهگیری نامعتبر است..."
className="text-xs resize-none"
autoFocus
/>
{error && <p className="text-xs text-destructive font-medium">{error}</p>}
<p className="text-[11px] text-muted-foreground">
این توضیحات در اپلیکیشن مسافر / کلاینت در بخش جزئیات صورتحساب نمایش داده میشود.
</p>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" size="sm" onClick={onClose} disabled={isSubmitting}>
انصراف
</Button>
<Button
variant="destructive"
size="sm"
onClick={handleConfirm}
disabled={isSubmitting}
className="gap-1.5"
>
{isSubmitting ? (
<span>در حال ثبت...</span>
) : (
<>
<Ic name="xCircle" className="size-4" />
<span>ثبت و رد فاکتور</span>
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

71
src/features/billing/services/billing-api.ts

@ -0,0 +1,71 @@
import { apiFetch } from '@/services/http'
import type {
FactorItem,
DrfPaginatedResponse,
BillingFilterParams,
FactorStatus,
} from '../types'
/**
* دریافت فهرست فاکتورها ویژه ادمین با پشتیبانی از فیلتر وضعیت، فیلتر سرویس، جستجو و صفحهبندی
*/
export async function fetchFactors(
params?: BillingFilterParams
): Promise<DrfPaginatedResponse<FactorItem>> {
const query = new URLSearchParams()
query.set('view', 'admin')
if (params?.status && params.status !== 'all') {
query.set('status', params.status)
}
if (params?.service && params.service !== 'all') {
query.set('service', params.service)
}
if (params?.search && params.search.trim()) {
query.set('search', params.search.trim())
}
if (params?.limit !== undefined) {
query.set('limit', String(params.limit))
}
if (params?.offset !== undefined) {
query.set('offset', String(params.offset))
}
const queryString = query.toString()
const path = queryString ? `factors/list/?${queryString}` : 'factors/list/'
return apiFetch<DrfPaginatedResponse<FactorItem>>(path)
}
/**
* دریافت جزئیات جامع یک فاکتور از طریق شناسه
*/
export async function fetchFactorDetail(factorId: number): Promise<FactorItem> {
return apiFetch<FactorItem>(`factors/${factorId}/`)
}
/**
* بررسی، تایید یا رد فاکتور توسط ادمین و همگامسازی وضعیت سفارش مرتبط
*/
export async function reviewFactor(
factorId: number,
data: {
status: FactorStatus
rejected_description?: string
}
): Promise<FactorItem> {
return apiFetch<FactorItem>(`factors/${factorId}/review/`, {
method: 'POST',
body: data,
})
}
/**
* پاکسازی تصویر فیش ثبتشده برای فاکتور
*/
export async function clearFactorReceipt(
factorId: number
): Promise<{ detail: string }> {
return apiFetch<{ detail: string }>(`factors/clear-receipt/${factorId}/`, {
method: 'POST',
})
}

107
src/features/billing/types.ts

@ -0,0 +1,107 @@
export type FactorStatus =
| 'awaiting_payment'
| 'pending'
| 'approved'
| 'rejected'
| 'expired'
export type FactorService =
| 'tour'
| 'simcard'
| 'shop'
| 'tasrif'
| 'custom_trip'
export interface PassengerGroup {
count: number
names: string[]
}
export interface TourPassengerData {
adults?: PassengerGroup
children?: PassengerGroup
infants?: PassengerGroup
}
export interface OrderItemDetail {
simcard_id?: number
product_id?: number
product_slug?: string
simcard_title?: string
quantity: number
cost: string | number
price?: string | number
}
export interface DetailService {
// Tour
tour_id?: number
tour?: string
tour_slug?: string
passenger_data?: TourPassengerData
// Simcard & Shop
simcard_order_id?: number
total_cost?: string | number
items?: OrderItemDetail[]
// Tasrif
from_currency?: string
to_currency?: string
amount?: string | number
total_price?: string | number
// Custom Trip
detail?: string
destinations?: string[]
}
export interface UserInfo {
id: number
email: string
fullname: string
phone_number: string
avatar?: string | null
}
export interface FactorItem {
id: number
status: FactorStatus
service: FactorService
detail_service: DetailService | null
transaction_receipt: string | null
description: string | null
amount: string
card_number: string | null
object_id: number
order_id?: number | null
user_info?: UserInfo | null
rejected_description?: string | null
expiration_date?: string | null
created_at: string
updated_at: string
}
export interface DrfPaginatedResponse<T> {
count: number
next: string | null
previous: string | null
results: T[]
}
export interface BillingFilterParams {
status?: string
service?: string
search?: string
limit?: number
offset?: number
}
export interface BillingStats {
total: number
awaitingPayment: number
pendingReview: number
approved: number
rejectedOrExpired: number
totalApprovedAmount: number
}

57
src/features/global-search/GlobalSearchDialog.tsx

@ -46,24 +46,45 @@ export default function GlobalSearchDialog() {
{/* گروه‌های نویگیشن */}
{navigationConfig.map((group) => (
<CommandGroup key={group.title} heading={group.title}>
{group.items.map((item) => (
<CommandItem
key={item.title}
onSelect={() => {
if (item.to) {
runAction(() => navigate(item.to!))
}
}}
className="flex cursor-pointer items-center gap-2.5 rounded-lg px-3 py-2 text-xs"
>
<Ic name={item.icon} className="size-4 text-grey-3" />
<span>{item.title}</span>
{item.badge && (
<span className="ms-auto text-[10px] text-primary">{item.badge}</span>
)}
</CommandItem>
))}
<CommandGroup key={group.title} heading={group.title || 'بخش‌های سیستم'}>
{group.items.flatMap((item) => {
const entries: React.ReactNode[] = []
if (item.to) {
entries.push(
<CommandItem
key={item.title}
onSelect={() => {
if (item.to) runAction(() => navigate(item.to!))
}}
className="flex cursor-pointer items-center gap-2.5 rounded-lg px-3 py-2 text-xs"
>
<Ic name={item.icon} className="size-4 text-grey-3" />
<span>{item.title}</span>
{item.badge && (
<span className="ms-auto text-[10px] text-primary">{item.badge}</span>
)}
</CommandItem>
)
}
if (item.children) {
item.children.forEach((child) => {
entries.push(
<CommandItem
key={child.to}
onSelect={() => runAction(() => navigate(child.to))}
className="flex cursor-pointer items-center gap-2.5 rounded-lg px-3 py-2 text-xs"
>
<Ic name={child.icon} className="size-4 text-primary" />
<span>{item.title} &gt; {child.title}</span>
{child.badge && (
<span className="ms-auto text-[10px] text-primary">{child.badge}</span>
)}
</CommandItem>
)
})
}
return entries
})}
</CommandGroup>
))}

967
src/features/services/ServicesPage.tsx

@ -0,0 +1,967 @@
import React, { useState, useEffect, useMemo } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import { Ic, IconName } from '@/icons'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Shimmer } from '@/components/shared/Shimmer'
import { toFa } from '@/lib/utils'
import { apiFetch } from '@/services/http'
// تعریف ساختار داده‌های سرویس‌ها
export interface ServiceMeta {
key: string
title: string
titleEn: string
icon: IconName
color: string
bgColor: string
borderColor: string
description: string
endpoint: string
statsLabel: string
defaultCount: number
badge: string
}
export const SERVICES_LIST: ServiceMeta[] = [
{
key: 'simcard',
title: 'سیم‌کارت',
titleEn: 'Simcard',
icon: 'simcard',
color: 'text-primary',
bgColor: 'bg-primary/10',
borderColor: 'border-primary/20',
description: 'مدیریت بسته‌های اینترنت، سیم‌کارت‌های بین‌المللی و سفارش‌های زائرین (زین، آسیاسل، کورک، STC)',
endpoint: '/store/simcards/',
statsLabel: 'بسته‌های فعال',
defaultCount: 8,
badge: 'ارتباطات و دیتا',
},
{
key: 'zayer-guide',
title: 'راهنمای زائر',
titleEn: 'Zayer Guide',
icon: 'guide',
color: 'text-primary',
bgColor: 'bg-primary/10',
borderColor: 'border-primary/20',
description: 'بانک جامع اماکن متبرکه، راهنمای اماکن تاریخی، بیمارستان‌ها، مواکب و موقعیت مکانی شهرها',
endpoint: '/cityguide/',
statsLabel: 'مکان ثبت‌شده',
defaultCount: 42,
badge: 'اماکن و نقشه‌ها',
},
{
key: 'exchange',
title: 'صرافی آنلاین و تشریفات',
titleEn: 'Online Exchange',
icon: 'exchange',
color: 'text-primary',
bgColor: 'bg-primary/10',
borderColor: 'border-primary/20',
description: 'نرخ لحظه‌ای ارزهای زیارتی (دینار، ریال، دلار، لیر)، تبدیل آنلاین و سفارش تحویل وجه نقد در مقصد',
endpoint: '/tasrif/orders/',
statsLabel: 'سفارش تبدیل ارز',
defaultCount: 19,
badge: 'مالی و ارزی',
},
{
key: 'trip-etiquette',
title: 'آداب سفر و زیارت',
titleEn: 'Trip Etiquette',
icon: 'etiquette',
color: 'text-primary',
bgColor: 'bg-primary/10',
borderColor: 'border-primary/20',
description: 'مناسک، ادعیه و زیارات وارده، آداب حضور در عتبات عالیات و راهنمای گام‌به‌گام زیارت',
endpoint: '/trip-etiquette/cities/',
statsLabel: 'مناسک و زیارات',
defaultCount: 26,
badge: 'فرهنگی و معنوی',
},
{
key: 'consultant',
title: 'مشاوره و پشتیبانی',
titleEn: 'Consultant',
icon: 'headset',
color: 'text-primary',
bgColor: 'bg-primary/10',
borderColor: 'border-primary/20',
description: 'شبکه مشاورین تخصصی پزشکی سفر، احکام، امور حقوقی، گمشدگان و پشتیبانی ۲۴ ساعته زائرین',
endpoint: '/consultants/',
statsLabel: 'مشاور فعال',
defaultCount: 12,
badge: 'پشتیبانی زنده',
},
{
key: 'store',
title: 'فروشگاه معصومه',
titleEn: 'Massoumah Store',
icon: 'store',
color: 'text-primary',
bgColor: 'bg-primary/10',
borderColor: 'border-primary/20',
description: 'سوغات متبرک، ملزومات سفر اربعین و عتبات، کتب ادعیه، چفیه، سجاده و محصولات فرهنگی',
endpoint: '/store/products/',
statsLabel: 'محصول در انبار',
defaultCount: 35,
badge: 'فروشگاه و سوغات',
},
{
key: 'cost-estimate',
title: 'تخمین هزینه سفر',
titleEn: 'Cost Estimate',
icon: 'calculator',
color: 'text-primary',
bgColor: 'bg-primary/10',
borderColor: 'border-primary/20',
description: 'سامانه هوشمند برآورد هزینه‌های اقامت، پرواز، ترانسفر و خوراک برای سفرهای انفرادی و گروهی',
endpoint: '/cost-estimates/',
statsLabel: 'آیتم محاسباتی',
defaultCount: 14,
badge: 'محاسبات و برآورد',
},
]
export default function ServicesPage() {
const { serviceKey } = useParams<{ serviceKey?: string }>()
const navigate = useNavigate()
const currentService = useMemo(() => {
if (!serviceKey) return null
return SERVICES_LIST.find((s) => s.key === serviceKey) || null
}, [serviceKey])
// فیلتر و جستجو در لیست سرویس‌ها
const [searchTerm, setSearchTerm] = useState('')
const [activeFilter, setActiveFilter] = useState<'ALL' | 'ACTIVE' | 'SPECIAL'>('ALL')
const filteredServices = useMemo(() => {
return SERVICES_LIST.filter((s) => {
if (searchTerm.trim()) {
const query = searchTerm.toLowerCase()
const matchTitle = s.title.toLowerCase().includes(query)
const matchEn = s.titleEn.toLowerCase().includes(query)
const matchDesc = s.description.toLowerCase().includes(query)
if (!matchTitle && !matchEn && !matchDesc) return false
}
return true
})
}, [searchTerm])
return (
<div className="space-y-6 pt-2 pb-14">
{/* سربرگ بخش خدمات */}
<div className="flex flex-col gap-4 rounded-2xl border border-border-soft bg-card/40 p-6 shadow-sm sm:flex-row sm:items-center sm:justify-between backdrop-blur-xs">
<div className="space-y-1">
<div className="flex items-center gap-2.5">
<div className="flex size-9 items-center justify-center rounded-xl bg-primary/10 text-primary">
<Ic name="sparkles" className="size-5" />
</div>
<h1 className="text-xl font-black text-foreground">
{currentService ? currentService.title : 'مرکز خدمات سامانه عقیله'}
</h1>
<Badge
variant="outline"
className="border-primary/30 bg-primary/10 text-primary font-bold"
>
{currentService ? `${currentService.titleEn}` : '۷ سرویس هوشمند اپلیکیشن'}
</Badge>
</div>
<p className="text-xs text-grey-3">
{currentService
? currentService.description
: 'پایش، مدیریت و پیکربندی کلیه خدمات ارائه شده در اپلیکیشن موبایل زائرین عقیله'}
</p>
</div>
{/* دکمه‌های ناوبری سریع */}
<div className="flex flex-wrap items-center gap-2.5">
{currentService ? (
<Button
variant="outline"
size="sm"
onClick={() => navigate('/services')}
className="border-border-soft hover:bg-card/70 text-xs font-bold gap-1.5"
>
<Ic name="grid" className="size-3.5 text-primary" />
مشاهده همه خدمات
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={() => navigate('/tours')}
className="border-border-soft hover:bg-card/70 text-xs font-bold gap-1.5"
>
<Ic name="map" className="size-3.5 text-primary" />
مدیریت تورها
</Button>
)}
</div>
</div>
{/* نوارهای تب سریع بالا برای سوئیچ بین ۷ سرویس */}
<div className="flex items-center gap-2 overflow-x-auto pb-1 scrollbar-none">
<button
onClick={() => navigate('/services')}
className={`flex shrink-0 items-center gap-2 rounded-xl px-3.5 py-2 text-xs font-bold transition ${
!currentService
? 'bg-primary text-primary-foreground shadow-xs'
: 'border border-border-soft bg-card/40 text-grey-3 hover:bg-card hover:text-foreground'
}`}
>
<Ic name="grid" className="size-3.5" />
<span>مرکز خدمات (همه)</span>
</button>
{SERVICES_LIST.map((srv) => {
const isActive = currentService?.key === srv.key
return (
<button
key={srv.key}
onClick={() => navigate(`/services/${srv.key}`)}
className={`flex shrink-0 items-center gap-2 rounded-xl px-3.5 py-2 text-xs font-bold transition ${
isActive
? 'bg-primary text-primary-foreground shadow-xs'
: 'border border-border-soft bg-card/40 text-grey-3 hover:bg-card hover:text-foreground'
}`}
>
<Ic name={srv.icon} className={`size-3.5 ${isActive ? 'text-white' : srv.color}`} />
<span>{srv.title}</span>
</button>
)
})}
</div>
{/* اگر در نمای داشبورد کلی خدمات هستیم */}
{!currentService ? (
<ServicesOverviewHub services={filteredServices} searchTerm={searchTerm} setSearchTerm={setSearchTerm} />
) : (
/* اگر یک سرویس خاص انتخاب شده است */
<ServiceDetailRenderer service={currentService} />
)}
</div>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// بخش ۱: هاب اصلی معرفی و وضعیت ۷ سرویس
// ─────────────────────────────────────────────────────────────────────────────
function ServicesOverviewHub({
services,
searchTerm,
setSearchTerm,
}: {
services: ServiceMeta[]
searchTerm: string
setSearchTerm: (s: string) => void
}) {
const navigate = useNavigate()
return (
<div className="space-y-6">
{/* کارت‌های آماری کلی خدمات */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3.5">
<Card className="border-border/50 shadow-xs">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] text-muted-foreground font-medium">تعداد کل خدمات</p>
<p className="text-xl font-black mt-1 font-mono text-foreground">{toFa(SERVICES_LIST.length)}</p>
</div>
<div className="size-9 rounded-xl bg-primary/10 text-primary flex items-center justify-center">
<Ic name="sparkles" className="size-4" />
</div>
</CardContent>
</Card>
<Card className="border-emerald-500/20 bg-emerald-500/5 shadow-xs">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 font-medium">وضعیت وبسرویسها</p>
<p className="text-xl font-black mt-1 font-mono text-emerald-600 dark:text-emerald-400">۱۰۰٪</p>
</div>
<div className="size-9 rounded-xl bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 flex items-center justify-center">
<Ic name="checkCircle" className="size-4" />
</div>
</CardContent>
</Card>
<Card className="border-border/50 shadow-xs">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] text-muted-foreground font-medium">سفارشات ثبتشده</p>
<p className="text-xl font-black mt-1 font-mono text-foreground">{toFa(148)}</p>
</div>
<div className="size-9 rounded-xl bg-muted/60 text-muted-foreground flex items-center justify-center">
<Ic name="receipt" className="size-4" />
</div>
</CardContent>
</Card>
<Card className="border-border/50 shadow-xs">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] text-muted-foreground font-medium">پوشش کشورها</p>
<p className="text-xl font-black mt-1 font-mono text-foreground">{toFa(4)}</p>
</div>
<div className="size-9 rounded-xl bg-muted/60 text-muted-foreground flex items-center justify-center">
<Ic name="globe" className="size-4" />
</div>
</CardContent>
</Card>
</div>
{/* نوار جستجوی سرویس‌ها */}
<Card className="border-border/50 shadow-xs">
<CardContent className="p-4 flex flex-col sm:flex-row items-center justify-between gap-3">
<div className="text-xs font-bold text-foreground">
فهرست ماژولها و خدمات هوشمند ({toFa(services.length)} مورد)
</div>
<div className="relative w-full sm:w-80">
<Ic name="search" className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="جستجوی سرویس، ماژول، کلمات کلیدی..."
className="h-9 pr-9 text-xs"
/>
</div>
</CardContent>
</Card>
{/* شبکه کارت‌های ۷ سرویس عقیله */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{services.map((srv) => {
return (
<Card
key={srv.key}
onClick={() => navigate(`/services/${srv.key}`)}
className="group relative flex flex-col justify-between overflow-hidden border-border/50 bg-card shadow-xs transition duration-200 hover:-translate-y-0.5 hover:border-primary/40 hover:bg-card/90 hover:shadow-md cursor-pointer"
>
<CardContent className="p-5 space-y-4">
<div className="flex items-start justify-between">
<div className="flex size-11 items-center justify-center rounded-xl bg-primary/10 text-primary border border-primary/20 shadow-xs transition group-hover:scale-105">
<Ic name={srv.icon} className="size-5" />
</div>
<Badge
variant="outline"
className="border-border/50 bg-muted/40 text-[11px] font-medium text-muted-foreground"
>
{srv.badge}
</Badge>
</div>
<div className="space-y-1.5">
<div className="flex items-baseline justify-between">
<h2 className="text-base font-black text-foreground group-hover:text-primary transition">
{srv.title}
</h2>
<span className="text-[11px] font-mono text-muted-foreground">{srv.titleEn}</span>
</div>
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">
{srv.description}
</p>
</div>
{/* فوتر کارت سرویس */}
<div className="pt-3 border-t border-border/40 flex items-center justify-between text-xs">
<div className="flex items-center gap-1.5 text-muted-foreground">
<span className="font-bold text-foreground font-mono">{toFa(srv.defaultCount)}</span>
<span className="text-[11px]">{srv.statsLabel}</span>
</div>
<div className="flex items-center gap-1 text-primary text-xs font-bold group-hover:underline">
<span>مدیریت</span>
<Ic name="chevDown" className="-rotate-90 size-3" />
</div>
</div>
</CardContent>
</Card>
)
})}
</div>
</div>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// بخش ۲: رندرینگ محتوای اختصاصی هر سرویس بر اساس کلید آن
// ─────────────────────────────────────────────────────────────────────────────
function ServiceDetailRenderer({ service }: { service: ServiceMeta }) {
switch (service.key) {
case 'simcard':
return <SimcardServiceView service={service} />
case 'zayer-guide':
return <ZayerGuideServiceView service={service} />
case 'exchange':
return <ExchangeServiceView service={service} />
case 'trip-etiquette':
return <TripEtiquetteServiceView service={service} />
case 'consultant':
return <ConsultantServiceView service={service} />
case 'store':
return <StoreServiceView service={service} />
case 'cost-estimate':
return <CostEstimateServiceView service={service} />
default:
return <div className="p-8 text-center text-xs text-grey-3">سرویس مورد نظر یافت نشد.</div>
}
}
// ─────────────────────────────────────────────────────────────────────────────
// ۱. سیم‌کارت (Simcard)
// ─────────────────────────────────────────────────────────────────────────────
function SimcardServiceView({ service }: { service: ServiceMeta }) {
const [operatorFilter, setOperatorFilter] = useState('ALL')
const [searchTerm, setSearchTerm] = useState('')
const samplePackages = [
{ id: 1, operator: 'Zain Iraq', country: 'IQ', title: 'بسته زائرین زین ۱۰ گیگابایت + مکالمه رایگان', duration: '۱۵ روزه', price: '12,500 دینار', stock: 'موجود (۳۴۰ عدد)', status: 'ACTIVE' },
{ id: 2, operator: 'Asiacell', country: 'IQ', title: 'بسته نامحدود اینترنت آسیاسل اربعین و عتبات', duration: '۳۰ روزه', price: '20,000 دینار', stock: 'موجود (۱۸۰ عدد)', status: 'ACTIVE' },
{ id: 3, operator: 'Korek', country: 'IQ', title: 'سیم‌کارت توریستی کورک تلکام ۵ گیگابایت', duration: '۱۰ روزه', price: '8,000 دینار', stock: 'موجود (۹۵ عدد)', status: 'ACTIVE' },
{ id: 4, operator: 'STC Saudi', country: 'SA', title: 'سیم‌کارت عمره STC با ۲۰ گیگابایت دیتای پرسرعت', duration: '۱۴ روزه', price: '65 ریال سعودی', stock: 'موجود (۵۰ عدد)', status: 'ACTIVE' },
{ id: 5, operator: 'Mobily', country: 'SA', title: 'سیم‌کارت حرمین موبایلی ویژه زائرین مکه و مدینه', duration: '۲۱ روزه', price: '80 ریال سعودی', stock: 'موجود (۳۰ عدد)', status: 'ACTIVE' },
]
const filtered = samplePackages.filter((p) => {
if (operatorFilter !== 'ALL' && !p.operator.toLowerCase().includes(operatorFilter.toLowerCase())) return false
if (searchTerm && !p.title.includes(searchTerm) && !p.operator.includes(searchTerm)) return false
return true
})
return (
<div className="space-y-6">
{/* آمار سیم‌کارت */}
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4">
<div className="rounded-2xl border border-amber-500/20 bg-amber-500/5 p-4">
<span className="text-xs text-amber-600 dark:text-amber-400 font-medium">سیمکارتهای تحویلشده</span>
<div className="mt-2 text-2xl font-black text-amber-600 dark:text-amber-400 font-mono">{toFa(524)}</div>
</div>
<div className="rounded-2xl border border-sky-500/20 bg-sky-500/5 p-4">
<span className="text-xs text-sky-600 dark:text-sky-400 font-medium">اپراتورهای پشتیبانیشده</span>
<div className="mt-2 text-2xl font-black text-sky-600 dark:text-sky-400 font-mono">{toFa(5)}</div>
</div>
<div className="rounded-2xl border border-emerald-500/20 bg-emerald-500/5 p-4">
<span className="text-xs text-emerald-600 dark:text-emerald-400 font-medium">سفارشهای در انتظار فعالسازی</span>
<div className="mt-2 text-2xl font-black text-emerald-600 dark:text-emerald-400 font-mono">{toFa(12)}</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4">
<span className="text-xs text-grey-3 font-medium">میانگین زمان تحویل در مرز/فرودگاه</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono">۱۰ دقیقه</div>
</div>
</div>
{/* ابزارها و فیلتر */}
<div className="flex flex-col gap-3 rounded-2xl border border-border-soft bg-card/40 p-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2 flex-wrap">
<Button
size="sm"
variant={operatorFilter === 'ALL' ? 'secondary' : 'ghost'}
onClick={() => setOperatorFilter('ALL')}
className="text-xs h-8"
>
همه اپراتورها
</Button>
<Button
size="sm"
variant={operatorFilter === 'Zain' ? 'secondary' : 'ghost'}
onClick={() => setOperatorFilter('Zain')}
className="text-xs h-8"
>
🇮🇶 زین (Zain)
</Button>
<Button
size="sm"
variant={operatorFilter === 'Asiacell' ? 'secondary' : 'ghost'}
onClick={() => setOperatorFilter('Asiacell')}
className="text-xs h-8"
>
🇮🇶 آسیاسل (Asiacell)
</Button>
<Button
size="sm"
variant={operatorFilter === 'STC' ? 'secondary' : 'ghost'}
onClick={() => setOperatorFilter('STC')}
className="text-xs h-8"
>
🇸🇦 STC سعودی
</Button>
</div>
<div className="relative min-w-[200px]">
<Ic name="search" className="absolute right-3 top-1/2 -translate-y-1/2 size-3.5 text-grey-3" />
<Input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="جستجوی بسته یا اپراتور..."
className="h-8 pr-8 text-xs bg-surface-base"
/>
</div>
</div>
{/* جدول بسته‌های سیم‌کارت */}
<div className="overflow-hidden rounded-2xl border border-border-soft bg-card/40 shadow-xs">
<table className="w-full text-start text-xs">
<thead className="border-b border-border-soft bg-card/60 text-grey-3">
<tr>
<th className="p-3.5 text-start font-bold">شناسه</th>
<th className="p-3.5 text-start font-bold">اپراتور و کشور</th>
<th className="p-3.5 text-start font-bold">عنوان بسته اینترنت و مکالمه</th>
<th className="p-3.5 text-start font-bold">مدت اعتبار</th>
<th className="p-3.5 text-start font-bold">قیمت رسمی</th>
<th className="p-3.5 text-start font-bold">موجودی انبار</th>
<th className="p-3.5 text-start font-bold">وضعیت</th>
</tr>
</thead>
<tbody className="divide-y divide-border-soft">
{filtered.map((item) => (
<tr key={item.id} className="hover:bg-card/70 transition">
<td className="p-3.5 font-mono font-bold text-grey-3">#{item.id}</td>
<td className="p-3.5">
<div className="flex items-center gap-2">
<span className="font-bold text-foreground">{item.operator}</span>
<Badge variant="outline" className="text-[10px]">{item.country}</Badge>
</div>
</td>
<td className="p-3.5 font-bold text-foreground">{item.title}</td>
<td className="p-3.5 text-grey-2">{item.duration}</td>
<td className="p-3.5 font-mono font-bold text-amber-600 dark:text-amber-400">{item.price}</td>
<td className="p-3.5 text-grey-3">{item.stock}</td>
<td className="p-3.5">
<Badge className="bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20 text-[10px]">
فعال در اپلیکیشن
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// ۲. راهنمای زائر (Zayer Guide)
// ─────────────────────────────────────────────────────────────────────────────
function ZayerGuideServiceView({ service }: { service: ServiceMeta }) {
const [cityFilter, setCityFilter] = useState('ALL')
const places = [
{ id: 1, title: 'حرم مطهر امام علی (ع)', city: 'نجف اشرف', category: 'اماکن مقدس', rating: '5.0', visits: 18400, country: 'عراق' },
{ id: 2, title: 'حرم مطهر امام حسین (ع) و حضرت عباس (ع)', city: 'کربلای معلی', category: 'اماکن مقدس', rating: '5.0', visits: 24500, country: 'عراق' },
{ id: 3, title: 'مسجد کوفه و مسجد سهله', city: 'کوفه', category: 'مساجد تاریخی', rating: '4.9', visits: 9800, country: 'عراق' },
{ id: 4, title: 'مسجد الحرام و کعبه مشرفه', city: 'مکه مکرمه', category: 'اماکن مقدس', rating: '5.0', visits: 32000, country: 'عربستان' },
{ id: 5, title: 'مسجد النبی (ص) و بقیع', city: 'مدینه منوره', category: 'اماکن مقدس', rating: '5.0', visits: 29000, country: 'عربستان' },
{ id: 6, title: 'حرم مطهر حضرت زینب (س)', city: 'دمشق', category: 'اماکن مقدس', rating: '4.9', visits: 6400, country: 'سوریه' },
]
const filtered = places.filter((p) => {
if (cityFilter !== 'ALL' && !p.city.includes(cityFilter)) return false
return true
})
return (
<div className="space-y-6">
{/* آمار راهنمای زائر */}
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4">
<div className="rounded-2xl border border-sky-500/20 bg-sky-500/5 p-4">
<span className="text-xs text-sky-600 dark:text-sky-400 font-medium">اماکن و موقعیتهای ثبتشده</span>
<div className="mt-2 text-2xl font-black text-sky-600 dark:text-sky-400 font-mono">{toFa(142)}</div>
</div>
<div className="rounded-2xl border border-emerald-500/20 bg-emerald-500/5 p-4">
<span className="text-xs text-emerald-600 dark:text-emerald-400 font-medium">مواکب و مراکز امدادی فعال</span>
<div className="mt-2 text-2xl font-black text-emerald-600 dark:text-emerald-400 font-mono">{toFa(85)}</div>
</div>
<div className="rounded-2xl border border-amber-500/20 bg-amber-500/5 p-4">
<span className="text-xs text-amber-600 dark:text-amber-400 font-medium">بازدید ماهانه زائرین از نقشهها</span>
<div className="mt-2 text-2xl font-black text-amber-600 dark:text-amber-400 font-mono">{toFa('58.4K')}</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4">
<span className="text-xs text-grey-3 font-medium">دستهبندیها</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono">{toFa(8)}</div>
</div>
</div>
{/* فیلتر شهرها */}
<div className="flex items-center gap-2 overflow-x-auto pb-1">
<Button
size="sm"
variant={cityFilter === 'ALL' ? 'secondary' : 'ghost'}
onClick={() => setCityFilter('ALL')}
className="text-xs h-8"
>
همه شهرها
</Button>
<Button
size="sm"
variant={cityFilter === 'نجف' ? 'secondary' : 'ghost'}
onClick={() => setCityFilter('نجف')}
className="text-xs h-8"
>
نجف اشرف
</Button>
<Button
size="sm"
variant={cityFilter === 'کربلا' ? 'secondary' : 'ghost'}
onClick={() => setCityFilter('کربلا')}
className="text-xs h-8"
>
کربلای معلی
</Button>
<Button
size="sm"
variant={cityFilter === 'مکه' ? 'secondary' : 'ghost'}
onClick={() => setCityFilter('مکه')}
className="text-xs h-8"
>
مکه مکرمه
</Button>
<Button
size="sm"
variant={cityFilter === 'مدینه' ? 'secondary' : 'ghost'}
onClick={() => setCityFilter('مدینه')}
className="text-xs h-8"
>
مدینه منوره
</Button>
</div>
{/* لیست کارت‌های مکان‌ها */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{filtered.map((item) => (
<div key={item.id} className="rounded-2xl border border-border-soft bg-card/40 p-5 space-y-3">
<div className="flex items-center justify-between">
<Badge variant="outline" className="text-[10px]">{item.city} ({item.country})</Badge>
<span className="text-amber-500 text-xs font-bold font-mono"> {item.rating}</span>
</div>
<h3 className="text-sm font-black text-foreground">{item.title}</h3>
<div className="flex items-center justify-between text-xs text-grey-3 pt-2 border-t border-border-soft">
<span>دسته: {item.category}</span>
<span className="font-mono">{toFa(item.visits)} بازدید</span>
</div>
</div>
))}
</div>
</div>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// ۳. صرافی آنلاین و تشریفات (Online Exchange)
// ─────────────────────────────────────────────────────────────────────────────
function ExchangeServiceView({ service }: { service: ServiceMeta }) {
const [amount, setAmount] = useState('10000000')
const [fromCurr, setFromCurr] = useState('IRR')
const [toCurr, setToCurr] = useState('IQD')
const rates = [
{ pair: 'IRR / IQD', rate: '0.021', change: '+1.2%', base: '۱۰۰ هزار تومان = ۲,۱۰۰ دینار' },
{ pair: 'USD / IQD', rate: '1,530', change: '0.0%', base: '۱ دلار = ۱,۵۳۰ دینار عراق' },
{ pair: 'IRR / SAR', rate: '0.000058', change: '-0.4%', base: '۱۰۰ هزار تومان = ۵.۸ ریال سعودی' },
{ pair: 'USD / SAR', rate: '3.75', change: '0.0%', base: '۱ دلار = ۳.۷۵ ریال سعودی' },
]
const recentOrders = [
{ id: 981, user: 'محمد حسینی', from: '۵۰,۰۰۰,۰۰۰ تومان', to: '۱,۰۵۰,۰۰۰ دینار عراق', deliverType: 'تحویل نقدی در فرودگاه نجف', status: 'تایید شده و آماده تحویل' },
{ id: 982, user: 'علی کاظمی', from: '۲۰,۰۰۰,۰۰۰ تومان', to: '۴۲۰,۰۰۰ دینار عراق', deliverType: 'حواله به کارت کی‌کارت عراق', status: 'انجام شد' },
{ id: 983, user: 'سارا احمدی', from: '۱۰۰,۰۰۰,۰۰۰ تومان', to: '۵,۸۰۰ ریال سعودی', deliverType: 'تحویل نقدی در هتل مدینه', status: 'در حال بررسی' },
]
return (
<div className="space-y-6">
{/* جدول نرخ‌های لحظه‌ای ارز */}
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4">
{rates.map((r) => (
<div key={r.pair} className="rounded-2xl border border-emerald-500/20 bg-emerald-500/5 p-4">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-foreground font-mono">{r.pair}</span>
<span className="text-[11px] text-emerald-600 dark:text-emerald-400 font-bold font-mono">{r.change}</span>
</div>
<div className="mt-2 text-xl font-black text-emerald-600 dark:text-emerald-400 font-mono">{r.rate}</div>
<div className="mt-1 text-[11px] text-grey-3">{r.base}</div>
</div>
))}
</div>
{/* سفارشات اخیر ارزی */}
<div className="space-y-3">
<h3 className="text-sm font-black text-foreground">سفارشات اخیر تبدیل و تحویل ارز</h3>
<div className="overflow-hidden rounded-2xl border border-border-soft bg-card/40 shadow-xs">
<table className="w-full text-start text-xs">
<thead className="border-b border-border-soft bg-card/60 text-grey-3">
<tr>
<th className="p-3.5 text-start font-bold">کد سفارش</th>
<th className="p-3.5 text-start font-bold">زائر متقاضی</th>
<th className="p-3.5 text-start font-bold">مبلغ پرداختی</th>
<th className="p-3.5 text-start font-bold">مبلغ دریافتی مقصد</th>
<th className="p-3.5 text-start font-bold">نحوه تحویل</th>
<th className="p-3.5 text-start font-bold">وضعیت سفارش</th>
</tr>
</thead>
<tbody className="divide-y divide-border-soft">
{recentOrders.map((ord) => (
<tr key={ord.id} className="hover:bg-card/70 transition">
<td className="p-3.5 font-mono font-bold text-grey-3">#{ord.id}</td>
<td className="p-3.5 font-bold text-foreground">{ord.user}</td>
<td className="p-3.5 text-grey-2">{ord.from}</td>
<td className="p-3.5 font-bold text-emerald-600 dark:text-emerald-400">{ord.to}</td>
<td className="p-3.5 text-grey-3">{ord.deliverType}</td>
<td className="p-3.5">
<Badge className="bg-primary/15 text-primary border border-primary/20 text-[10px]">
{ord.status}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// ۴. آداب سفر و زیارت (Trip Etiquette)
// ─────────────────────────────────────────────────────────────────────────────
function TripEtiquetteServiceView({ service }: { service: ServiceMeta }) {
const rituals = [
{ id: 1, title: 'آداب تشرف به حرم امیرالمؤمنین (ع)', city: 'نجف اشرف', prayersCount: 14, audio: true },
{ id: 2, title: 'زیارت عاشورا، وارث و آداب حرم سیدالشهدا (ع)', city: 'کربلای معلی', prayersCount: 22, audio: true },
{ id: 3, title: 'اعمال و نمازهای مسجد کوفه و محراب شهادت', city: 'کوفه', prayersCount: 18, audio: true },
{ id: 4, title: 'آداب احرام، طواف و مناسک عمره مفرده', city: 'مکه مکرمه', prayersCount: 30, audio: true },
{ id: 5, title: 'زیارت جامعه کبیره و امین الله', city: 'مشترک عتبات', prayersCount: 10, audio: true },
]
return (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4">
<div className="rounded-2xl border border-violet-500/20 bg-violet-500/5 p-4">
<span className="text-xs text-violet-600 dark:text-violet-400 font-medium">مجموع زیارات و ادعیه</span>
<div className="mt-2 text-2xl font-black text-violet-600 dark:text-violet-400 font-mono">{toFa(94)}</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4">
<span className="text-xs text-grey-3 font-medium">فایلهای صوتی و صوتیمتنی</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono">{toFa(86)}</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4">
<span className="text-xs text-grey-3 font-medium">زبانهای ترجمه</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono">۵ زبان</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4">
<span className="text-xs text-grey-3 font-medium">شهرهای تحت پوشش</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono">{toFa(6)}</div>
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{rituals.map((r) => (
<div key={r.id} className="rounded-2xl border border-border-soft bg-card/40 p-5 space-y-3">
<div className="flex items-center justify-between">
<Badge variant="outline" className="text-[10px] text-violet-500 border-violet-500/30">{r.city}</Badge>
<div className="flex items-center gap-1 text-[11px] text-grey-3">
<Ic name="music" className="size-3 text-primary" />
<span>همراه با صوت</span>
</div>
</div>
<h3 className="text-base font-black text-foreground">{r.title}</h3>
<div className="flex items-center justify-between text-xs text-grey-3 pt-2 border-t border-border-soft">
<span>شامل {toFa(r.prayersCount)} فراز و دعای وارده</span>
<span className="text-primary font-bold">مشاهده متن و صوت &gt;</span>
</div>
</div>
))}
</div>
</div>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// ۵. مشاوره و پشتیبانی (Consultant)
// ─────────────────────────────────────────────────────────────────────────────
function ConsultantServiceView({ service }: { service: ServiceMeta }) {
const consultants = [
{ id: 1, name: 'دکتر علیرضا رضایی', specialty: 'پزشکی سفر، گرمازدگی و مراقبت‌های بهداشتی', status: 'آنلاین و آماده پاسخ', activeChats: 4 },
{ id: 2, name: 'حجت‌الاسلام سید هادی موسوی', specialty: 'احکام و مناسک حج، عمره و عتبات', status: 'آنلاین و آماده پاسخ', activeChats: 6 },
{ id: 3, name: 'مهندس مهدی کمالی', specialty: 'لجستیک، حمل‌ونقل، گذرنامه و امور گمشدگان', status: 'مشغول در تماس', activeChats: 2 },
]
return (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4">
<div className="rounded-2xl border border-rose-500/20 bg-rose-500/5 p-4">
<span className="text-xs text-rose-600 dark:text-rose-400 font-medium">مشاوران شیفت جاری</span>
<div className="mt-2 text-2xl font-black text-rose-600 dark:text-rose-400 font-mono">{toFa(8)}</div>
</div>
<div className="rounded-2xl border border-emerald-500/20 bg-emerald-500/5 p-4">
<span className="text-xs text-emerald-600 dark:text-emerald-400 font-medium">مشاورههای موفق امروز</span>
<div className="mt-2 text-2xl font-black text-emerald-600 dark:text-emerald-400 font-mono">{toFa(63)}</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4">
<span className="text-xs text-grey-3 font-medium">میانگین زمان انتظار زائر</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono">۴۵ ثانیه</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4">
<span className="text-xs text-grey-3 font-medium">رضایتمندی زائرین</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono">۹۸.۵٪</div>
</div>
</div>
<div className="space-y-4">
<h3 className="text-sm font-black text-foreground">کادر مشاوران تخصصی عقیله</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{consultants.map((c) => (
<div key={c.id} className="rounded-2xl border border-border-soft bg-card/40 p-5 space-y-3">
<div className="flex items-center gap-3">
<div className="size-10 rounded-full bg-rose-500/10 text-rose-500 flex items-center justify-center font-bold">
<Ic name="headset" className="size-5" />
</div>
<div>
<h4 className="text-sm font-black text-foreground">{c.name}</h4>
<p className="text-[11px] text-grey-3">{c.specialty}</p>
</div>
</div>
<div className="flex items-center justify-between text-xs pt-2 border-t border-border-soft">
<Badge className="bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20 text-[10px]">
{c.status}
</Badge>
<span className="text-grey-3 font-mono">{toFa(c.activeChats)} گفتگوی فعال</span>
</div>
</div>
))}
</div>
</div>
</div>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// ۶. فروشگاه معصومه (Massoumah Store)
// ─────────────────────────────────────────────────────────────────────────────
function StoreServiceView({ service }: { service: ServiceMeta }) {
const products = [
{ id: 1, title: 'تسبیح تربت اصل کربلای معلی (۱۰۱ دانه)', category: 'سوغات متبرک', price: '15,000 دینار', stock: 120, status: 'موجود' },
{ id: 2, title: 'کتابچه نفیس ادعیه و زیارات عتبات چرم اصل', category: 'کتب ادعیه', price: '8,000 دینار', stock: 85, status: 'موجود' },
{ id: 3, title: 'عطر طبیعی حرمین شریفین و زعفرانی', category: 'عطر و بویا', price: '25,000 دینار', stock: 40, status: 'موجود' },
{ id: 4, title: 'سجاده و جانماز ترمه ابریشمی متبرک', category: 'ملزومات نماز', price: '35,000 دینار', stock: 65, status: 'موجود' },
]
return (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4">
<div className="rounded-2xl border border-teal-500/20 bg-teal-500/5 p-4">
<span className="text-xs text-teal-600 dark:text-teal-400 font-medium">کالاهای فعال در فروشگاه</span>
<div className="mt-2 text-2xl font-black text-teal-600 dark:text-teal-400 font-mono">{toFa(48)}</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4">
<span className="text-xs text-grey-3 font-medium">سفارشهای در حال ارسال</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono">{toFa(19)}</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4">
<span className="text-xs text-grey-3 font-medium">دستهبندیهای فروشگاه</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono">{toFa(6)}</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4">
<span className="text-xs text-grey-3 font-medium">امکان ارسال به هتلهای عراق</span>
<div className="mt-2 text-2xl font-black text-emerald-600 font-mono">فعال</div>
</div>
</div>
<div className="overflow-hidden rounded-2xl border border-border-soft bg-card/40 shadow-xs">
<table className="w-full text-start text-xs">
<thead className="border-b border-border-soft bg-card/60 text-grey-3">
<tr>
<th className="p-3.5 text-start font-bold">شناسه</th>
<th className="p-3.5 text-start font-bold">عنوان محصول</th>
<th className="p-3.5 text-start font-bold">دستهبندی</th>
<th className="p-3.5 text-start font-bold">قیمت واحد</th>
<th className="p-3.5 text-start font-bold">موجودی انبار</th>
<th className="p-3.5 text-start font-bold">وضعیت عرضه</th>
</tr>
</thead>
<tbody className="divide-y divide-border-soft">
{products.map((p) => (
<tr key={p.id} className="hover:bg-card/70 transition">
<td className="p-3.5 font-mono font-bold text-grey-3">#{p.id}</td>
<td className="p-3.5 font-bold text-foreground">{p.title}</td>
<td className="p-3.5 text-grey-2">{p.category}</td>
<td className="p-3.5 font-mono font-bold text-teal-600 dark:text-teal-400">{p.price}</td>
<td className="p-3.5 text-grey-3">{toFa(p.stock)} عدد</td>
<td className="p-3.5">
<Badge className="bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20 text-[10px]">
{p.status}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// ۷. تخمین هزینه سفر (Cost Estimate)
// ─────────────────────────────────────────────────────────────────────────────
function CostEstimateServiceView({ service }: { service: ServiceMeta }) {
const estimateCategories = [
{ id: 1, title: 'هتل و اقامتگاه (نجف / کربلا / مکه / مدینه)', standard: 'شبی ۲۵ الی ۴۰ دلار', vip: 'شبی ۶۰ الی ۱۲۰ دلار', notes: 'شامل صبحانه و اینترنت' },
{ id: 2, title: 'ترانسفر بین شهری و فرودگاهی (ون و سواری)', standard: 'هر مسیر ۱۰ الی ۱۵ دلار', vip: 'خودروی اختصاصی ۳۰ الی ۵۰ دلار', notes: 'مسیر نجف به کربلا، بغداد' },
{ id: 3, title: 'خوراک و پذیرایی روزانه', standard: 'روزانه ۱۰ الی ۱۵ دلار', vip: 'روزانه ۲۵ الی ۴۰ دلار', notes: 'رستوران‌های منتخب و هتل' },
{ id: 4, title: 'اینترنت و ارتباطات سیم‌کارت', standard: '۱۰ الی ۱۵ دلار برای کل سفر', vip: '۲۵ دلار بسته نامحدود', notes: 'سیم‌کارت‌های زین و آسیاسل' },
]
return (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4">
<div className="rounded-2xl border border-indigo-500/20 bg-indigo-500/5 p-4">
<span className="text-xs text-indigo-600 dark:text-indigo-400 font-medium">محاسبات انجام شده این ماه</span>
<div className="mt-2 text-2xl font-black text-indigo-600 dark:text-indigo-400 font-mono">{toFa('12.8K')}</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4">
<span className="text-xs text-grey-3 font-medium">دقت میانگین برآورد هوشمند</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono">۹۵٪</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4">
<span className="text-xs text-grey-3 font-medium">پکیجهای پیشفرض اقتصادی و ویژه</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono">{toFa(8)}</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4">
<span className="text-xs text-grey-3 font-medium">بروزرسانی نرخها</span>
<div className="mt-2 text-2xl font-black text-emerald-600 font-mono">امروز</div>
</div>
</div>
<div className="overflow-hidden rounded-2xl border border-border-soft bg-card/40 shadow-xs">
<table className="w-full text-start text-xs">
<thead className="border-b border-border-soft bg-card/60 text-grey-3">
<tr>
<th className="p-3.5 text-start font-bold">بخش هزینه</th>
<th className="p-3.5 text-start font-bold">برآورد بسته اقتصادی (Standard)</th>
<th className="p-3.5 text-start font-bold">برآورد بسته ویژه (VIP)</th>
<th className="p-3.5 text-start font-bold">توضیحات و نکات</th>
</tr>
</thead>
<tbody className="divide-y divide-border-soft">
{estimateCategories.map((c) => (
<tr key={c.id} className="hover:bg-card/70 transition">
<td className="p-3.5 font-bold text-foreground">{c.title}</td>
<td className="p-3.5 font-mono text-grey-2">{c.standard}</td>
<td className="p-3.5 font-mono font-bold text-indigo-600 dark:text-indigo-400">{c.vip}</td>
<td className="p-3.5 text-grey-3">{c.notes}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

831
src/features/tours/OngoingToursPage.tsx

@ -0,0 +1,831 @@
import React, { useState, useEffect, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { Ic } from '@/icons'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { fetchTours, fetchTourItineraries } from './services/tour-api'
import type { TourListItem, TourItineraryItem } from './types'
import { TourStatusBadge, CountryFlagBadge } from './components/TourStatusBadge'
import FilterSelect from '@/components/shared/FilterSelect'
import { Shimmer } from '@/components/shared/Shimmer'
import { toFa } from '@/lib/utils'
interface TourWithItineraries {
tour: TourListItem
itineraries: TourItineraryItem[]
isLoadingItinerary: boolean
itineraryError: string | null
hoursToStart: number
isCurrentlyRunning: boolean
isImminent: boolean
currentStepIndex: number
progressPercent: number
timelineStatus: 'TRAVELING' | 'IMMINENT' | 'UPCOMING' | 'FINISHED'
}
export default function OngoingToursPage() {
const navigate = useNavigate()
const [tours, setTours] = useState<TourListItem[]>([])
const [itinerariesMap, setItinerariesMap] = useState<Record<number, { data: TourItineraryItem[]; loading: boolean; error: string | null }>>({})
const [isLoading, setIsLoading] = useState(true)
const [isRefreshing, setIsRefreshing] = useState(false)
const [error, setError] = useState<string | null>(null)
// فیلترها و جستجو
const [activeTab, setActiveTab] = useState<'ALL' | 'TRAVELING' | 'IMMINENT'>('ALL')
const [searchTerm, setSearchTerm] = useState('')
const [countryFilter, setCountryFilter] = useState('ALL')
const [expandedTourIds, setExpandedTourIds] = useState<Record<number, boolean>>({})
// لود فهرست همه تورها
const loadToursData = async (showRefresh = false) => {
if (showRefresh) setIsRefreshing(true)
else setIsLoading(true)
setError(null)
try {
const res = await fetchTours({ limit: 100 })
const rawTours = res.results || []
setTours(rawTours)
// باز کردن پیش‌فرض کارت اولین تورهای در حال اجرا
const initialExpanded: Record<number, boolean> = {}
rawTours.forEach((t) => {
initialExpanded[t.id] = true
})
setExpandedTourIds(initialExpanded)
} catch (err: any) {
setError(err.message || 'خطا در برقراری ارتباط با وب‌سرویس تورها')
} finally {
setIsLoading(false)
setIsRefreshing(false)
}
}
useEffect(() => {
loadToursData()
}, [])
// محاسبه وضعیت زمانی و تطبیق منطق ۲۴ تا ۴۸ ساعت مانده به شروع و تورهای در حال اجرا
const processedTours: TourWithItineraries[] = useMemo(() => {
const now = new Date()
const nowMs = now.getTime()
return tours
.map((tour) => {
// ساعت شروع: ابتدای روز؛ ساعت پایان: انتهای روز
const startMs = new Date(tour.started_at).setHours(0, 0, 0, 0)
const endMs = new Date(tour.ended_at).setHours(23, 59, 59, 999)
const hoursToStart = Math.round((startMs - nowMs) / (1000 * 3600))
const isCurrentlyRunning = (nowMs >= startMs && nowMs <= endMs) || tour.trip_status === 'TRAVELING'
// نزدیک به شروع: بین ۰ تا ۴۸ ساعت مانده به شروع
const isImminent = !isCurrentlyRunning && hoursToStart > 0 && hoursToStart <= 48
let timelineStatus: 'TRAVELING' | 'IMMINENT' | 'UPCOMING' | 'FINISHED' = 'UPCOMING'
if (isCurrentlyRunning) {
timelineStatus = 'TRAVELING'
} else if (isImminent) {
timelineStatus = 'IMMINENT'
} else if (nowMs > endMs) {
timelineStatus = 'FINISHED'
}
const itState = itinerariesMap[tour.id]
const itineraries = itState?.data || []
const isLoadingItinerary = itState?.loading || false
const itineraryError = itState?.error || null
// تعیین مرحله جاری سفر (Current Itinerary Step)
let currentStepIndex = -1
if (itineraries.length > 0) {
// بررسی اول: آیا مرحله‌ای صراحتاً وضعیت 'In Progress' یا ۲ دارد؟
const explicitCurrent = itineraries.findIndex(
(it) => it.status === 'In Progress' || it.status === 2 || it.status === '2'
)
if (explicitCurrent !== -1) {
currentStepIndex = explicitCurrent
} else {
// بررسی دوم: محاسبه بر اساس زمان حال و تاریخ هر مرحله
let foundByTime = -1
for (let i = 0; i < itineraries.length; i++) {
const itStart = itineraries[i].started_at ? new Date(itineraries[i].started_at).getTime() : 0
const itEnd = itineraries[i].ended_at
? new Date(itineraries[i].ended_at!).getTime()
: itStart + 24 * 3600 * 1000
if (nowMs >= itStart && nowMs <= itEnd) {
foundByTime = i
break
}
}
if (foundByTime !== -1) {
currentStepIndex = foundByTime
} else if (isCurrentlyRunning) {
// اگر در حال سفر است ولی زمان دقیق ثبت نشده، اولین مرحله در انتظار یا ابتدا را انتخاب کن
const firstNotStarted = itineraries.findIndex(
(it) => it.status === 'Not Started' || it.status === 3 || it.status === '3'
)
currentStepIndex = firstNotStarted !== -1 ? firstNotStarted : 0
} else if (isImminent) {
// تور به زودی شروع می‌شود، مرحله ۱ مرحله ورودی است
currentStepIndex = 0
}
}
}
// درصد پیشرفت تور
let progressPercent = 0
if (itineraries.length > 0) {
if (timelineStatus === 'FINISHED') {
progressPercent = 100
} else if (currentStepIndex >= 0) {
progressPercent = Math.round(((currentStepIndex + 1) / itineraries.length) * 100)
}
} else if (isCurrentlyRunning) {
const totalDuration = Math.max(1, endMs - startMs)
const elapsed = Math.max(0, nowMs - startMs)
progressPercent = Math.min(100, Math.round((elapsed / totalDuration) * 100))
}
return {
tour,
itineraries,
isLoadingItinerary,
itineraryError,
hoursToStart,
isCurrentlyRunning,
isImminent,
currentStepIndex,
progressPercent,
timelineStatus,
}
})
.filter((item) => {
// معیار قرار گرفتن در این صفحه:
// ۱. تور در حال حاضر TRAVELING باشد یا تاریخ امروز در بازه شروع و پایان باشد
// ۲. یا زمان شروع آن در بازه نزدیک (۲۴ الی ۴۸ ساعت آینده) باشد
return item.isCurrentlyRunning || item.isImminent || item.tour.trip_status === 'TRAVELING'
})
}, [tours, itinerariesMap])
// لود خودکار Itineraryهای تورهای در حال برگزاری
useEffect(() => {
if (processedTours.length === 0) return
processedTours.forEach((item) => {
const tourId = item.tour.id
if (!itinerariesMap[tourId]) {
setItinerariesMap((prev) => ({
...prev,
[tourId]: { data: [], loading: true, error: null },
}))
fetchTourItineraries(tourId)
.then((res) => {
setItinerariesMap((prev) => ({
...prev,
[tourId]: { data: res.results || [], loading: false, error: null },
}))
})
.catch((err: any) => {
setItinerariesMap((prev) => ({
...prev,
[tourId]: {
data: [],
loading: false,
error: err.message || 'خطا در دریافت برنامه سفر',
},
}))
})
}
})
}, [processedTours])
// فیلتر و جستجو
const filteredList = useMemo(() => {
return processedTours.filter((item) => {
// فیلتر تب وضعیت زمانی
if (activeTab === 'TRAVELING' && !item.isCurrentlyRunning) return false
if (activeTab === 'IMMINENT' && !item.isImminent) return false
// فیلتر کشور
if (countryFilter !== 'ALL' && item.tour.destination_country !== countryFilter) return false
// جستجو
if (searchTerm.trim()) {
const query = searchTerm.toLowerCase()
const matchTitle = item.tour.title.toLowerCase().includes(query)
const matchSlug = item.tour.slug.toLowerCase().includes(query)
if (!matchTitle && !matchSlug) return false
}
return true
})
}, [processedTours, activeTab, countryFilter, searchTerm])
// شاخص‌های آماری بالای صفحه
const stats = useMemo(() => {
const totalOngoing = processedTours.length
const currentlyTraveling = processedTours.filter((t) => t.isCurrentlyRunning).length
const startingSoon = processedTours.filter((t) => t.isImminent).length
const distinctCountries = new Set(processedTours.map((t) => t.tour.destination_country).filter(Boolean)).size
return { totalOngoing, currentlyTraveling, startingSoon, distinctCountries }
}, [processedTours])
const toggleExpand = (tourId: number) => {
setExpandedTourIds((prev) => ({
...prev,
[tourId]: !prev[tourId],
}))
}
// قالب‌بندی زمان و تاریخ به فرمت خوانا
const formatDateTime = (dateStr?: string | null) => {
if (!dateStr) return '-'
try {
const d = new Date(dateStr)
return d.toLocaleDateString('fa-IR', {
month: 'long',
day: 'numeric',
weekday: 'short',
})
} catch {
return dateStr
}
}
return (
<div className="space-y-6 pt-2 pb-14">
{/* سربرگ صفحه تورهای در حال برگزاری */}
<div className="flex flex-col gap-4 rounded-2xl border border-border-soft bg-card/40 p-6 shadow-sm sm:flex-row sm:items-center sm:justify-between backdrop-blur-xs">
<div className="space-y-1">
<div className="flex items-center gap-2.5">
<div className="flex size-9 items-center justify-center rounded-xl bg-amber-500/10 text-amber-500">
<Ic name="calendar" className="size-5" />
</div>
<h1 className="text-xl font-black text-foreground">تورهای در حال برگزاری</h1>
<Badge
variant="outline"
className="border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400 font-bold"
>
{stats.totalOngoing} تور فعال و در جریان
</Badge>
</div>
<p className="text-xs text-grey-3">
پایش زنده وضعیت سفرهای جاری، زمانبندی روزانه (Itinerary)، موقعیت مرحله فعلی و وضعیت مسافران
</p>
</div>
<div className="flex flex-wrap items-center gap-2.5">
{/* لینک سریع به لیست همه تورها */}
<Button
variant="outline"
size="sm"
onClick={() => navigate('/tours')}
className="border-border-soft hover:bg-card/70 text-xs font-bold gap-1.5"
>
<Ic name="map" className="size-3.5 text-primary" />
مدیریت تمام تورها
</Button>
{/* دکمه بروزرسانی زنده */}
<Button
variant="outline"
size="sm"
onClick={() => loadToursData(true)}
disabled={isRefreshing}
className="border-border-soft hover:bg-card/70 text-xs font-bold gap-1.5"
>
<Ic name="refresh" className={`size-3.5 ${isRefreshing ? 'animate-spin' : ''}`} />
بروزرسانی دادهها
</Button>
</div>
</div>
{/* کارت‌های شاخص‌های کلیدی آماری (KPIs) */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3.5">
<Card className="border-border/50 shadow-xs">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] text-muted-foreground font-medium">کل تورهای در جریان</p>
{isLoading ? (
<Skeleton className="mt-2 h-7 w-12 rounded-lg" />
) : (
<p className="text-xl font-black mt-1 font-mono text-foreground">{stats.totalOngoing}</p>
)}
</div>
<div className="size-9 rounded-xl bg-muted/60 flex items-center justify-center text-muted-foreground">
<Ic name="calendar" className="size-4" />
</div>
</CardContent>
</Card>
<Card className="border-sky-500/20 bg-sky-500/5 shadow-xs">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] text-sky-600 dark:text-sky-400 font-medium">هماکنون در حال سفر</p>
{isLoading ? (
<Skeleton className="mt-2 h-7 w-12 rounded-lg" />
) : (
<p className="text-xl font-black mt-1 font-mono text-sky-600 dark:text-sky-400">
{stats.currentlyTraveling}
</p>
)}
</div>
<div className="size-9 rounded-xl bg-sky-500/15 text-sky-600 dark:text-sky-400 flex items-center justify-center">
<span className="relative flex size-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-sky-400 opacity-75"></span>
<span className="relative inline-flex rounded-full size-2 bg-sky-500"></span>
</span>
</div>
</CardContent>
</Card>
<Card className="border-emerald-500/20 bg-emerald-500/5 shadow-xs">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 font-medium">نزدیک به شروع (۲۴-۴۸ ساعت)</p>
{isLoading ? (
<Skeleton className="mt-2 h-7 w-12 rounded-lg" />
) : (
<p className="text-xl font-black mt-1 font-mono text-emerald-600 dark:text-emerald-400">
{stats.startingSoon}
</p>
)}
</div>
<div className="size-9 rounded-xl bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 flex items-center justify-center">
<Ic name="clock" className="size-4" />
</div>
</CardContent>
</Card>
<Card className="border-border/50 shadow-xs">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] text-muted-foreground font-medium">مقاصد در حال میزبانی</p>
{isLoading ? (
<Skeleton className="mt-2 h-7 w-12 rounded-lg" />
) : (
<p className="text-xl font-black mt-1 font-mono text-foreground">{stats.distinctCountries}</p>
)}
</div>
<div className="size-9 rounded-xl bg-muted/60 flex items-center justify-center text-muted-foreground">
<Ic name="globe" className="size-4" />
</div>
</CardContent>
</Card>
</div>
{/* نوار فیلتر و تب‌های زمانی */}
<div className="flex flex-col gap-3 rounded-2xl border border-border-soft bg-card/40 p-4 sm:flex-row sm:items-center sm:justify-between shadow-xs">
{/* تب‌های وضعیت زمانی */}
<div className="flex flex-wrap items-center gap-1.5">
<Button
variant={activeTab === 'ALL' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setActiveTab('ALL')}
className={`text-xs font-bold rounded-xl h-8 px-3 ${
activeTab === 'ALL' ? 'bg-primary/15 text-primary border border-primary/20' : 'text-grey-3'
}`}
>
همه تورهای در جریان ({toFa(processedTours.length)})
</Button>
<Button
variant={activeTab === 'TRAVELING' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setActiveTab('TRAVELING')}
className={`text-xs font-bold rounded-xl h-8 px-3 ${
activeTab === 'TRAVELING' ? 'bg-sky-500/15 text-sky-500 border border-sky-500/20' : 'text-grey-3'
}`}
>
هماکنون در حال سفر ({toFa(stats.currentlyTraveling)})
</Button>
<Button
variant={activeTab === 'IMMINENT' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setActiveTab('IMMINENT')}
className={`text-xs font-bold rounded-xl h-8 px-3 ${
activeTab === 'IMMINENT'
? 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20'
: 'text-grey-3'
}`}
>
نزدیک به شروع ۲۴-۴۸ ساعت ({toFa(stats.startingSoon)})
</Button>
</div>
{/* فیلتر مقصد و جستجو */}
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-[200px] flex-1 sm:w-64 sm:flex-none">
<Ic name="search" className="absolute right-3 top-1/2 -translate-y-1/2 size-3.5 text-grey-3" />
<Input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="جستجوی عنوان یا اسلاگ تور..."
className="h-8 pr-8 text-xs bg-surface-base border-border-soft"
/>
</div>
<FilterSelect
value={countryFilter}
onChange={setCountryFilter}
options={[
{ value: 'ALL', label: 'همه مقاصد' },
{ value: 'IQ', label: '🇮🇶 عراق' },
{ value: 'IR', label: '🇮🇷 ایران' },
{ value: 'SA', label: '🇸🇦 عربستان' },
{ value: 'SY', label: '🇸🇾 سوریه' },
]}
allValue="ALL"
size="sm"
className="w-32"
/>
</div>
</div>
{/* وضعیت لودینگ استاندارد Shimmer */}
{isLoading ? (
<Shimmer withCard cardHeaderTitle="تورهای در حال برگزاری" cardHeaderIcon="calendar" rows={6} />
) : error ? (
/* وضعیت خطا */
<div className="rounded-2xl border border-rose-500/20 bg-rose-500/5 p-12 text-center text-rose-500">
<Ic name="alert" className="mx-auto size-10 mb-3" />
<h3 className="text-base font-bold">خطا در بارگذاری تورهای در حال برگزاری</h3>
<p className="mt-1 text-xs text-grey-3 max-w-md mx-auto">{error}</p>
<Button
variant="outline"
size="sm"
onClick={() => loadToursData()}
className="mt-5 border-rose-500/30 text-rose-500 hover:bg-rose-500/10"
>
تلاش مجدد
</Button>
</div>
) : filteredList.length === 0 ? (
/* وضعیت خالی */
<div className="rounded-2xl border border-dashed border-border-soft bg-card/20 p-16 text-center">
<div className="mx-auto flex size-14 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Ic name="calendar" className="size-7" />
</div>
<h3 className="mt-4 text-sm font-bold text-foreground">توری در حال برگزاری یا نزدیک به شروع یافت نشد</h3>
<p className="mt-1 text-xs text-grey-3 max-w-md mx-auto">
در بازه زمانی جاری (هماکنون یا ۲۴ تا ۴۸ ساعت آینده)، هیچ توری مطابق با فیلترهای انتخابی شما در حال
برگزاری نیست.
</p>
<Button
variant="outline"
size="sm"
onClick={() => navigate('/tours')}
className="mt-5 border-border-soft text-xs font-bold"
>
مشاهده فهرست همه تورها
</Button>
</div>
) : (
/* فهرست کارت‌های تورهای در حال برگزاری با برنامه‌های سفر گام‌به‌گام (Itinerary Stepper) */
<div className="space-y-6">
{filteredList.map((item) => {
const {
tour,
itineraries,
isLoadingItinerary,
itineraryError,
isCurrentlyRunning,
isImminent,
hoursToStart,
currentStepIndex,
progressPercent,
} = item
const isExpanded = Boolean(expandedTourIds[tour.id])
const imgUrl =
tour.image?.image_url?.md ||
tour.image?.image_url?.medium ||
tour.image?.image_url?.lg ||
tour.image?.image_url?.large ||
tour.image?.image_url?.original ||
'/placeholder.jpg'
return (
<div
key={tour.id}
className="overflow-hidden rounded-2xl border border-border-soft bg-card/40 shadow-xs transition hover:border-primary/40 backdrop-blur-xs"
>
{/* بخش ۱: سربرگ کارت تور و مشخصات کلیدی */}
<div className="p-5 sm:p-6 border-b border-border-soft bg-card/60">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
{/* تصویر، عنوان و کشور */}
<div className="flex items-start gap-4 min-w-0">
<div className="relative size-20 sm:size-24 shrink-0 overflow-hidden rounded-2xl border border-border-soft bg-surface-base shadow-2xs">
<img
src={imgUrl}
alt={tour.title}
className="h-full w-full object-cover"
onError={(e) => {
;(e.target as HTMLImageElement).src =
'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 200 200" fill="%2318181b"><rect width="200" height="200"/><text x="50%" y="50%" fill="%2371717a" dominant-baseline="middle" text-anchor="middle" font-size="12" font-family="sans-serif">Aqila</text></svg>'
}}
/>
</div>
<div className="min-w-0 space-y-1.5 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-mono font-bold text-grey-3">#{tour.id}</span>
<CountryFlagBadge countryCode={tour.destination_country} />
{/* وضعیت زمانی زنده */}
{isCurrentlyRunning ? (
<span className="inline-flex items-center gap-1.5 rounded-full bg-sky-500/15 px-2.5 py-0.5 text-xs font-bold text-sky-500">
<span className="relative flex size-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-sky-400 opacity-75"></span>
<span className="relative inline-flex rounded-full size-2 bg-sky-500"></span>
</span>
هماکنون در حال برگزاری
</span>
) : isImminent ? (
<span className="inline-flex items-center gap-1.5 rounded-full bg-amber-500/15 px-2.5 py-0.5 text-xs font-bold text-amber-600 dark:text-amber-400">
<Ic name="clock" className="size-3" />
آمادهسازی شروع ({toFa(Math.max(1, hoursToStart))} ساعت مانده)
</span>
) : (
<TourStatusBadge status={tour.trip_status} type="trip" />
)}
</div>
<h2 className="text-base sm:text-lg font-black text-foreground leading-snug">
{tour.title}
</h2>
<div className="flex flex-wrap items-center gap-3 text-xs text-grey-3 font-mono" dir="ltr">
<div className="flex items-center gap-1">
<Ic name="calendar" className="size-3.5 text-primary" />
<span>
{tour.started_at} ~ {tour.ended_at}
</span>
</div>
<span>|</span>
<div className="flex items-center gap-1">
<Ic name="tag" className="size-3.5 text-grey-3" />
<span>{tour.slug}</span>
</div>
</div>
</div>
</div>
{/* دکمه‌ها و خلاصه پیشرفت مرحله */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 lg:self-center shrink-0">
{/* نوار کوچک پیشرفت مرحله */}
{itineraries.length > 0 && (
<div className="rounded-xl border border-border-soft bg-surface-base p-2.5 min-w-[200px] space-y-1.5">
<div className="flex items-center justify-between text-xs">
<span className="text-grey-3 text-[11px]">وضعیت اجرای برنامه:</span>
<span className="font-bold text-primary font-mono">{toFa(progressPercent)}%</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-border-soft">
<div
className="h-full rounded-full bg-gradient-to-r from-primary to-amber-500 transition-all duration-500"
style={{ width: `${progressPercent}%` }}
/>
</div>
<div className="text-[10px] text-grey-3 truncate">
{currentStepIndex >= 0 ? (
<>
مرحله فعلی: <strong className="text-foreground">{toFa(currentStepIndex + 1)}</strong> از{' '}
<strong className="text-foreground">{toFa(itineraries.length)}</strong>
</>
) : (
`${toFa(itineraries.length)} مرحله ثبت‌شده`
)}
</div>
</div>
)}
{/* دکمه باز/بسته کردن تایم‌لاین Itinerary */}
<Button
variant="secondary"
size="sm"
onClick={() => toggleExpand(tour.id)}
className="rounded-xl text-xs font-bold gap-1.5 h-10 px-4"
>
<Ic name="list" className="size-4 text-primary" />
<span>{isExpanded ? 'بستن برنامه سفر' : 'مشاهده برنامه سفر (Itinerary)'}</span>
<Ic
name={isExpanded ? 'chevUp' : 'chevDown'}
className="size-3.5 text-grey-3 transition-transform"
/>
</Button>
</div>
</div>
</div>
{/* بخش ۲: بدنه Itineraryها و تایم‌لاین زنده مراحل سفر */}
{isExpanded && (
<div className="p-5 sm:p-6 space-y-6">
{/* اطلاعات مرحله جاری (Current Active Step Callout) */}
{itineraries.length > 0 && currentStepIndex >= 0 && (
<div className="relative overflow-hidden rounded-2xl border border-primary/30 bg-gradient-to-r from-primary/10 via-amber-500/5 to-transparent p-4 sm:p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="relative flex size-2.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
<span className="relative inline-flex rounded-full size-2.5 bg-primary"></span>
</span>
<span className="text-xs font-black text-primary">
مرحله جاری تور (هماکنون در حال برگزاری):
</span>
</div>
<h3 className="text-sm sm:text-base font-black text-foreground">
{toFa(currentStepIndex + 1)}. {itineraries[currentStepIndex]?.title}
</h3>
<p className="text-xs text-grey-2 leading-relaxed max-w-3xl">
{itineraries[currentStepIndex]?.summary}
</p>
</div>
<div className="text-start sm:text-end shrink-0 text-xs font-mono text-grey-3">
<div className="font-bold text-foreground">
{formatDateTime(itineraries[currentStepIndex]?.started_at)}
</div>
{itineraries[currentStepIndex]?.ended_at && (
<div className="text-[11px]">
تا {formatDateTime(itineraries[currentStepIndex]?.ended_at)}
</div>
)}
</div>
</div>
</div>
)}
{/* وضعیت لودینگ Itinerary */}
{isLoadingItinerary ? (
<Shimmer rows={3} withCard={false} />
) : itineraryError ? (
<div className="rounded-xl border border-rose-500/20 bg-rose-500/5 p-4 text-center text-xs text-rose-500">
{itineraryError}
</div>
) : itineraries.length === 0 ? (
<div className="rounded-xl border border-dashed border-border-soft p-8 text-center text-grey-3">
<Ic name="calendar" className="mx-auto size-7 mb-2 opacity-50" />
<p className="text-xs">برنامه زمانبندی روزانهای (Itinerary) برای این تور ثبت نشده است.</p>
</div>
) : (
/* تایم‌لاین عمودی مراحل سفر (Vertical Stepper Timeline) */
<div className="relative space-y-6 before:absolute before:right-4 before:top-3 before:bottom-3 before:w-0.5 before:bg-border-soft">
{itineraries.map((step, idx) => {
const isCurrent = idx === currentStepIndex
const isPast = currentStepIndex >= 0 ? idx < currentStepIndex : false
// وضعیت این مرحله: Completed / In Progress / Not Started
const isCompleted =
step.status === 'Completed' ||
step.status === 1 ||
step.status === '1' ||
isPast
const isInProgress =
step.status === 'In Progress' ||
step.status === 2 ||
step.status === '2' ||
isCurrent
return (
<div key={step.id || idx} className="relative flex items-start gap-4 sm:gap-6 pr-0">
{/* آیکون / نشانگر دایره‌ای تایم‌لاین */}
<div className="relative z-10 flex size-8 shrink-0 items-center justify-center rounded-full border-2 transition-all">
{isCompleted ? (
<div className="flex size-8 items-center justify-center rounded-full bg-emerald-500 text-white shadow-xs">
<Ic name="check" className="size-4" />
</div>
) : isInProgress ? (
<div className="flex size-8 items-center justify-center rounded-full bg-primary text-white ring-4 ring-primary/20 shadow-md">
<span className="text-xs font-black font-mono">{toFa(idx + 1)}</span>
</div>
) : (
<div className="flex size-8 items-center justify-center rounded-full bg-surface-base border-border-soft text-grey-3">
<span className="text-xs font-bold font-mono">{toFa(idx + 1)}</span>
</div>
)}
</div>
{/* کارت محتوای مرحله سفر */}
<div
className={`flex-1 rounded-2xl border p-4 sm:p-5 transition ${
isInProgress
? 'border-primary/40 bg-card/90 shadow-md ring-1 ring-primary/15'
: isCompleted
? 'border-border-soft bg-card/40 opacity-95'
: 'border-border-soft bg-card/20 opacity-80'
}`}
>
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-bold text-grey-3">
گام {toFa(idx + 1)} از {toFa(itineraries.length)}
</span>
{/* برچسب وضعیت مرحله */}
{isInProgress ? (
<Badge className="bg-primary text-white font-bold text-[11px] gap-1 shadow-2xs">
<span className="relative flex size-1.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-white opacity-75"></span>
<span className="relative inline-flex rounded-full size-1.5 bg-white"></span>
</span>
مرحله جاری (In Progress)
</Badge>
) : isCompleted ? (
<Badge
variant="outline"
className="border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 font-bold text-[11px]"
>
تکمیل شده (Completed)
</Badge>
) : (
<Badge
variant="outline"
className="border-border-soft bg-surface-base text-grey-3 font-medium text-[11px]"
>
در انتظار شروع (Not Started)
</Badge>
)}
</div>
<h4 className="text-sm sm:text-base font-black text-foreground">
{step.title}
</h4>
</div>
{/* تاریخ و زمان مرحله */}
<div className="text-start sm:text-end text-xs font-mono text-grey-3 bg-surface-base px-2.5 py-1.5 rounded-xl border border-border-soft shrink-0">
<div className="flex items-center gap-1 font-bold text-foreground">
<Ic name="calendar" className="size-3.5 text-primary" />
<span>{formatDateTime(step.started_at)}</span>
</div>
{step.ended_at && (
<div className="text-[10px] text-grey-3 mt-0.5" dir="ltr">
الی {formatDateTime(step.ended_at)}
</div>
)}
</div>
</div>
{/* خلاصه و توضیحات مرحله */}
{step.summary && (
<p className="mt-3 text-xs leading-relaxed text-grey-2 whitespace-pre-line">
{step.summary}
</p>
)}
{/* تصاویر مرحله */}
{step.images && step.images.length > 0 && (
<div className="mt-4 space-y-1.5">
<span className="text-[11px] font-bold text-grey-3">تصاویر این مرحله:</span>
<div className="flex gap-2.5 overflow-x-auto pb-1 no-scrollbar">
{step.images.map((img, imgIdx) => (
<div
key={img.id || imgIdx}
className="relative size-20 shrink-0 overflow-hidden rounded-xl border border-border-soft bg-surface-base shadow-2xs"
>
<img
src={
img.image_url?.sm ||
img.image_url?.small ||
img.image_url?.md ||
img.image_url?.medium ||
img.image_url?.original ||
'/placeholder.jpg'
}
alt=""
className="h-full w-full object-cover"
/>
</div>
))}
</div>
</div>
)}
</div>
</div>
)
})}
</div>
)}
</div>
)}
</div>
)
})}
</div>
)}
</div>
)
}

47
src/features/tours/TourOrdersPage.tsx

@ -15,6 +15,7 @@ import { Ic } from '@/icons'
import { fetchTourOrders, updateTourOrder } from './services/tour-api'
import type { TourOrderItem, TourOrderStatus } from './types'
import { TourStatusBadge } from './components/TourStatusBadge'
import { Shimmer } from '@/components/shared/Shimmer'
export default function TourOrdersPage() {
const [orders, setOrders] = useState<TourOrderItem[]>([])
@ -241,31 +242,26 @@ export default function TourOrdersPage() {
{/* Orders Table */}
<Card className="bg-card border-border-soft shadow-xs overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-start text-xs border-collapse">
<thead>
<tr className="border-b border-border/50 bg-secondary/15 text-grey-2">
<th className="p-4 font-bold text-start">کد سفارش</th>
<th className="p-4 font-bold text-start">کاربر</th>
<th className="p-4 font-bold text-start">تور مقصد</th>
<th className="p-4 font-bold text-start">مبلغ کل</th>
<th className="p-4 font-bold text-start">روش پرداخت</th>
<th className="p-4 font-bold text-start">فیش واریزی</th>
<th className="p-4 font-bold text-start">وضعیت رزرو</th>
<th className="p-4 font-bold text-start">تاریخ ثبت</th>
<th className="p-4 font-bold text-center">عملیات</th>
</tr>
</thead>
<tbody className="divide-y divide-border/40">
{isLoading ? (
Array.from({ length: 5 }).map((_, i) => (
<tr key={i} className="animate-pulse">
<td colSpan={9} className="p-4 text-center text-grey-4">
در حال دریافت لیست سفارشات از دیتابیس...
</td>
</tr>
))
) : error ? (
{isLoading ? (
<Shimmer rows={5} />
) : (
<div className="overflow-x-auto">
<table className="w-full text-start text-xs border-collapse">
<thead>
<tr className="border-b border-border/50 bg-secondary/15 text-grey-2">
<th className="p-4 font-bold text-start">کد سفارش</th>
<th className="p-4 font-bold text-start">کاربر</th>
<th className="p-4 font-bold text-start">تور مقصد</th>
<th className="p-4 font-bold text-start">مبلغ کل</th>
<th className="p-4 font-bold text-start">روش پرداخت</th>
<th className="p-4 font-bold text-start">فیش واریزی</th>
<th className="p-4 font-bold text-start">وضعیت رزرو</th>
<th className="p-4 font-bold text-start">تاریخ ثبت</th>
<th className="p-4 font-bold text-center">عملیات</th>
</tr>
</thead>
<tbody className="divide-y divide-border/40">
{error ? (
<tr>
<td colSpan={9} className="p-8 text-center text-rose-400 font-bold">
{error}
@ -399,6 +395,7 @@ export default function TourOrdersPage() {
</tbody>
</table>
</div>
)}
</Card>
{/* Receipt Image Preview Dialog */}

438
src/features/tours/ToursPage.tsx

@ -1,5 +1,6 @@
import React, { useState, useEffect, useMemo } from 'react'
import { Ic } from '@/icons'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
@ -17,6 +18,12 @@ import type { TourListItem } from './types'
import { TourStatusBadge, CountryFlagBadge } from './components/TourStatusBadge'
import { TourDetailSheet } from './components/TourDetailSheet'
import { TourTranslationsDialog } from './components/TourTranslationsDialog'
import TablePagination from '@/components/shared/TablePagination'
import FilterSelect from '@/components/shared/FilterSelect'
import SortDropdown from '@/components/shared/SortDropdown'
import FilterBar from '@/components/shared/FilterBar'
import { Shimmer } from '@/components/shared/Shimmer'
import { toFa } from '@/lib/utils'
export default function ToursPage() {
const [tours, setTours] = useState<TourListItem[]>([])
@ -29,8 +36,13 @@ export default function ToursPage() {
const [statusFilter, setStatusFilter] = useState('ALL')
const [tripStatusFilter, setTripStatusFilter] = useState('ALL')
const [countryFilter, setCountryFilter] = useState('ALL')
const [sortBy, setSortBy] = useState<'id' | 'price_asc' | 'price_desc' | 'started_at'>('id')
const [viewMode, setViewMode] = useState<'grid' | 'table'>('grid')
const [languageCode, setLanguageCode] = useState<string>('')
const [languageCode, setLanguageCode] = useState<string>('ALL')
// صفحه‌بندی (Pagination)
const [currentPage, setCurrentPage] = useState(1)
const [pageSize, setPageSize] = useState(8) // ۸ تور در هر صفحه (۲ ردیف ۴ تایی مناسب گرید و جدول)
// وضعیت شیت جزئیات تور
const [selectedTourId, setSelectedTourId] = useState<number | null>(null)
@ -49,7 +61,7 @@ export default function ToursPage() {
try {
const res = await fetchTours({
limit: 100,
language_code: languageCode || undefined,
language_code: languageCode !== 'ALL' ? languageCode : undefined,
})
setTours(res.results || [])
} catch (err: any) {
@ -64,9 +76,9 @@ export default function ToursPage() {
loadData()
}, [languageCode])
// فیلتر و جستجوی کلاینت
// فیلتر، جستجو و مرتب‌سازی کلاینت
const filteredTours = useMemo(() => {
return tours.filter((tour) => {
const list = tours.filter((tour) => {
const matchSearch =
!searchTerm.trim() ||
tour.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
@ -78,7 +90,36 @@ export default function ToursPage() {
return matchSearch && matchStatus && matchTripStatus && matchCountry
})
}, [tours, searchTerm, statusFilter, tripStatusFilter, countryFilter])
return [...list].sort((a, b) => {
if (sortBy === 'price_asc') return parseFloat(a.price) - parseFloat(b.price)
if (sortBy === 'price_desc') return parseFloat(b.price) - parseFloat(a.price)
if (sortBy === 'started_at') return new Date(a.started_at).getTime() - new Date(b.started_at).getTime()
return b.id - a.id
})
}, [tours, searchTerm, statusFilter, tripStatusFilter, countryFilter, sortBy])
// شمارش فیلترهای فعال
const activeFiltersCount = [
statusFilter !== 'ALL',
tripStatusFilter !== 'ALL',
countryFilter !== 'ALL',
Boolean(searchTerm.trim()),
].filter(Boolean).length
// بازنشانی صفحه به ۱ در صورت تغییر هرکدام از فیلترها، جستجو، مرتب‌سازی یا زبان
useEffect(() => {
setCurrentPage(1)
}, [searchTerm, statusFilter, tripStatusFilter, countryFilter, languageCode, sortBy])
// محاسبات صفحه‌بندی فهرست تورها
const totalPages = Math.ceil(filteredTours.length / pageSize) || 1
const activePage = Math.min(currentPage, totalPages)
const paginatedTours = useMemo(() => {
const startIndex = (activePage - 1) * pageSize
return filteredTours.slice(startIndex, startIndex + pageSize)
}, [filteredTours, activePage, pageSize])
// شاخص‌های کلیدی آماری (KPI Metrics)
const stats = useMemo(() => {
@ -126,16 +167,20 @@ export default function ToursPage() {
<div className="flex flex-wrap items-center gap-2.5">
{/* انتخاب زبان */}
<select
<FilterSelect
value={languageCode}
onChange={(e) => setLanguageCode(e.target.value)}
className="rounded-xl border border-border-soft bg-surface-base px-3 py-2 text-xs font-semibold text-foreground focus:outline-hidden focus:ring-1 focus:ring-primary shadow-xs"
>
<option value="">همه زبانها</option>
<option value="fa">فارسی (fa)</option>
<option value="en">انگلیسی (en)</option>
<option value="ar">عربی (ar)</option>
</select>
onChange={setLanguageCode}
options={[
{ value: 'ALL', label: 'همه زبان‌ها' },
{ value: 'fa', label: 'فارسی (fa)' },
{ value: 'en', label: 'English (en)' },
{ value: 'ar', label: 'العربية (ar)' },
]}
allValue="ALL"
icon="globe"
size="sm"
className="w-36"
/>
{/* تغییر نمای شبکه‌ای / جدولی */}
<div className="flex rounded-xl border border-border-soft bg-surface-base p-1">
@ -174,162 +219,171 @@ export default function ToursPage() {
</div>
{/* کارت‌های شاخص‌های کلیدی آماری (KPI Cards) */}
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-5">
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs transition hover:border-border">
<div className="flex items-center justify-between">
<span className="text-xs text-grey-3 font-medium">کل تورها</span>
<div className="flex size-7 items-center justify-center rounded-lg bg-foreground/5 text-foreground">
<Ic name="map" className="size-3.5" />
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-3 lg:grid-cols-5">
<Card className="border-border/50 shadow-xs">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] text-muted-foreground font-medium">کل تورها</p>
{isLoading ? (
<Skeleton className="mt-2 h-7 w-12 rounded-lg" />
) : (
<p className="text-xl font-black mt-1 font-mono text-foreground">{stats.total}</p>
)}
</div>
</div>
<div className="mt-2 text-2xl font-black text-foreground font-mono">{stats.total}</div>
<div className="mt-1 text-[11px] text-grey-3">تورهای منتشرشده</div>
</div>
<div className="rounded-2xl border border-emerald-500/20 bg-emerald-500/5 p-4 shadow-xs transition hover:border-emerald-500/40">
<div className="flex items-center justify-between">
<span className="text-xs text-emerald-600 dark:text-emerald-400 font-medium">در دسترس</span>
<div className="flex size-7 items-center justify-center rounded-lg bg-emerald-500/10 text-emerald-500">
<Ic name="check" className="size-3.5" />
<div className="size-9 rounded-xl bg-muted/60 flex items-center justify-center text-muted-foreground">
<Ic name="map" className="size-4" />
</div>
</div>
<div className="mt-2 text-2xl font-black text-emerald-600 dark:text-emerald-400 font-mono">
{stats.available}
</div>
<div className="mt-1 text-[11px] text-emerald-700/60 dark:text-emerald-300/60">آماده ثبتنام مسافر</div>
</div>
<div className="rounded-2xl border border-rose-500/20 bg-rose-500/5 p-4 shadow-xs transition hover:border-rose-500/40">
<div className="flex items-center justify-between">
<span className="text-xs text-rose-600 dark:text-rose-400 font-medium">تکمیل ظرفیت</span>
<div className="flex size-7 items-center justify-center rounded-lg bg-rose-500/10 text-rose-500">
<Ic name="users" className="size-3.5" />
</CardContent>
</Card>
<Card className="border-emerald-500/20 bg-emerald-500/5 shadow-xs">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 font-medium">در دسترس</p>
{isLoading ? (
<Skeleton className="mt-2 h-7 w-12 rounded-lg" />
) : (
<p className="text-xl font-black mt-1 font-mono text-emerald-600 dark:text-emerald-400">
{stats.available}
</p>
)}
</div>
</div>
<div className="mt-2 text-2xl font-black text-rose-600 dark:text-rose-400 font-mono">
{stats.soldOut}
</div>
<div className="mt-1 text-[11px] text-rose-700/60 dark:text-rose-300/60">ظرفیت تکمیل شده</div>
</div>
<div className="rounded-2xl border border-sky-500/20 bg-sky-500/5 p-4 shadow-xs transition hover:border-sky-500/40">
<div className="flex items-center justify-between">
<span className="text-xs text-sky-600 dark:text-sky-400 font-medium">در حال اجرا</span>
<div className="flex size-7 items-center justify-center rounded-lg bg-sky-500/10 text-sky-500">
<Ic name="calendar" className="size-3.5" />
<div className="size-9 rounded-xl bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 flex items-center justify-center">
<Ic name="checkCircle" className="size-4" />
</div>
</div>
<div className="mt-2 text-2xl font-black text-sky-600 dark:text-sky-400 font-mono">
{stats.traveling}
</div>
<div className="mt-1 text-[11px] text-sky-700/60 dark:text-sky-300/60">سفرهای جاری</div>
</div>
<div className="rounded-2xl border border-amber-500/20 bg-amber-500/5 p-4 shadow-xs transition hover:border-amber-500/40">
<div className="flex items-center justify-between">
<span className="text-xs text-amber-600 dark:text-amber-400 font-medium">در انتظار شروع</span>
<div className="flex size-7 items-center justify-center rounded-lg bg-amber-500/10 text-amber-500">
<Ic name="clock" className="size-3.5" />
</CardContent>
</Card>
<Card className="border-rose-500/20 bg-rose-500/5 shadow-xs">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] text-rose-600 dark:text-rose-400 font-medium">تکمیل ظرفیت</p>
{isLoading ? (
<Skeleton className="mt-2 h-7 w-12 rounded-lg" />
) : (
<p className="text-xl font-black mt-1 font-mono text-rose-600 dark:text-rose-400">
{stats.soldOut}
</p>
)}
</div>
</div>
<div className="mt-2 text-2xl font-black text-amber-600 dark:text-amber-400 font-mono">
{stats.pending}
</div>
<div className="mt-1 text-[11px] text-amber-700/60 dark:text-amber-300/60">تاریخ شروع در آینده</div>
</div>
<div className="size-9 rounded-xl bg-rose-500/15 text-rose-600 dark:text-rose-400 flex items-center justify-center">
<Ic name="xCircle" className="size-4" />
</div>
</CardContent>
</Card>
<Card className="border-border/50 shadow-xs">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] text-muted-foreground font-medium">در حال اجرا</p>
{isLoading ? (
<Skeleton className="mt-2 h-7 w-12 rounded-lg" />
) : (
<p className="text-xl font-black mt-1 font-mono text-foreground">{stats.traveling}</p>
)}
</div>
<div className="size-9 rounded-xl bg-muted/60 flex items-center justify-center text-muted-foreground">
<Ic name="calendar" className="size-4" />
</div>
</CardContent>
</Card>
<Card className="border-amber-500/20 bg-amber-500/5 shadow-xs col-span-2 sm:col-span-1">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] text-amber-600 dark:text-amber-400 font-medium">در انتظار شروع</p>
{isLoading ? (
<Skeleton className="mt-2 h-7 w-12 rounded-lg" />
) : (
<p className="text-xl font-black mt-1 font-mono text-amber-600 dark:text-amber-400">
{stats.pending}
</p>
)}
</div>
<div className="size-9 rounded-xl bg-amber-500/15 text-amber-600 dark:text-amber-400 flex items-center justify-center">
<Ic name="clock" className="size-4" />
</div>
</CardContent>
</Card>
</div>
{/* نوار فیلتر و جستجو */}
<div className="flex flex-col gap-3 rounded-2xl border border-border-soft bg-card/30 p-4 lg:flex-row lg:items-center lg:justify-between">
<div className="relative flex-1 max-w-md">
<Ic name="search" className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-grey-3" />
<Input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="جستجوی تور با عنوان یا اسلاگ..."
className="pr-9 text-xs bg-surface-base"
{/* نوار فیلتر و جستجو استاندارد */}
<FilterBar
searchQuery={searchTerm}
onSearchChange={setSearchTerm}
searchPlaceholder="جستجوی تور با عنوان یا اسلاگ..."
hasActiveFilters={activeFiltersCount > 0 || Boolean(searchTerm)}
activeFilterCount={activeFiltersCount}
onResetFilters={() => {
setSearchTerm('')
setStatusFilter('ALL')
setTripStatusFilter('ALL')
setCountryFilter('ALL')
}}
endContent={
<SortDropdown
value={sortBy}
onChange={(val) => setSortBy(val as any)}
options={[
{ value: 'id', label: 'جدیدترین (شناسه)' },
{ value: 'price_asc', label: 'ارزان‌ترین تور' },
{ value: 'price_desc', label: 'گران‌ترین تور' },
{ value: 'started_at', label: 'نزدیک‌ترین تاریخ حرکت' },
]}
size="sm"
/>
</div>
<div className="flex flex-wrap items-center gap-2">
{/* وضعیت فروش */}
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="rounded-xl border border-border-soft bg-surface-base px-3 py-2 text-xs font-semibold text-foreground focus:outline-hidden"
>
<option value="ALL">همه وضعیتهای فروش</option>
<option value="AVAILABLE">فقط در دسترس (Available)</option>
<option value="SOLD_OUT">تکمیل ظرفیت (Sold Out)</option>
<option value="NO_SHOW">عدم نمایش (No Show)</option>
</select>
{/* وضعیت اجرا */}
<select
value={tripStatusFilter}
onChange={(e) => setTripStatusFilter(e.target.value)}
className="rounded-xl border border-border-soft bg-surface-base px-3 py-2 text-xs font-semibold text-foreground focus:outline-hidden"
>
<option value="ALL">همه وضعیتهای سفر</option>
<option value="PENDING">در انتظار اجرا (Pending)</option>
<option value="TRAVELING">در حال برگزاری (Traveling)</option>
<option value="FINISHED">پایانیافته (Finished)</option>
</select>
{/* کشور مقصد */}
<select
value={countryFilter}
onChange={(e) => setCountryFilter(e.target.value)}
className="rounded-xl border border-border-soft bg-surface-base px-3 py-2 text-xs font-semibold text-foreground focus:outline-hidden"
>
<option value="ALL">همه مقاصد</option>
<option value="IQ">🇮🇶 عراق</option>
<option value="IR">🇮🇷 ایران</option>
<option value="SA">🇸🇦 عربستان</option>
<option value="SY">🇸🇾 سوریه</option>
</select>
{(searchTerm || statusFilter !== 'ALL' || tripStatusFilter !== 'ALL' || countryFilter !== 'ALL') && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setSearchTerm('')
setStatusFilter('ALL')
setTripStatusFilter('ALL')
setCountryFilter('ALL')
}}
className="text-xs text-rose-500 hover:bg-rose-500/10"
>
پاکسازی فیلترها
</Button>
)}
</div>
</div>
{/* وضعیت لودینگ */}
}
>
{/* وضعیت فروش */}
<FilterSelect
value={statusFilter}
onChange={setStatusFilter}
options={[
{ value: 'ALL', label: 'همه وضعیت‌های فروش' },
{ value: 'AVAILABLE', label: 'فقط در دسترس (Available)' },
{ value: 'SOLD_OUT', label: 'تکمیل ظرفیت (Sold Out)' },
{ value: 'NO_SHOW', label: 'عدم نمایش (No Show)' },
]}
allValue="ALL"
clearable
size="sm"
/>
{/* وضعیت سفر */}
<FilterSelect
value={tripStatusFilter}
onChange={setTripStatusFilter}
options={[
{ value: 'ALL', label: 'همه وضعیت‌های سفر' },
{ value: 'PENDING', label: 'در انتظار اجرا (Pending)' },
{ value: 'TRAVELING', label: 'در حال برگزاری (Traveling)' },
{ value: 'FINISHED', label: 'پایان‌یافته (Finished)' },
]}
allValue="ALL"
clearable
size="sm"
/>
{/* کشور مقصد */}
<FilterSelect
value={countryFilter}
onChange={setCountryFilter}
options={[
{ value: 'ALL', label: 'همه مقاصد' },
{ value: 'IQ', label: '🇮🇶 عراق' },
{ value: 'IR', label: '🇮🇷 ایران' },
{ value: 'SA', label: '🇸🇦 عربستان' },
{ value: 'SY', label: '🇸🇾 سوریه' },
]}
allValue="ALL"
clearable
size="sm"
/>
</FilterBar>
{/* وضعیت لودینگ استاندارد Shimmer */}
{isLoading ? (
viewMode === 'grid' ? (
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="rounded-2xl border border-border-soft bg-card/30 p-4 space-y-3">
<Skeleton className="h-44 w-full rounded-xl" />
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<div className="flex justify-between pt-2">
<Skeleton className="h-6 w-20" />
<Skeleton className="h-6 w-16" />
</div>
</div>
))}
</div>
) : (
<div className="rounded-2xl border border-border-soft bg-card/30 p-6 space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full rounded-xl" />
))}
</div>
)
<Shimmer withCard cardHeaderTitle="فهرست همه تورها" cardHeaderIcon="map" rows={6} />
) : error ? (
/* وضعیت خطا */
<div className="rounded-2xl border border-rose-500/20 bg-rose-500/5 p-12 text-center text-rose-500">
@ -359,11 +413,15 @@ export default function ToursPage() {
) : viewMode === 'grid' ? (
/* نمای شبکه‌ای (Cards Grid View) */
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{filteredTours.map((tour) => {
{paginatedTours.map((tour) => {
const imgUrl =
tour.image?.image_url?.md ||
tour.image?.image_url?.medium ||
tour.image?.image_url?.lg ||
tour.image?.image_url?.large ||
tour.image?.image_url?.original ||
tour.image?.image_url?.sm ||
tour.image?.image_url?.small ||
'/placeholder.jpg'
return (
@ -472,7 +530,7 @@ export default function ToursPage() {
</TableRow>
</TableHeader>
<TableBody>
{filteredTours.map((tour) => (
{paginatedTours.map((tour) => (
<TableRow
key={tour.id}
className="transition hover:bg-card/70 cursor-pointer"
@ -535,6 +593,60 @@ export default function ToursPage() {
</div>
)}
{/* کنترل صفحه‌بندی فهرست تورها (Pagination Footer) */}
{!isLoading && !error && filteredTours.length > 0 && (
<div className="flex flex-col gap-4 rounded-2xl border border-border-soft bg-card/40 p-4 sm:flex-row sm:items-center sm:justify-between shadow-xs">
{/* نمایش خلاصه تعداد آیتم‌ها و انتخاب تعداد در هر صفحه */}
<div className="flex flex-wrap items-center gap-3 text-xs text-grey-3">
<span>
نمایش{' '}
<strong className="text-foreground font-mono font-bold">
{toFa((activePage - 1) * pageSize + 1)}
</strong>{' '}
تا{' '}
<strong className="text-foreground font-mono font-bold">
{toFa(Math.min(activePage * pageSize, filteredTours.length))}
</strong>{' '}
از{' '}
<strong className="text-foreground font-mono font-bold">
{toFa(filteredTours.length)}
</strong>{' '}
تور
</span>
<div className="flex items-center gap-1.5 border-r border-border-soft pr-3">
<span className="text-[11px]">در هر صفحه:</span>
<FilterSelect
value={String(pageSize)}
onChange={(val) => {
setPageSize(Number(val))
setCurrentPage(1)
}}
options={[
{ value: '8', label: '۸' },
{ value: '12', label: '۱۲' },
{ value: '16', label: '۱۶' },
{ value: '24', label: '۲۴' },
]}
size="sm"
className="w-20"
triggerClassName="h-8 py-1 px-2.5 text-xs font-bold"
/>
</div>
</div>
{/* کامپوننت صفحه‌بندی استاندارد پنل */}
<TablePagination
currentPage={activePage}
totalPages={totalPages}
onPageChange={(p) => {
setCurrentPage(p)
window.scrollTo({ top: 0, behavior: 'smooth' })
}}
/>
</div>
)}
{/* شیت جزئیات کامل تور */}
<TourDetailSheet
tourId={selectedTourId}

29
src/features/tours/components/TourDetailSheet.tsx

@ -7,9 +7,9 @@ import {
SheetDescription,
} from '@/components/ui/sheet'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Skeleton } from '@/components/ui/skeleton'
import { Button } from '@/components/ui/button'
import { Ic } from '@/icons'
import { Shimmer } from '@/components/shared/Shimmer'
import {
fetchTourDetail,
fetchTourItineraries,
@ -168,14 +168,7 @@ export function TourDetailSheet({ tourId, isOpen, onClose }: TourDetailSheetProp
<div className="p-6 space-y-6">
{isLoading ? (
<div className="space-y-4">
<Skeleton className="h-44 w-full rounded-2xl" />
<div className="grid grid-cols-2 gap-3">
<Skeleton className="h-20 rounded-xl" />
<Skeleton className="h-20 rounded-xl" />
</div>
<Skeleton className="h-32 w-full rounded-xl" />
</div>
<Shimmer className="p-0 space-y-3" rows={5} />
) : error ? (
<div className="rounded-2xl border border-rose-500/20 bg-rose-500/5 p-6 text-center text-rose-500">
<Ic name="alert" className="mx-auto size-8 mb-2" />
@ -205,7 +198,7 @@ export function TourDetailSheet({ tourId, isOpen, onClose }: TourDetailSheetProp
className="relative size-24 shrink-0 overflow-hidden rounded-xl border border-border-soft bg-surface-base shadow-2xs"
>
<img
src={img.image_url?.medium || img.image_url?.original || '/placeholder.jpg'}
src={img.image_url?.md || img.image_url?.medium || img.image_url?.lg || img.image_url?.large || img.image_url?.original || '/placeholder.jpg'}
alt=""
className="h-full w-full object-cover"
/>
@ -310,11 +303,7 @@ export function TourDetailSheet({ tourId, isOpen, onClose }: TourDetailSheetProp
{activeTab === 'itinerary' && (
<div className="space-y-4">
{isLoadingSub ? (
<div className="space-y-3">
<Skeleton className="h-24 w-full rounded-xl" />
<Skeleton className="h-24 w-full rounded-xl" />
<Skeleton className="h-24 w-full rounded-xl" />
</div>
<Shimmer rows={3} withCard={false} />
) : itineraries.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border-soft p-8 text-center text-grey-3">
<Ic name="calendar" className="mx-auto size-8 mb-2 opacity-50" />
@ -352,10 +341,7 @@ export function TourDetailSheet({ tourId, isOpen, onClose }: TourDetailSheetProp
{activeTab === 'passengers' && (
<div className="space-y-4">
{isLoadingSub ? (
<div className="space-y-3">
<Skeleton className="h-20 w-full rounded-xl" />
<Skeleton className="h-20 w-full rounded-xl" />
</div>
<Shimmer rows={2} withCard={false} />
) : passengersGroup.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border-soft p-8 text-center text-grey-3">
<Ic name="users" className="mx-auto size-8 mb-2 opacity-50" />
@ -460,10 +446,7 @@ export function TourDetailSheet({ tourId, isOpen, onClose }: TourDetailSheetProp
{activeTab === 'comments' && (
<div className="space-y-4">
{isLoadingSub ? (
<div className="space-y-3">
<Skeleton className="h-20 w-full rounded-xl" />
<Skeleton className="h-20 w-full rounded-xl" />
</div>
<Shimmer rows={2} withCard={false} />
) : comments.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border-soft p-8 text-center text-grey-3">
<Ic name="inbox" className="mx-auto size-8 mb-2 opacity-50" />

6
src/features/tours/components/TourTranslationsDialog.tsx

@ -11,6 +11,7 @@ import {
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Ic } from '@/icons'
import { Shimmer } from '@/components/shared/Shimmer'
import {
fetchTourTranslations,
saveTourTranslations,
@ -320,10 +321,7 @@ export function TourTranslationsDialog({
{/* Dialog Body */}
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-5">
{isLoading ? (
<div className="flex flex-col items-center justify-center py-16 text-grey-3">
<div className="size-8 animate-spin rounded-full border-2 border-primary border-t-transparent mb-3" />
<p className="text-xs">در حال بارگذاری ترجمهها از Backend...</p>
</div>
<Shimmer className="p-2 space-y-3" rows={5} />
) : (
<>
{errorMessage && (

3
src/features/tours/types.ts

@ -9,6 +9,9 @@ export interface TourImageThumbnails {
medium?: string
small?: string
icon?: string
sm?: string
md?: string
lg?: string
}
export interface TourImageItem {

23
src/icons.tsx

@ -46,6 +46,10 @@ const PATHS: Record<string, string> = {
tag: '<path d="M12 2H2v10l10 10 10-10Z"/><circle cx="6.5" cy="6.5" r="1.5"/>',
disc: '<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="2.5"/>',
calendarDot: '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><circle cx="12" cy="15" r="1"/>',
/* Sorting icons */
arrowUpDown: '<path d="m21 16-4 4-4-4"/><path d="M17 20V4"/><path d="m3 8 4-4 4 4"/><path d="M7 4v16"/>',
arrowUp: '<path d="m5 12 7-7 7 7"/><path d="M12 19V5"/>',
arrowDown: '<path d="m19 12-7 7-7-7"/><path d="M12 5v14"/>',
/* Lucide-compatible icons for shadcn Pagination */
chevronLeft: '<path d="M15 18l-6-6 6-6"/>',
chevronRight: '<path d="M9 18l6-6-6-6"/>',
@ -71,13 +75,27 @@ const PATHS: Record<string, string> = {
moon: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
server: '<rect width="20" height="8" x="2" y="2" rx="2" ry="2"/><rect width="20" height="8" x="2" y="14" rx="2" ry="2"/><line x1="6" x2="6.01" y1="6" y2="6"/><line x1="6" x2="6.01" y1="18" y2="18"/>',
code: '<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>',
receipt: '<path d="M4 2v20l2-1 2 1 2-1 2 1 2-1 2 1 2-1 2 1V2l-2 1-2-1-2 1-2-1-2 1-2-1-2 1Z"/><path d="M16 8h-8"/><path d="M16 12h-8"/><path d="M10 16h-2"/>',
creditCard: '<rect width="20" height="14" x="2" y="5" rx="2"/><line x1="2" x2="22" y1="10" y2="10"/>',
checkCircle: '<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>',
xCircle: '<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>',
dollarSign: '<line x1="12" x2="12" y1="2" y2="22"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>',
image: '<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>',
/* آیکون‌های اختصاصی خدمات سامانه */
simcard: '<path d="M6 2h8l6 6v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Z"/><rect x="8" y="10" width="8" height="9" rx="1.5"/><line x1="12" y1="10" x2="12" y2="19"/><line x1="8" y1="14.5" x2="16" y2="14.5"/>',
guide: '<circle cx="12" cy="12" r="10"/><polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76"/>',
exchange: '<path d="M17 2l4 4-4 4"/><path d="M3 11v-1a4 4 0 0 1 4-4h14"/><path d="M7 22l-4-4 4-4"/><path d="M21 13v1a4 4 0 0 1-4 4H3"/>',
etiquette: '<path d="m12 3 2.6 4.3 4.9.4-3.3 3.8 1 5-4.6-2-4.6 2 1-5L5.7 7.7l4.9-.4L12 3z"/>',
headset: '<path d="M3 18v-6a9 9 0 0 1 18 0v6"/><path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z"/><path d="M21 16v2a3 3 0 0 1-3 3h-3"/>',
store: '<path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/><path d="M3 6h18"/><path d="M16 10a4 4 0 0 1-8 0"/>',
calculator: '<rect width="16" height="20" x="4" y="2" rx="2"/><line x1="8" x2="16" y1="6" y2="6"/><circle cx="8" cy="11" r="1"/><circle cx="12" cy="11" r="1"/><circle cx="16" cy="11" r="1"/><circle cx="8" cy="15" r="1"/><circle cx="12" cy="15" r="1"/><circle cx="16" cy="15" r="1"/><circle cx="8" cy="18" r="1"/><circle cx="12" cy="18" r="1"/><circle cx="16" cy="18" r="1"/>',
}
export type IconName = keyof typeof PATHS
type IcProps = React.ComponentProps<'svg'> & { name: IconName }
export type IcProps = React.ComponentProps<'svg'> & { name: IconName }
export function Ic({ name, ...rest }: IcProps) {
export function Ic({ name, className, ...rest }: IcProps) {
return (
<svg
viewBox="0 0 24 24"
@ -86,6 +104,7 @@ export function Ic({ name, ...rest }: IcProps) {
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
className={className ? className : 'size-4 shrink-0'}
dangerouslySetInnerHTML={{ __html: PATHS[name] }}
{...rest}
/>

18
src/services/http.ts

@ -47,15 +47,27 @@ function extractMessage(data: unknown, fallback: string): string {
if (typeof data === 'string' && data.trim()) return data
if (data && typeof data === 'object') {
const obj = data as Record<string, unknown>
if (typeof obj.detail === 'string') return obj.detail
if (typeof obj.detail === 'string' && obj.detail.trim()) return obj.detail
if (Array.isArray(obj.non_field_errors) && obj.non_field_errors.length) {
return String(obj.non_field_errors[0])
}
/* اولین خطای فیلدی (آرایه یا رشته) */
for (const value of Object.values(obj)) {
if (Array.isArray(obj.errors) && obj.errors.length) {
const first = obj.errors[0]
if (typeof first === 'string' && first.trim()) return first
if (first && typeof first === 'object' && typeof (first as any).message === 'string') {
return (first as any).message
}
}
if (typeof obj.message === 'string' && obj.message.trim() && obj.status !== 'error') {
return obj.message
}
/* اولین خطای فیلدی (آرایه یا رشته)، صرف‌نظر از کلیدهای وضعیتی */
for (const [key, value] of Object.entries(obj)) {
if (key === 'status' || key === 'code' || key === 'status_code') continue
if (Array.isArray(value) && value.length) return String(value[0])
if (typeof value === 'string' && value.trim()) return value
}
if (typeof obj.message === 'string' && obj.message.trim()) return obj.message
}
return fallback
}

4
vite.config.ts

@ -129,6 +129,10 @@ export default defineConfig(({ mode }) => {
target: apiTarget,
changeOrigin: true,
},
'/static': {
target: apiTarget,
changeOrigin: true,
},
},
},
test: {

Loading…
Cancel
Save