Browse Source
feat: add application layout, UI component library, and management features for billing, tours, and services
master
feat: add application layout, UI component library, and management features for billing, tours, and services
master
36 changed files with 4743 additions and 282 deletions
-
249guide/aqila_panel_ui_design_system_standard.md
-
7src/app/router.tsx
-
31src/components/layout/AppSidebar.tsx
-
16src/components/layout/Topbar.tsx
-
94src/components/shared/FilterBar.tsx
-
137src/components/shared/FilterSelect.tsx
-
133src/components/shared/Shimmer.tsx
-
69src/components/shared/SortDropdown.tsx
-
2src/components/ui/alert-dialog.tsx
-
4src/components/ui/command.tsx
-
24src/components/ui/dropdown-menu.tsx
-
6src/components/ui/multi-select.tsx
-
2src/components/ui/popover.tsx
-
77src/components/ui/select.tsx
-
2src/components/ui/shimmer.tsx
-
6src/components/ui/single-select.tsx
-
18src/config/navigation.ts
-
690src/features/billing/BillingPage.tsx
-
472src/features/billing/components/BillingDetailModal.tsx
-
70src/features/billing/components/BillingServiceBadge.tsx
-
70src/features/billing/components/BillingStatusBadge.tsx
-
140src/features/billing/components/ReceiptLightboxDialog.tsx
-
105src/features/billing/components/RejectFactorDialog.tsx
-
71src/features/billing/services/billing-api.ts
-
107src/features/billing/types.ts
-
57src/features/global-search/GlobalSearchDialog.tsx
-
967src/features/services/ServicesPage.tsx
-
831src/features/tours/OngoingToursPage.tsx
-
47src/features/tours/TourOrdersPage.tsx
-
438src/features/tours/ToursPage.tsx
-
29src/features/tours/components/TourDetailSheet.tsx
-
6src/features/tours/components/TourTranslationsDialog.tsx
-
3src/features/tours/types.ts
-
23src/icons.tsx
-
18src/services/http.ts
-
4vite.config.ts
@ -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 کنتراست و خوانایی کامل دارد؟ |
||||
|
- [ ] **بررسی اصطلاحات و کلیدها:** آیا منطق برنامه صرفاً با کلیدهای انگلیسی کار کرده و هیچ متنی هاردکد نشده است؟ |
||||
@ -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> |
||||
|
) |
||||
|
} |
||||
@ -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> |
||||
|
) |
||||
|
} |
||||
@ -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 |
||||
@ -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> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,2 @@ |
|||||
|
export * from '@/components/shared/Shimmer' |
||||
|
export { default } from '@/components/shared/Shimmer' |
||||
@ -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> |
||||
|
) |
||||
|
} |
||||
@ -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> |
||||
|
) |
||||
|
} |
||||
@ -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> |
||||
|
) |
||||
|
} |
||||
|
} |
||||
@ -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> |
||||
|
) |
||||
|
} |
||||
|
} |
||||
@ -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> |
||||
|
) |
||||
|
} |
||||
@ -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> |
||||
|
) |
||||
|
} |
||||
@ -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', |
||||
|
}) |
||||
|
} |
||||
@ -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 |
||||
|
} |
||||
@ -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">مشاهده متن و صوت ></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> |
||||
|
) |
||||
|
} |
||||
@ -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> |
||||
|
) |
||||
|
} |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue