diff --git a/guide/aqila_panel_ui_design_system_standard.md b/guide/aqila_panel_ui_design_system_standard.md new file mode 100644 index 0000000..dc3caca --- /dev/null +++ b/guide/aqila_panel_ui_design_system_standard.md @@ -0,0 +1,249 @@ +# سند جامع سیستم طراحی بصری و استانداردهای رابط کاربری (UI Design System & Typography Standard) + +**سامانه مدیریت و پلتفرم عقیله (Aqila)** +**مرجع سیستم:** کلاینت وب و پنل مدیریت (`aqila_panel`) و راهنمای سراسری هوش مصنوعی (AI Agent Directive) +**نسخه:** 1.0.0 +**وضعیت:** مصوب و الزامی (Mandatory Reference) + +--- + +## ۱. هدف و دامنه کاربرد (Objective & Scope) + +این سند، **مرجع واحد و غیرقابل‌تغییر (Single Source of Truth)** برای تمامی استانداردهای بصری، تایپوگرافی، رنگ‌بندی، چیدمان، فاصله‌گذاری، کامپوننت‌های تعاملی و رفتارهای چندزبانه در رابط کاربری سامانه عقیله است. + +> [!IMPORTANT] +> **قانون الزام‌آور هوش مصنوعی (AI Agent Rule):** +> هوش مصنوعی (AI Agent) در زمان ایجاد هر صفحه جدید، ساخت هر کامپوننت، ویرایش صفحات موجود، بازطراحی فرم‌ها، فیلترها، جداول، تگ‌ها و وضعیت‌های بارگذاری (Loading)، **باید ابتدا و بدون استثنا این سند را به عنوان مرجع قطعی رعایت نماید.** هرگونه کدنویسی بصری سلیقه‌ای، استفاده از رنگ‌های رنگین‌کمانی مدادرنگی (Crayon-like)، فونت‌های متفرقه یا ساختارهای ناهمگون اکیداً ممنوع است. + +--- + +## ۲. زبان، تایپوگرافی و نگارش چندزبانه (Language & Typography) + +سامانه عقیله یک سیستم بین‌المللی با تمرکز بر سه زبان اصلی **فارسی (FA)**، **عربی (AR)** و **انگلیسی (EN)** است. + +``` +┌────────────────────────────────────────────────────────────────────────┐ +│ مقیاس تایپوگرافی عقیله │ +├───────────────────┬────────────┬─────────┬──────────────┬──────────────┤ +│ سطح متنی (Role) │ سایز (Size)│ وزن (Wt)│ Line-Height │ کلاس Tailwind│ +├───────────────────┼────────────┼─────────┼──────────────┼──────────────┤ +│ Display / H1 │ 24px (1.5r)│ Black/900│ 1.35 (32px) │ text-2xl │ +│ Page Title / H2 │ 20px (1.25)│ Bold/800│ 1.4 (28px) │ text-xl │ +│ Section Title / H3│ 16px (1.0r)│ Bold/700│ 1.45 (24px) │ text-base │ +│ Card Title / H4 │ 14px (.875)│ Bold/700│ 1.5 (21px) │ text-sm │ +│ Body Text (اصلی) │ 13px-14px │ Medium/500│ 1.6 (22px) │ text-xs/sm │ +│ Secondary / Muted │ 12px (.75r)│ Regular │ 1.5 (18px) │ text-xs │ +│ Caption / Footnote│ 11px (.687)│ Medium │ 1.4 (16px) │ text-[11px] │ +│ Badge / Tag Label │ 10px-11px │ Bold/700│ 1.0 (12px) │ text-[10px] │ +│ Monospace / Stats │ 12px-20px │ Bold/900│ 1.2 (Tabular)│ font-mono │ +└───────────────────┴────────────┴─────────┴──────────────┴──────────────┘ +``` + +### ۲.۱. خانواده فونت‌ها (Font Families) +1. **فارسی و عربی (RTL):** + - فونت استاندارد: `Vazirmatn` (وزن‌های 400, 500, 700, 800, 900) + - فونت فال‌بک: `system-ui, -apple-system, 'Segoe UI', Tahoma, sans-serif` +2. **انگلیسی و ارقام/داده‌های فنی (LTR):** + - متون انگلیسی: `Inter, system-ui, sans-serif` + - ارقام مالی، شناسه‌ها، شماره سفارش، تاریخ‌های میلادی و ساعت: `font-mono` (فونت مونو اسپیس با Tabular Figures جهت تراز دقیق در جداول و کارت‌ها). + +### ۲.۲. ترکیب متون دوزبانه و اعداد (Mixed-Language Content) +- **ارقام فارسی در برابر انگلیسی:** + - در متون توضیحی و عناوین فارسی از ارقام بومی (`toFa(number)`) استفاده شود. + - در کد رهگیری، شماره کارت، شناسه دیتابیس (`#1240`)، مبالغ ارزی دلاری/دیناری (`$1,200` یا `15,000 IQD`) و تاریخ‌های لاگ، منحصراً از ارقام انگلیسی درون بلوک `font-mono` و با جهت `dir="ltr"` استفاده شود. +- **اصطلاحات فنی و لاتین در متن فارسی:** + - کلیه اسلاگ‌ها، نام فایل‌ها و عبارت‌های انگلیسی درون متون فارسی باید درون محفظه مجزا یا با `dir="ltr"` قرار گیرند تا نظم کلمات به هم نخورد. +- **پرهیز از Letter-Spacing غیرمجاز:** + - در خطوط فارسی و عربی به هیچ وجه از `letter-spacing` (کلاس‌های `tracking-widest` و...) استفاده نشود؛ زیرا باعث تکه‌تکه شدن حروف متصل می‌شود. + +--- + +## ۳. جهت‌چینی، ترازبندی و قواعد RTL / LTR + +### ۳.۱. اصول جهت‌چینی (Directionality Rules) +- رابط کاربری به صورت پیش‌فرض **راست‌به‌چپ (`dir="rtl"`)** است. +- **ویژگی‌های منطقی (CSS Logical Properties):** + - به جای `left` و `right`، همواره از ویژگی‌های منطقی استفاده شود: + - پدینگ و مارجین: `ps-` (padding-start)، `pe-` (padding-end)، `ms-` (margin-start)، `me-` (margin-end). + - گوشه‌ها: `rounded-s-` (start) و `rounded-e-` (end). + - ترازبندی متن: `text-start` و `text-end` به جای `text-left` و `text-right`. + +### ۳.۲. آیکون‌ها در RTL +- آیکون‌های هدایتی (مانند پیکان بازگشت، بعدی/قبلی، Breadcrumbs) باید در حالت RTL متناسب با جهت جریان اطلاعات بچرخند یا به صورت طبیعی بر اساس `start/end` قرار گیرند. +- آیکون‌های متقارن (مانند جستجو، تقویم، تنظیمات، سبد خرید) نیاز به چرخش ندارند. + +--- + +## ۴. پالت رنگی خنثی، مینیمال و معنادار (Neutral & Semantic Color System) + +> [!CAUTION] +> **پرهیز جدی از رابط کاربری مدادرنگی (No "Crayon-like" Overly Colorful UI):** +> بیش از **۹۰٪ رابط کاربری** باید از رنگ‌های خنثی (سفید، خاکستری، اسلیت، تیره) تشکیل شود. استفاده از رنگ‌های تند (آبی روشن، نارنجی فسفری، بنفش تیره، سبز فسفری) به عنوان پس‌زمینه کارت‌ها، بج‌ها و کادرها کاملاً ممنوع است مگر اینکه معنای وضعیتی مشخصی داشته باشد. + +``` +┌────────────────────────────────────────────────────────────────────────┐ +│ معماری پالت رنگی عقیله │ +├───────────────────┬──────────────────────┬─────────────────────────────┤ +│ توکن معنایی │ تم دارک (Dark Mode) │ تم لایت (Light Mode) │ +├───────────────────┼──────────────────────┼─────────────────────────────┤ +│ background │ #18191D (تیره مات) │ #F8FAFC (سفید-خاکستری محو) │ +│ surface / card │ #22252C (کارت پایه) │ #FFFFFF (سفید خالص) │ +│ surface-muted │ #1E2128 (پس‌زمینه فرعی)│ #F1F5F9 (خاکستری بسیار روشن)│ +│ border │ rgba(255,255,255,0.1)│ rgba(15,23,42,0.10) │ +│ border-soft │ rgba(255,255,255,0.06)│ rgba(15,23,42,0.06) │ +│ primary (هویت برنز)│ #D09460 │ #D09460 │ +│ primary-muted │ rgba(208,148,96,0.12)│ rgba(208,148,96,0.10) │ +│ text-foreground │ #FFFFFF (خوانایی کامل)│ #0F172A (سرمه‌ای-مشکی پرکنتراست)│ +│ text-muted │ #8B8B8B (خاکستری متوسط)│ #64748B (خاکستری خوانا) │ +│ text-subtle │ #666666 │ #94A3B8 │ +└───────────────────┴──────────────────────┴─────────────────────────────┘ +``` + +### ۴.۱. رنگ‌های معنایی و وضعیت‌ها (Semantic Status Colors) +رنگ‌های اشباع منحصراً برای اعلام وضعیت و فیدبک‌های مهم سیستم رزرو شده‌اند: +1. **موفقیت / تایید (Success):** + - کارکرد: پرداخت موفق، تور تکمیل‌شده، رزرو فعال. + - رنگ: `Emerald` (لایت: `text-emerald-700 bg-emerald-50 border-emerald-200` | دارک: `text-emerald-400 bg-emerald-500/10 border-emerald-500/20`). +2. **هشدار / در انتظار (Warning / Pending):** + - کارکرد: در انتظار پرداخت، بررسی فیش، نزدیک به ظرفیت. + - رنگ: `Amber` (لایت: `text-amber-700 bg-amber-50 border-amber-200` | دارک: `text-amber-400 bg-amber-500/10 border-amber-500/20`). +3. **خطا / رد شده (Error / Destructive):** + - کارکرد: فیش نامعتبر، سفارش لغوشده، حذف آیتم. + - رنگ: `Rose / Red` (لایت: `text-rose-700 bg-rose-50 border-rose-200` | دارک: `text-rose-400 bg-rose-500/10 border-rose-500/20`). +4. **اطلاعات عمومی (Informational / Neutral):** + - کارکرد: دسته‌بندی‌ها، تعداد بازدید، مشخصات عادی، تگ‌های عمومی. + - رنگ: **کاملاً خنثی (Neutral / Slate)** (`bg-muted/40 text-muted-foreground border-border-soft`). هرگز برای تگ‌های معمولی از رنگ‌های رنگین‌کمانی استفاده نشود. + +--- + +## ۵. مقیاس فواصل، ابعاد و شعاع انحناها (Spacing, Sizing & Radii) + +سامانه از سیستم فاصله‌گذاری مضرب ۴ و ۸ پیکسلی تبعیت می‌کند: + +``` +┌────────────────────────────────────────────────────────────────────────┐ +│ مقیاس فواصل استاندارد (Spacing) │ +├─────────────┬─────────────┬────────────────────────────────────────────┤ +│ توکن │ مقدار (px) │ کاربرد و موارد مصرف │ +├─────────────┼─────────────┼────────────────────────────────────────────┤ +│ space-xs │ 4px │ فاصله بین آیکون و متن، پدینگ میکرو │ +│ space-sm │ 8px │ فاصله عناصر درون یک کامپوننت، گپ دکمه‌ها │ +│ space-md │ 12px │ فاصله آیتم‌های لیست، پدینگ فیلدها │ +│ space-lg │ 16px │ پدینگ افقی صفحات، پدینگ پیش‌فرض کارت‌ها │ +│ space-xl │ 20px │ فاصله بین بخش‌ها و ستون‌های فرم‌ها │ +│ space-2xl │ 24px │ گپ بین کارت‌های اصلی و سکشن‌های داشبورد │ +│ space-3xl │ 32px │ فاصله عمودی سکشن‌های بزرگ │ +└─────────────┴─────────────┴────────────────────────────────────────────┘ +``` + +### ۵.۱. شعاع انحناها (Border Radii) +- **تگ‌ها، بج‌ها و المان‌های کوچک:** `rounded-lg` (8px الی 10px) +- **فیلدهای ورودی و دکمه‌ها:** `rounded-xl` (12px) +- **کارت‌ها، جداول و بخش‌ها:** `rounded-2xl` (16px) +- **دیالوگ‌ها، پاپ‌آپ‌ها و مودال‌ها:** `rounded-2xl` الی `rounded-3xl` (20px - 24px) +- **بج‌های کپسولی / قرصی:** `rounded-full` (9999px) + +--- + +## ۶. استاندارد جامع تگ‌ها و بج‌های وضعیتی (Tags & Status Badges) + +کلیه تگ‌ها باید ساختاری منسجم و یکدست داشته باشند: + +```tsx +// ۱. تگ خنثی استاندارد (اطلاعات عمومی، دسته‌بندی، کشور، نسخه) + + عراق + + +// ۲. بج وضعیتی تایید شده / موفق (Approved / Success) + + + تأیید شده + + +// ۳. بج وضعیتی در انتظار / بررسی (Pending / Warning) + + + در انتظار پرداخت + + +// ۴. بج وضعیتی رد شده / خطا (Rejected / Error) + + + رد شده + +``` + +--- + +## ۷. استانداردهای کامپوننت‌های پایه (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 ? ( + +) : ( + +)} +``` + +--- + +## ۹. دسترسی‌پذیری و تم‌های تاریک و روشن (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 کنتراست و خوانایی کامل دارد؟ +- [ ] **بررسی اصطلاحات و کلیدها:** آیا منطق برنامه صرفاً با کلیدهای انگلیسی کار کرده و هیچ متنی هاردکد نشده است؟ diff --git a/src/app/router.tsx b/src/app/router.tsx index 3a3228f..5affb44 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -7,7 +7,10 @@ import RequireAuth from '@/features/auth/RequireAuth' const LoginPage = lazy(() => import('@/features/auth/LoginPage')) const DashboardHome = lazy(() => import('@/features/dashboard/DashboardHome')) const ToursPage = lazy(() => import('@/features/tours/ToursPage')) +const OngoingToursPage = lazy(() => import('@/features/tours/OngoingToursPage')) const TourOrdersPage = lazy(() => import('@/features/tours/TourOrdersPage')) +const ServicesPage = lazy(() => import('@/features/services/ServicesPage')) +const BillingPage = lazy(() => import('@/features/billing/BillingPage')) export default function AppRouter() { return ( @@ -22,7 +25,11 @@ export default function AppRouter() { }> } /> } /> + } /> } /> + } /> + } /> + } /> {/* هدایت مسیرهای ناشناخته به صفحه اصلی */} } /> diff --git a/src/components/layout/AppSidebar.tsx b/src/components/layout/AppSidebar.tsx index 7d52788..332bfc1 100644 --- a/src/components/layout/AppSidebar.tsx +++ b/src/components/layout/AppSidebar.tsx @@ -37,8 +37,12 @@ function Tail({ badge, soon }: { badge?: string; soon?: boolean }) { function NavItem({ item }: { item: NavItemConfig }) { const { pathname } = useLocation() const hasChildren = Boolean(item.children && item.children.length > 0) - const isChildActive = hasChildren && item.children?.some((c) => pathname.startsWith(c.to)) - const [isOpen, setIsOpen] = React.useState(Boolean(isChildActive || pathname.startsWith('/tours'))) + const isChildActive = + hasChildren && + item.children?.some((c) => + c.to === '/tours' || c.to === '/services' ? pathname === c.to : pathname.startsWith(c.to) + ) + const [isOpen, setIsOpen] = React.useState(Boolean(isChildActive)) React.useEffect(() => { if (isChildActive) { @@ -57,15 +61,15 @@ function NavItem({ item }: { item: NavItemConfig }) { )} tooltip={item.title} > -
- - {item.title} +
+ + {item.title}
-
+
@@ -73,7 +77,10 @@ function NavItem({ item }: { item: NavItemConfig }) { {isOpen && (
{item.children.map((sub) => { - const isSubActive = sub.to === '/tours' ? pathname === '/tours' : pathname.startsWith(sub.to) + const isSubActive = + sub.to === '/tours' || sub.to === '/services' + ? pathname === sub.to + : pathname.startsWith(sub.to) return ( - {sub.title} + {sub.title} {sub.badge && ( {sub.badge} @@ -104,8 +111,8 @@ function NavItem({ item }: { item: NavItemConfig }) { const isActive = item.to ? (item.to === '/' ? pathname === '/' : pathname.startsWith(item.to)) : false const inner = ( <> - - {item.title} + + {item.title} ) @@ -155,7 +162,7 @@ function SidebarUserFooter() { onClick={logout} className="text-grey-2 hover:bg-rose-500/10 hover:text-rose-400" > - + خروج از سیستم diff --git a/src/components/layout/Topbar.tsx b/src/components/layout/Topbar.tsx index 7007e0c..0bead47 100644 --- a/src/components/layout/Topbar.tsx +++ b/src/components/layout/Topbar.tsx @@ -11,10 +11,18 @@ export default function Topbar() { // پیدا کردن عنوان صفحه بر اساس مسیر از کانفیگ let currentTitle = siteConfig.name for (const group of navigationConfig) { - const found = group.items.find((it) => it.to === pathname) - if (found) { - currentTitle = found.title - break + for (const it of group.items) { + if (it.to === pathname) { + currentTitle = it.title + break + } + if (it.children) { + const sub = it.children.find((c) => c.to === pathname) + if (sub) { + currentTitle = sub.title + break + } + } } } if (pathname === '/') currentTitle = 'داشبورد مدیریت' diff --git a/src/components/shared/FilterBar.tsx b/src/components/shared/FilterBar.tsx new file mode 100644 index 0000000..c3b1a2b --- /dev/null +++ b/src/components/shared/FilterBar.tsx @@ -0,0 +1,94 @@ +import React from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Ic } from '@/icons' +import { cn, toFa } from '@/lib/utils' + +export interface FilterBarProps { + searchQuery?: string + onSearchChange?: (query: string) => void + searchPlaceholder?: string + hasActiveFilters?: boolean + activeFilterCount?: number + onResetFilters?: () => void + children?: React.ReactNode + endContent?: React.ReactNode + className?: string +} + +export default function FilterBar({ + searchQuery, + onSearchChange, + searchPlaceholder = 'جستجو...', + hasActiveFilters = false, + activeFilterCount, + onResetFilters, + children, + endContent, + className, +}: FilterBarProps) { + return ( +
+ {/* Search Input and Primary Filters */} +
+ {onSearchChange !== undefined && ( +
+ + 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) && ( + + )} +
+ )} + + {/* Filter Dropdowns */} + {children} + + {/* Reset Filters Button */} + {hasActiveFilters && onResetFilters && ( + + )} +
+ + {/* Optional End Content (e.g. SortDropdown, ViewToggle, etc.) */} + {endContent && ( +
+ {endContent} +
+ )} +
+ ) +} diff --git a/src/components/shared/FilterSelect.tsx b/src/components/shared/FilterSelect.tsx new file mode 100644 index 0000000..21d3135 --- /dev/null +++ b/src/components/shared/FilterSelect.tsx @@ -0,0 +1,137 @@ +import React from 'react' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Ic, type IconName } from '@/icons' +import { cn } from '@/lib/utils' + +export interface FilterOption { + value: T + label: string + icon?: IconName | React.ReactNode + badge?: string | number + disabled?: boolean +} + +export interface FilterSelectProps { + value: T + onChange: (value: T) => void + options: FilterOption[] + 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({ + value, + onChange, + options, + placeholder = 'انتخاب کنید...', + label, + icon, + allValue, + size = 'default', + disabled = false, + clearable = false, + className, + triggerClassName, + contentClassName, +}: FilterSelectProps) { + 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 ( +
+ {label && ( + + {label} + + )} +
+ + + {clearable && isFiltered && ( + + )} +
+
+ ) +} diff --git a/src/components/shared/Shimmer.tsx b/src/components/shared/Shimmer.tsx new file mode 100644 index 0000000..2b674e3 --- /dev/null +++ b/src/components/shared/Shimmer.tsx @@ -0,0 +1,133 @@ +import React from 'react' +import { cn } from '@/lib/utils' +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card' +import { Ic, IconName } from '@/icons' + +export interface ShimmerProps { + /** تعداد ردیف‌ها یا آیتم‌های اسکلتون (پیش‌فرض: ۵) */ + rows?: number + count?: number + /** ارتفاع هر ردیف (پیش‌فرض: h-12) */ + rowHeight?: string + /** کلاس‌های سفارشی برای بدنه و والد */ + className?: string + /** کلاس‌های سفارشی برای هر آیتم */ + itemClassName?: string + /** آیا درون یک Card با کادر و سایه استاندارد قرار گیرد؟ */ + withCard?: boolean + /** عنوان هدر کارت در صورت استفاده از withCard */ + cardHeaderTitle?: string + /** آیکون هدر کارت */ + cardHeaderIcon?: IconName + /** متن سمت چپ هدر کارت */ + cardHeaderSub?: React.ReactNode + /** نوع نمایش: ردیفی (پیش‌فرض) یا کارت‌های گرید */ + variant?: 'list' | 'grid' | 'kpi' + /** تعداد ستون‌ها در حالت grid (پیش‌فرض: 3) */ + gridCols?: number +} + +/** + * کامپوننت یکپارچه و استاندارد Shimmer برای Loadingهای پنل مدیریت عقیله + * الگوی اصلی برگرفته از صفحه صورت‌حساب‌ها (Billing) + */ +export function Shimmer({ + rows, + count = 5, + rowHeight = 'h-12', + className, + itemClassName, + withCard = false, + cardHeaderTitle, + cardHeaderIcon = 'list', + cardHeaderSub, + variant = 'list', + gridCols = 3, +}: ShimmerProps) { + const itemCount = rows !== undefined ? rows : count + + let content: React.ReactNode + + if (variant === 'grid') { + const colClasses: Record = { + 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 = ( +
+ {Array.from({ length: itemCount }).map((_, i) => ( +
+ ))} +
+ ) + } else if (variant === 'kpi') { + content = ( +
+ {Array.from({ length: itemCount }).map((_, i) => ( +
+ ))} +
+ ) + } else { + // ردیفی استاندارد (مطابق ۱۰۰٪ با صورت‌حساب‌ها) + content = ( +
+ {Array.from({ length: itemCount }).map((_, i) => ( +
+ ))} +
+ ) + } + + if (withCard) { + return ( + + {(cardHeaderTitle || cardHeaderSub) && ( + + {cardHeaderTitle && ( + + + {cardHeaderTitle} + + )} + {cardHeaderSub && ( + + {cardHeaderSub} + + )} + + )} + + {content} + + + ) + } + + return <>{content} +} + +export const shimmer = Shimmer +export default Shimmer diff --git a/src/components/shared/SortDropdown.tsx b/src/components/shared/SortDropdown.tsx new file mode 100644 index 0000000..ffc53e5 --- /dev/null +++ b/src/components/shared/SortDropdown.tsx @@ -0,0 +1,69 @@ +import React from 'react' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Ic } from '@/icons' +import { cn } from '@/lib/utils' + +export interface SortOption { + value: T + label: string +} + +export interface SortDropdownProps { + value: T + onChange: (value: T) => void + options: SortOption[] + label?: string + placeholder?: string + size?: 'sm' | 'default' + className?: string + triggerClassName?: string +} + +export default function SortDropdown({ + value, + onChange, + options, + label = 'مرتب‌سازی:', + placeholder = 'مرتب‌سازی بر اساس...', + size = 'default', + className, + triggerClassName, +}: SortDropdownProps) { + return ( +
+ {label && ( + + {label} + + )} + +
+ ) +} diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx index 4351128..27f263f 100644 --- a/src/components/ui/alert-dialog.tsx +++ b/src/components/ui/alert-dialog.tsx @@ -23,7 +23,7 @@ function AlertDialogContent({ className, children, ...props }: React.ComponentPr diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index 0641708..da1ab22 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -4,14 +4,19 @@ import { cn } from '@/lib/utils' const DropdownMenu = DropdownMenuPrimitive.Root const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger +const DropdownMenuGroup = DropdownMenuPrimitive.Group -function DropdownMenuContent({ className, sideOffset = 6, ...props }: React.ComponentProps) { +function DropdownMenuContent({ + className, + sideOffset = 6, + ...props +}: React.ComponentProps) { return ( ) { +function DropdownMenuItem({ + className, + ...props +}: React.ComponentProps) { return ( ) { return ( ) @@ -50,7 +60,7 @@ function DropdownMenuSeparator({ }: React.ComponentProps) { return ( ) @@ -63,5 +73,5 @@ export { DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, + DropdownMenuGroup, } - diff --git a/src/components/ui/multi-select.tsx b/src/components/ui/multi-select.tsx index 247c1a4..e5ac1b8 100644 --- a/src/components/ui/multi-select.tsx +++ b/src/components/ui/multi-select.tsx @@ -129,19 +129,19 @@ export default function MultiSelect({ align="start" sideOffset={6} className={cn( - 'z-[100] max-h-56 w-[var(--radix-popover-trigger-width)] overflow-y-hidden rounded-lg border border-border-soft bg-[#27282e] p-0 shadow-[0_16px_48px_rgba(0,0,0,.5)]', + 'z-[100] max-h-56 w-[var(--radix-popover-trigger-width)] overflow-y-hidden rounded-xl border border-border bg-popover text-popover-foreground p-0 shadow-xl backdrop-blur-md', contentClassName, )} > {searchable && ( -
+
setQuery(e.target.value)} placeholder={searchPlaceholder} - className="w-full rounded-md border border-border-soft bg-white/5 px-3 py-1.5 text-[12.5px] text-grey-2 outline-none transition-colors placeholder:text-grey-4 focus:border-red-mid/50" + className="w-full rounded-lg border border-border bg-background px-3 py-1.5 text-[12.5px] text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary" />
)} diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx index d5c7477..cb630a5 100644 --- a/src/components/ui/popover.tsx +++ b/src/components/ui/popover.tsx @@ -12,7 +12,7 @@ function PopoverContent({ className, align = 'center', sideOffset = 4, ...props ) { +interface SelectTriggerProps extends React.ComponentProps { + size?: 'sm' | 'default' | 'lg' +} + +function SelectTrigger({ className, children, size = 'default', ...props }: SelectTriggerProps) { return ( - {children} +
{children}
- +
) } -function SelectContent({ className, children, position = 'popper', ...props }: React.ComponentProps) { +function SelectContent({ + className, + children, + position = 'popper', + ...props +}: React.ComponentProps) { return ( {children} @@ -50,14 +74,47 @@ function SelectItem({ className, children, ...props }: React.ComponentProps {children} + + + ) } -export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectItem } +function SelectLabel({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +function SelectSeparator({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectItem, + SelectLabel, + SelectSeparator, +} diff --git a/src/components/ui/shimmer.tsx b/src/components/ui/shimmer.tsx new file mode 100644 index 0000000..c27cdba --- /dev/null +++ b/src/components/ui/shimmer.tsx @@ -0,0 +1,2 @@ +export * from '@/components/shared/Shimmer' +export { default } from '@/components/shared/Shimmer' diff --git a/src/components/ui/single-select.tsx b/src/components/ui/single-select.tsx index 784938c..cb7336f 100644 --- a/src/components/ui/single-select.tsx +++ b/src/components/ui/single-select.tsx @@ -98,19 +98,19 @@ export default function SingleSelect({ align="start" sideOffset={6} className={cn( - 'z-[100] max-h-56 w-[var(--radix-popover-trigger-width)] overflow-y-hidden rounded-lg border border-border-soft bg-[#27282e] p-0 shadow-[0_16px_48px_rgba(0,0,0,.5)]', + 'z-[100] max-h-56 w-[var(--radix-popover-trigger-width)] overflow-y-hidden rounded-xl border border-border bg-popover text-popover-foreground p-0 shadow-xl backdrop-blur-md', contentClassName, )} > {searchable && ( -
+
setQuery(e.target.value)} placeholder={searchPlaceholder} - className="w-full rounded-md border border-border-soft bg-white/5 px-3 py-1.5 text-[12.5px] text-grey-2 outline-none transition-colors placeholder:text-grey-4 focus:border-red-mid/50" + className="w-full rounded-lg border border-border bg-background px-3 py-1.5 text-[12.5px] text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary" />
)} diff --git a/src/config/navigation.ts b/src/config/navigation.ts index de150a6..5f54e5d 100644 --- a/src/config/navigation.ts +++ b/src/config/navigation.ts @@ -30,10 +30,26 @@ export const navigationConfig: NavGroupConfig[] = [ title: 'تورها', icon: 'map', children: [ - { title: 'فهرست تورها', to: '/tours', icon: 'list' }, + { title: 'فهرست همه تورها', to: '/tours', icon: 'map' }, + { title: 'تورهای در حال برگزاری', to: '/tours/ongoing', icon: 'calendar' }, { title: 'سفارشات و رزروها', to: '/tours/orders', icon: 'clipboardCheck' }, ], }, + { + title: 'خدمات سامانه', + icon: 'sparkles', + children: [ + { title: 'مرکز خدمات (همه)', to: '/services', icon: 'grid' }, + { title: 'سیم‌کارت (Simcard)', to: '/services/simcard', icon: 'simcard' }, + { title: 'راهنمای زائر (Zayer Guide)', to: '/services/zayer-guide', icon: 'guide' }, + { title: 'صرافی آنلاین (Exchange)', to: '/services/exchange', icon: 'exchange' }, + { title: 'آداب سفر (Etiquette)', to: '/services/trip-etiquette', icon: 'etiquette' }, + { title: 'مشاوره (Consultant)', to: '/services/consultant', icon: 'headset' }, + { title: 'فروشگاه معصومه (Store)', to: '/services/store', icon: 'store' }, + { title: 'تخمین هزینه (Cost Estimate)', to: '/services/cost-estimate', icon: 'calculator' }, + ], + }, + { title: 'صورت‌حساب‌ها و مالی', to: '/billing', icon: 'receipt' }, ], }, ] diff --git a/src/features/billing/BillingPage.tsx b/src/features/billing/BillingPage.tsx new file mode 100644 index 0000000..9d9572e --- /dev/null +++ b/src/features/billing/BillingPage.tsx @@ -0,0 +1,690 @@ +import React, { useState, useEffect, useMemo, useCallback } from 'react' +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Avatar } from '@/components/ui/avatar' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import FilterSelect from '@/components/shared/FilterSelect' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { Ic } from '@/icons' +import { Shimmer } from '@/components/shared/Shimmer' +import { fetchFactors, reviewFactor, clearFactorReceipt } from './services/billing-api' +import type { FactorItem, FactorStatus, FactorService, BillingStats } from './types' +import { BillingStatusBadge } from './components/BillingStatusBadge' +import { BillingServiceBadge } from './components/BillingServiceBadge' +import { BillingDetailModal } from './components/BillingDetailModal' +import { RejectFactorDialog } from './components/RejectFactorDialog' +import { ReceiptLightboxDialog } from './components/ReceiptLightboxDialog' + +const PAGE_SIZE = 15 + +export default function BillingPage() { + const [factors, setFactors] = useState([]) + const [totalCount, setTotalCount] = useState(0) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + const [actionSuccessMessage, setActionSuccessMessage] = useState(null) + + // Filters & Pagination + const [searchQuery, setSearchQuery] = useState('') + const [selectedStatus, setSelectedStatus] = useState('all') + const [selectedService, setSelectedService] = useState('all') + const [currentPage, setCurrentPage] = useState(1) + + // Modals state + const [selectedFactorForDetail, setSelectedFactorForDetail] = useState(null) + const [selectedFactorForReject, setSelectedFactorForReject] = useState(null) + const [selectedFactorForLightbox, setSelectedFactorForLightbox] = useState(null) + + // Updating action state + const [updatingId, setUpdatingId] = useState(null) + const [isSubmittingReject, setIsSubmittingReject] = useState(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(() => { + 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 ( +
+ {/* پیام بازخورد عملیات */} + {actionSuccessMessage && ( +
+
+ + {actionSuccessMessage} +
+ +
+ )} + + {/* هدر صفحه */} +
+
+
+
+ +
+

مدیریت صورت‌حساب‌ها و فاکتورها

+ + {totalCount} فاکتور + +
+

+ بررسی فیش‌های بانکی، تایید یا رد پرداخت‌ها و پیگیری ریز فاکتورهای ۵ سرویس سامانه عقیله +

+
+ +
+ +
+
+ + {/* کارت‌های KPI شاخص‌های کلیدی */} +
+ {/* کل فاکتورها */} + + +
+

کل صورت‌حساب‌ها

+

{stats.total}

+
+
+ +
+
+
+ + {/* در انتظار بررسی فیش */} + + +
+

+ در انتظار بررسی فیش +

+

+ {stats.pendingReview} +

+
+
+ +
+
+
+ + {/* در انتظار پرداخت */} + + +
+

+ در انتظار پرداخت مشتری +

+

+ {stats.awaitingPayment} +

+
+
+ +
+
+
+ + {/* تایید شده */} + + +
+

+ پرداخت‌های تایید شده +

+

+ {stats.approved} +

+
+
+ +
+
+
+ + {/* رد شده یا منقضی */} + + +
+

+ رد شده / منقضی +

+

+ {stats.rejectedOrExpired} +

+
+
+ +
+
+
+
+ + {/* نوار جستجو و فیلترها */} + + +
+ {/* جستجوی متنی */} +
+ + { + setSearchQuery(e.target.value) + setCurrentPage(1) + }} + className="pr-9 h-9 text-xs" + /> + {searchQuery && ( + + )} +
+ + {/* فیلتر وضعیت */} +
+ +
+ + {/* فیلتر سرویس */} +
+ +
+ + {/* بازنشانی فیلترها */} + {(searchQuery || selectedStatus !== 'all' || selectedService !== 'all') && ( + + )} +
+
+
+ + {/* جدول داده‌ای فاکتورها */} + + + + + فهرست فاکتورها + + + صفحه {currentPage} از {totalPages} + + + + + {isLoading ? ( + + ) : error ? ( +
+ +

{error}

+ +
+ ) : factors.length === 0 ? ( +
+
+ +
+

صورت‌حسابی یافت نشد.

+

+ {searchQuery || selectedStatus !== 'all' || selectedService !== 'all' + ? 'با فیلترهای جستجوی فعلی موردی پیدا نشد. می‌توانید فیلترها را پاکسازی نمایید.' + : 'هنوز هیچ صورت‌حسابی در سیستم صادر نشده است.'} +

+ {(searchQuery || selectedStatus !== 'all' || selectedService !== 'all') && ( + + )} +
+ ) : ( +
+ + + + شناسه + مشتری + سرویس + مبلغ فاکتور + وضعیت + فیش واریزی + تاریخ صدور + عملیات + + + + {factors.map((factor) => { + const isPending = factor.status === 'pending' + const hasReceipt = Boolean(factor.transaction_receipt) + const user = factor.user_info + + return ( + + {/* شناسه */} + + #{factor.id} + + + {/* مشخصات مشتری */} + +
+ +
+

+ {user?.fullname || 'نامشخص'} +

+

+ {user?.email || user?.phone_number || '—'} +

+
+
+
+ + {/* سرویس */} + + + + + {/* مبلغ */} + + + {Number(factor.amount).toLocaleString()} $ + + + + {/* وضعیت */} + + + + + {/* پیش‌نمایش تصویر فیش */} + + {hasReceipt ? ( + + ) : ( + + ندارد + + )} + + + {/* تاریخ صدور */} + + {new Date(factor.created_at).toLocaleDateString('fa-IR')} + + + {/* دکمه‌های عملیات */} + +
+ {/* دکمه مشاهده جزئیات */} + + + {/* تایید سریع فیش */} + {factor.status !== 'approved' && ( + + )} + + {/* رد سریع فیش */} + {factor.status !== 'rejected' && ( + + )} +
+
+
+ ) + })} +
+
+
+ )} + + {/* صفحه‌بندی */} + {totalPages > 1 && ( +
+ + نمایش {(currentPage - 1) * PAGE_SIZE + 1} تا{' '} + {Math.min(currentPage * PAGE_SIZE, totalCount)} از {totalCount} فاکتور + +
+ + + {currentPage} / {totalPages} + + +
+
+ )} +
+
+ + {/* ۱. مودال جزئیات جامع صورت‌حساب */} + setSelectedFactorForDetail(null)} + onApprove={handleApprove} + onOpenRejectDialog={(f) => { + setSelectedFactorForDetail(null) + setSelectedFactorForReject(f) + }} + onOpenReceiptLightbox={(f) => setSelectedFactorForLightbox(f)} + onClearReceipt={handleClearReceipt} + isUpdating={updatingId === selectedFactorForDetail?.id} + /> + + {/* ۲. مودال لایت‌باکس پیش‌نمایش بزرگ فیش واریزی */} + setSelectedFactorForLightbox(null)} + onApprove={handleApprove} + onReject={(factorId) => { + setSelectedFactorForLightbox(null) + const f = factors.find((item) => item.id === factorId) || null + setSelectedFactorForReject(f) + }} + isUpdating={updatingId === selectedFactorForLightbox?.id} + /> + + {/* ۳. مودال دریافت دلیل رد فاکتور */} + setSelectedFactorForReject(null)} + onConfirm={handleConfirmReject} + isSubmitting={isSubmittingReject} + /> +
+ ) +} diff --git a/src/features/billing/components/BillingDetailModal.tsx b/src/features/billing/components/BillingDetailModal.tsx new file mode 100644 index 0000000..a9fd1f3 --- /dev/null +++ b/src/features/billing/components/BillingDetailModal.tsx @@ -0,0 +1,472 @@ +import React, { useState } from 'react' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Avatar } from '@/components/ui/avatar' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Ic } from '@/icons' +import type { FactorItem } from '../types' +import { BillingStatusBadge } from './BillingStatusBadge' +import { BillingServiceBadge } from './BillingServiceBadge' + +interface Props { + factor: FactorItem | null + open: boolean + onClose: () => void + onApprove: (factorId: number) => Promise + onOpenRejectDialog: (factor: FactorItem) => void + onOpenReceiptLightbox: (factor: FactorItem) => void + onClearReceipt?: (factorId: number) => Promise + 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 ( + !val && onClose()}> + + {/* هدر مودال */} + +
+
+
+ +
+
+ + صورت‌حساب شماره #{factor.id} + + + ثبت شده در: {new Date(factor.created_at).toLocaleString('fa-IR')} + +
+
+ +
+ + +
+
+
+ +
+ {/* کارت وضعیت انقضا یا هشدار رد */} + {factor.status === 'rejected' && factor.rejected_description && ( +
+ +
+

دلیل رد صورت‌حساب:

+

{factor.rejected_description}

+
+
+ )} + + {factor.expiration_date && ( +
+ + + مهلت پرداخت (تاریخ انقضا): + + {factor.expiration_date} +
+ )} + + {/* اطلاعات خریدار و اطلاعات مالی در ۲ ستون */} +
+ {/* کارت مشخصات خریدار */} + + + + + اطلاعات مشتری + + + +
+ +
+

+ {user?.fullname || 'نام ثبت نشده'} +

+

{user?.email || '—'}

+
+
+ +
+
+ شماره تماس: + + {user?.phone_number || '—'} + +
+
+ شناسه کاربری: + {user?.id ?? '—'} +
+
+
+
+ + {/* کارت مشخصات مالی فاکتور */} + + + + + مشخصات مالی و واریز + + + +
+ مبلغ کل فاکتور: + + {Number(factor.amount).toLocaleString()} $ + +
+ + {factor.card_number && ( +
+
+ شماره کارت مقصد: + + {factor.card_number} + +
+ +
+ )} + +
+
+ شناسه سفارش مرجع: + + {factor.order_id || factor.object_id} + +
+ {factor.description && ( +
+ توضیحات: + {factor.description} +
+ )} +
+
+
+
+ + {/* جزئیات سرویس چندشکلی (Polymorphic Details) */} + + + + + ریز اقلام و مشخصات سرویس ({factor.service.toUpperCase()}) + + + + {/* ۱. تور */} + {factor.service === 'tour' && ( +
+
+ عنوان تور: + {detail?.tour || '—'} +
+ + {detail?.passenger_data ? ( +
+

اسامی و رده سنی مسافران:

+ + {/* بزرگسالان */} + {(detail.passenger_data.adults?.count ?? 0) > 0 && ( +
+ + بزرگسال ({detail.passenger_data.adults?.count} نفر): + + + {detail.passenger_data.adults?.names.join('، ')} + +
+ )} + + {/* کودکان */} + {(detail.passenger_data.children?.count ?? 0) > 0 && ( +
+ + کودک ({detail.passenger_data.children?.count} نفر): + + + {detail.passenger_data.children?.names.join('، ')} + +
+ )} + + {/* نوزادان */} + {(detail.passenger_data.infants?.count ?? 0) > 0 && ( +
+ + نوزاد ({detail.passenger_data.infants?.count} نفر): + + + {detail.passenger_data.infants?.names.join('، ')} + +
+ )} +
+ ) : ( +

اطلاعات مسافران ثبت نشده است.

+ )} +
+ )} + + {/* ۲. سیم‌کارت */} + {factor.service === 'simcard' && ( +
+
+ {detail?.items?.map((item, idx) => ( +
+
+ + {item.simcard_title || `سیم‌کارت کد ${item.simcard_id}`} + + + (تعداد: {item.quantity}) + +
+ + {Number(item.cost).toLocaleString()} $ + +
+ ))} +
+ {detail?.total_cost && ( +
+ هزینه کل اقلام: + {detail.total_cost} $ +
+ )} +
+ )} + + {/* ۳. فروشگاه */} + {factor.service === 'shop' && ( +
+
+ {detail?.items?.map((item, idx) => ( +
+
+ + محصول {item.product_slug || `#${item.product_id}`} + + + ({item.quantity} عدد × {item.price} $) + +
+ + {Number(item.cost).toLocaleString()} $ + +
+ ))} +
+ {detail?.total_cost && ( +
+ جمع اقلام سبد خرید: + {detail.total_cost} $ +
+ )} +
+ )} + + {/* ۴. صرافی / تسریف */} + {factor.service === 'tasrif' && ( +
+
+ تبدیل ارز: + + از {detail?.from_currency} به {detail?.to_currency} + +
+
+ مبلغ درخواستی: + {detail?.amount} +
+
+ مبلغ نهایی معادل: + + {detail?.total_price} $ + +
+
+ )} + + {/* ۵. سفر اختصاصی (Custom Trip) */} + {factor.service === 'custom_trip' && ( +
+ {detail?.destinations && detail.destinations.length > 0 && ( +
+ مسیر سفر: +
+ {detail.destinations.map((d, i) => ( + + {d} + + ))} +
+
+ )} + {detail?.detail && ( +
+ خلاصه برنامه سفر: +

+ {typeof detail.detail === 'string' ? detail.detail : JSON.stringify(detail.detail)} +

+
+ )} +
+ )} +
+
+ + {/* تصویر فیش واریزی */} + + + + + فیش واریزی و رسید بانکی + + {factor.transaction_receipt && onClearReceipt && ( + + )} + + + {factor.transaction_receipt ? ( +
+
onOpenReceiptLightbox(factor)} + className="relative cursor-pointer group overflow-hidden rounded-lg border border-border/40 size-32 shrink-0 bg-black/5" + > + فیش پرداختی +
+ +
+
+ +
+

فیش بانکی توسط مشتری آپلود شده است.

+

+ جهت بررسی صحت اطلاعات و انطباق با واریز حساب، می‌توانید تصویر فیش را در سایز + کامل باز کرده و سپس وضعیت صورت‌حساب را تایید یا رد نمایید. +

+ +
+
+ ) : ( +
+ +

+ هنوز تصویر فیش واریزی توسط مشتری در سامانه بارگذاری نشده است. +

+
+ )} +
+
+
+ + {/* فوتر مودال و دکمه‌های عملیاتی ادمین */} + + + +
+ {factor.status !== 'approved' && ( + + )} + + {factor.status !== 'rejected' && ( + + )} +
+
+
+
+ ) +} diff --git a/src/features/billing/components/BillingServiceBadge.tsx b/src/features/billing/components/BillingServiceBadge.tsx new file mode 100644 index 0000000..a3e72e9 --- /dev/null +++ b/src/features/billing/components/BillingServiceBadge.tsx @@ -0,0 +1,70 @@ +import React from 'react' +import { Badge } from '@/components/ui/badge' +import { Ic } from '@/icons' +import type { FactorService } from '../types' + +interface Props { + service: FactorService + className?: string +} + +export function BillingServiceBadge({ service, className }: Props) { + switch (service) { + case 'tour': + return ( + + + تور + + ) + case 'simcard': + return ( + + + سیم‌کارت + + ) + case 'shop': + return ( + + + فروشگاه + + ) + case 'tasrif': + return ( + + + صرافی (تسریف) + + ) + case 'custom_trip': + return ( + + + سفر اختصاصی + + ) + default: + return ( + + {service} + + ) + } +} diff --git a/src/features/billing/components/BillingStatusBadge.tsx b/src/features/billing/components/BillingStatusBadge.tsx new file mode 100644 index 0000000..1821e80 --- /dev/null +++ b/src/features/billing/components/BillingStatusBadge.tsx @@ -0,0 +1,70 @@ +import React from 'react' +import { Badge } from '@/components/ui/badge' +import { Ic } from '@/icons' +import type { FactorStatus } from '../types' + +interface Props { + status: FactorStatus + className?: string +} + +export function BillingStatusBadge({ status, className }: Props) { + switch (status) { + case 'awaiting_payment': + return ( + + + در انتظار پرداخت + + ) + case 'pending': + return ( + + + در انتظار بررسی فیش + + ) + case 'approved': + return ( + + + پرداخت تایید شده + + ) + case 'rejected': + return ( + + + رد شده + + ) + case 'expired': + return ( + + + منقضی شده + + ) + default: + return ( + + {status} + + ) + } +} diff --git a/src/features/billing/components/ReceiptLightboxDialog.tsx b/src/features/billing/components/ReceiptLightboxDialog.tsx new file mode 100644 index 0000000..7fb40cf --- /dev/null +++ b/src/features/billing/components/ReceiptLightboxDialog.tsx @@ -0,0 +1,140 @@ +import React from 'react' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Ic } from '@/icons' +import type { FactorItem } from '../types' +import { BillingStatusBadge } from './BillingStatusBadge' + +interface Props { + factor: FactorItem | null + open: boolean + onClose: () => void + onApprove?: (factorId: number) => void + onReject?: (factorId: number) => void + isUpdating?: boolean +} + +export function ReceiptLightboxDialog({ + factor, + open, + onClose, + onApprove, + onReject, + isUpdating, +}: Props) { + if (!factor) return null + + const hasReceipt = Boolean(factor.transaction_receipt) + + return ( + !val && onClose()}> + + +
+ + + تصویر فیش پرداختی صورت‌حساب #{factor.id} + + + مشتری: {factor.user_info?.fullname || factor.user_info?.email || 'نامشخص'} • مبلغ:{' '} + {Number(factor.amount).toLocaleString()} $ + +
+
+ +
+
+ +
+ {hasReceipt ? ( + + ) : ( +
+
+ +
+

هنوز فیش واریزی برای این صورت‌حساب ثبت نشده است.

+

+ وضعیت فاکتور در انتظار پرداخت توسط مشتری می‌باشد. +

+
+ )} + + {factor.card_number && ( +
+ شماره کارت مقصد: + + {factor.card_number} + +
+ )} + + {factor.rejected_description && ( +
+ علت رد فاکتور: + {factor.rejected_description} +
+ )} +
+ + + + +
+ {factor.status !== 'approved' && onApprove && ( + + )} + + {factor.status !== 'rejected' && onReject && ( + + )} +
+
+
+
+ ) +} diff --git a/src/features/billing/components/RejectFactorDialog.tsx b/src/features/billing/components/RejectFactorDialog.tsx new file mode 100644 index 0000000..6508396 --- /dev/null +++ b/src/features/billing/components/RejectFactorDialog.tsx @@ -0,0 +1,105 @@ +import React, { useState } from 'react' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Textarea } from '@/components/ui/textarea' +import { Ic } from '@/icons' +import type { FactorItem } from '../types' + +interface Props { + factor: FactorItem | null + open: boolean + onClose: () => void + onConfirm: (factorId: number, reason: string) => Promise + isSubmitting?: boolean +} + +export function RejectFactorDialog({ + factor, + open, + onClose, + onConfirm, + isSubmitting, +}: Props) { + const [reason, setReason] = useState('') + const [error, setError] = useState(null) + + if (!factor) return null + + const handleConfirm = async () => { + if (!reason.trim()) { + setError('لطفاً دلیل رد فاکتور را وارد فرمایید تا به مشتری اطلاع داده شود.') + return + } + setError(null) + await onConfirm(factor.id, reason.trim()) + setReason('') + onClose() + } + + return ( + !val && onClose()}> + + + + + رد صورت‌حساب #{factor.id} + + + مشتری: {factor.user_info?.fullname || factor.user_info?.email || 'نامشخص'} • مبلغ:{' '} + {Number(factor.amount).toLocaleString()} $ + + + +
+ +