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