diff --git a/guide/aqila_panel_ui_design_system_standard.md b/guide/aqila_panel_ui_design_system_standard.md index 437a5a0..a59e882 100644 --- a/guide/aqila_panel_ui_design_system_standard.md +++ b/guide/aqila_panel_ui_design_system_standard.md @@ -1,174 +1,205 @@ -# سند جامع سیستم طراحی بصری و استانداردهای رابط کاربری (UI Design System & Typography Standard) +# سند جامع سیستم طراحی بصری، تایپوگرافی و معماری رسپانسیو پنل مدیریت عقیله (UI Design System & Responsive Standard) -**سامانه مدیریت و پلتفرم عقیله (Aqila)** -**مرجع سیستم:** کلاینت وب و پنل مدیریت (`aqila_panel`) و راهنمای سراسری هوش مصنوعی (AI Agent Directive) -**نسخه:** 1.0.0 +**سامانه پلتفرم و پنل مدیریت عقیله (Aqila Web Panel)** +**مرجع سیستم:** کلاینت وب (`aqila_panel`) و راهنمای سراسری هوش مصنوعی و توسعه‌دهندگان (Single Source of Truth) +**نسخه:** 2.0.0 (ویرایش جامع دسکتاپ، تبلت و موبایل) **وضعیت:** مصوب و الزامی (Mandatory Reference) --- ## ۱. هدف و دامنه کاربرد (Objective & Scope) -این سند، **مرجع واحد و غیرقابل‌تغییر (Single Source of Truth)** برای تمامی استانداردهای بصری، تایپوگرافی، رنگ‌بندی، چیدمان، فاصله‌گذاری، کامپوننت‌های تعاملی و رفتارهای چندزبانه در رابط کاربری سامانه عقیله است. +این سند، **مرجع قطعی، جامع و غیرقابل‌تغییر** برای تمامی استانداردهای بصری، تایپوگرافی، پالت رنگی خنثی، نقاط شکست (Breakpoints)، چیدمان‌های چندستونه دسکتاپ و واکنش‌گرایی موبایل (Mobile Responsiveness) در پنل مدیریت عقیله است. > [!IMPORTANT] -> **قانون الزام‌آور هوش مصنوعی (AI Agent Rule):** -> هوش مصنوعی (AI Agent) در زمان ایجاد هر صفحه جدید، ساخت هر کامپوننت، ویرایش صفحات موجود، بازطراحی فرم‌ها، فیلترها، جداول، تگ‌ها و وضعیت‌های بارگذاری (Loading)، **باید ابتدا و بدون استثنا این سند را به عنوان مرجع قطعی رعایت نماید.** هرگونه کدنویسی بصری سلیقه‌ای، استفاده از رنگ‌های رنگین‌کمانی مدادرنگی (Crayon-like)، فونت‌های متفرقه یا ساختارهای ناهمگون اکیداً ممنوع است. +> **قانون الزام‌آور هوش مصنوعی و توسعه‌دهندگان (AI Agent & Dev Directive):** +> در هنگام ایجاد هر صفحه جدید، کامپوننت، فرم، فیلتر، جدول، دیالوگ، شیت یا وضعیت بارگذاری (Loading)، **باید ابتدا و بدون استثنا کلیه بندهای این سند به عنوان استاندارد قطعی اعمال شوند.** +> هرگونه کدنویسی بصری سلیقه‌ای، استفاده از رنگ‌های رنگین‌کمانی مدادرنگی (Crayon-like UI)، فونت‌های متفرقه، پدینگ‌های هاردکدشده بدون مدیاکوئری، یا جدول‌های غیرریسپانسیو که در موبایل سرریز (Overflow) نامناسب ایجاد کنند، **اکیداً ممنوع و مردود است.** --- -## ۲. زبان، تایپوگرافی و نگارش چندزبانه (Language & Typography) +## ۲. نقاط شکست و معماری چیدمان رسپانسیو (Breakpoints & Layout Architecture) -سامانه عقیله یک سیستم بین‌المللی با تمرکز بر سه زبان اصلی **فارسی (FA)**، **عربی (AR)** و **انگلیسی (EN)** است. +پنل مدیریت عقیله باید تجربه‌ای بی‌نقص و روان از مانیتورهای عریض دسکتاپ تا گوشی‌های هوشمند ارائه دهد. نقاط شکست استاندارد پروژه مطابق با سیستم رسمی Tailwind CSS و متناسب با ابعاد داشبورد به شرح زیر تعریف شده است: ``` -┌────────────────────────────────────────────────────────────────────────┐ -│ مقیاس تایپوگرافی عقیله │ -├───────────────────┬────────────┬─────────┬──────────────┬──────────────┤ -│ سطح متنی (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 │ -└───────────────────┴────────────┴─────────┴──────────────┴──────────────┘ +┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ جدول نقاط شکست رسپانسیو پنل عقیله │ +├───────────┬──────────────┬──────────────────┬────────────────────────────────────────────────────┤ +│ نقطه شکست │ بازه عرضی │ دستگاه هدف │ رفتار چیدمان و اسکلت اصلی │ +├───────────┼──────────────┼──────────────────┼────────────────────────────────────────────────────┤ +│ xs / Base │ < 640px │ گوشی‌های هوشمند │ تک‌ستونه، سایدبار منوی همبرگری، فیلترهای استک‌شده، │ +│ │ │ (Mobile Portrait)│ تبدیل جداول به کارت/اسکرول افقی، دیالوگ باتم‌شیت │ +├───────────┼──────────────┼──────────────────┼────────────────────────────────────────────────────┤ +│ sm │ 640px-767px │ فبلت و موبایل افقی│ فرم‌های ۲ ستونه، فیلترهای منعطف، پدینگ‌های میانی │ +├───────────┼──────────────┼──────────────────┼────────────────────────────────────────────────────┤ +│ md │ 768px-1023px │ تبلت (Tablet) │ سایدبار کشویی (Overlay)، جداول فشرده با اسکرول، │ +│ │ │ │ کارت‌های آماری ۲ الی ۳ ستونه │ +├───────────┼──────────────┼──────────────────┼────────────────────────────────────────────────────┤ +│ lg │ 1024px-1279px│ لپ‌تاپ / دسکتاپ │ سایدبار ثابت راست (240px)، هدر کامل، جداول جامع │ +├───────────┼──────────────┼──────────────────┼────────────────────────────────────────────────────┤ +│ xl │ 1280px-1535px│ مانیتور استاندارد│ نمایش سه‌ستونه: سایدبار راست + محتوا + پنل اسلاید چپ│ +├───────────┼──────────────┼──────────────────┼────────────────────────────────────────────────────┤ +│ 2xl │ ≥ 1536px │ مانیتورهای عریض │ حداکثر خوانایی، گرید ۴ ستونه کارت‌های KPI و آمار │ +└───────────┴──────────────┴──────────────────┴────────────────────────────────────────────────────┘ ``` -### ۲.۱. خانواده فونت‌ها (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` و...) استفاده نشود؛ زیرا باعث تکه‌تکه شدن حروف متصل می‌شود. +### ۲.۱. ساختار شل اصلی در دسکتاپ در برابر موبایل (Responsive Shell Structure) ---- +شل اصلی اپلیکیشن (`AuthenticatedShell`) به صورت داینامیک بین دو حالت سوئیچ می‌کند: -## ۳. جهت‌چینی، ترازبندی و قواعد RTL / LTR +1. **نمای دسکتاپ و مانیتورهای بزرگ (`lg` و بالاتر / `≥ 1024px`):** + - **سایدبار ناوبری راست (`AppSidebar`):** عرض ثابت `240px`، همیشه قابل مشاهده و قابل جمع‌شدن به صورت آیکونی. + - **بخش میانی محتوا (`SidebarInset`):** شامل `Topbar` با ارتفاع `h-16` و محتوای اصلی با پدینگ `px-8 py-6`. + - **پنل جزئیات سمت چپ (`PanelSlot`):** در ابعاد `≥ 1280px` به صورت ستون کناری ثابت (Off-canvas با عرض `384px`) باز شده و محتوا را به نرمی جمع می‌کند. -### ۳.۱. اصول جهت‌چینی (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`. +2. **نمای تبلت و موبایل (`< 1024px`):** + - **سایدبار ناوبری:** به صورت کشوی مدال (`Sheet` / Drawer) با کلیک روی دکمه همبرگری هدر باز می‌شود. + - **پنل جزئیات (`PanelSlot`):** به صورت `Sheet` تمام‌صفحه یا با عرض `w-[min(88vw,384px)]` روی صفحه شناور می‌شود. + - **پدینگ محتوا:** پدینگ به `px-4 py-4 pb-12` کاهش می‌یابد تا بیشترین فضای مفید به داده‌ها اختصاص یابد. -### ۳.۲. آیکون‌ها در RTL -- آیکون‌های هدایتی (مانند پیکان بازگشت، بعدی/قبلی، Breadcrumbs) باید در حالت RTL متناسب با جهت جریان اطلاعات بچرخند یا به صورت طبیعی بر اساس `start/end` قرار گیرند. -- آیکون‌های متقارن (مانند جستجو، تقویم، تنظیمات، سبد خرید) نیاز به چرخش ندارند. +```tsx +// ساختار استاندارد کانتینر اصلی محتوای هر صفحه +
+ {/* هدر صفحه: در موبایل عمودی و در دسکتاپ افقی */} +
+
+

+ {pageTitle} +

+

+ {pageDescription} +

+
+
+ {headerActions} +
+
+ + {/* بدنه محتوا */} + {children} +
+``` --- -## ۴. پالت رنگی خنثی، مینیمال و معنادار (Neutral & Semantic Color System) +## ۳. سیستم تایپوگرافی و مقیاس متنی رسپانسیو (Responsive Typography Matrix) -> [!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 │ -└───────────────────┴──────────────────────┴─────────────────────────────┘ +┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ مقیاس تایپوگرافی تطبیقی (Responsive Typography) │ +├───────────────────┬──────────────┬──────────────┬─────────┬──────────────┬───────────────────────┤ +│ سطح متنی (Role) │ سایز موبایل │ سایز دسکتاپ │ وزن (Wt)│ Line-Height │ کلاس Tailwind پیشنهادی│ +├───────────────────┼──────────────┼──────────────┼─────────┼──────────────┼───────────────────────┤ +│ Display / H1 │ 20px (1.25r) │ 24px (1.5r) │ 900/Bold│ 1.35 (32px) │ text-xl md:text-2xl │ +│ Page Title / H2 │ 18px (1.12r) │ 20px (1.25r) │ 800/Bold│ 1.4 (28px) │ text-lg md:text-xl │ +│ Section Title / H3│ 15px (.937r) │ 16px (1.0r) │ 700/Bold│ 1.45 (24px) │ text-[15px] md:text-base│ +│ Card Title / H4 │ 13px (.812r) │ 14px (.875r) │ 700/Bold│ 1.5 (21px) │ text-xs sm:text-sm │ +│ Body Text (اصلی) │ 13px (.812r) │ 14px (.875r) │ 500/Med │ 1.6 (22px) │ text-xs sm:text-sm │ +│ Secondary / Muted │ 11px (.687r) │ 12px (.75r) │ 400/Reg │ 1.5 (18px) │ text-[11px] sm:text-xs│ +│ Caption / Footnote│ 10px (.625r) │ 11px (.687r) │ 500/Med │ 1.4 (16px) │ text-[10px] sm:text-[11px]│ +│ Badge / Tag Label │ 10px (.625r) │ 11px (.687r) │ 700/Bold│ 1.0 (12px) │ text-[10px] sm:text-[11px]│ +│ Monospace / Stats │ 14px-20px │ 16px-24px │ 900/Mono│ 1.2 (Tabular)│ font-mono tabular-nums│ +└───────────────────┴──────────────┴──────────────┴─────────┴──────────────┴───────────────────────┘ ``` -### ۴.۱. رنگ‌های معنایی و وضعیت‌ها (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`). هرگز برای تگ‌های معمولی از رنگ‌های رنگین‌کمانی استفاده نشود. +### ۳.۱. فونت‌ها و ترکیب دوزبانه (Fonts & Multi-Language) + +1. **فارسی و عربی (RTL):** + - فونت رسمی: `Vazirmatn` (وزن‌های 400، 500، 600، 700، 800، 900). + - برای خطوط فارسی و عربی به هیچ عنوان از `letter-spacing` (کلاس‌های `tracking-wide` یا `tracking-widest`) استفاده نشود زیرا اتصالات حروف شکسته می‌شود. +2. **انگلیسی و متون فنی (LTR):** + - فونت استاندارد: `Inter, system-ui, sans-serif`. +3. **ارقام مالی، شناسه‌ها، مبالغ و کدها:** + - منحصراً از کلاس `font-mono tabular-nums` همراه با جهت `dir="ltr"` برای نمایش ارقام انگلیسی، قیمت‌ها (`$1,250` یا `25,000 IQD`)، شناسه‌های سیستمی (`#TRIP-1082`) و تاریخ‌های لاگ استفاده شود. + - در عناوین و متون فارسی توضیحی از متد کمکی `toFa(number)` برای نمایش بومی اعداد بهره گرفته شود. --- -## ۵. مقیاس فواصل، ابعاد و شعاع انحناها (Spacing, Sizing & Radii) +## ۴. مقیاس فواصل، پدینگ‌ها، ابعاد و ارگونومی لمسی (Spacing & Touch Targets) -سامانه از سیستم فاصله‌گذاری مضرب ۴ و ۸ پیکسلی تبعیت می‌کند: +رعایت سیستم فاصله‌گذاری مضرب ۴ و ۸ پیکسلی برای حفظ ریتم عمودی و افقی الزامی است: ``` -┌────────────────────────────────────────────────────────────────────────┐ -│ مقیاس فواصل استاندارد (Spacing) │ -├─────────────┬─────────────┬────────────────────────────────────────────┤ -│ توکن │ مقدار (px) │ کاربرد و موارد مصرف │ -├─────────────┼─────────────┼────────────────────────────────────────────┤ -│ space-xs │ 4px │ فاصله بین آیکون و متن، پدینگ میکرو │ -│ space-sm │ 8px │ فاصله عناصر درون یک کامپوننت، گپ دکمه‌ها │ -│ space-md │ 12px │ فاصله آیتم‌های لیست، پدینگ فیلدها │ -│ space-lg │ 16px │ پدینگ افقی صفحات، پدینگ پیش‌فرض کارت‌ها │ -│ space-xl │ 20px │ فاصله بین بخش‌ها و ستون‌های فرم‌ها │ -│ space-2xl │ 24px │ گپ بین کارت‌های اصلی و سکشن‌های داشبورد │ -│ space-3xl │ 32px │ فاصله عمودی سکشن‌های بزرگ │ -└─────────────┴─────────────┴────────────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ ماتریس فواصل و پدینگ‌های رسپانسیو │ +├─────────────┬─────────────┬─────────────┬────────────────────────────────────────────────────────┤ +│ نام توکن │ موبایل (px) │ دسکتاپ (px) │ کاربرد و موارد استفاده │ +├─────────────┼─────────────┼──────────────────────────────────────────────────────────────────────┤ +│ Page Padding│ 16px (px-4) │ 32px (px-8) │ پدینگ افقی دیواره‌های اصلی صفحه به محتوا │ +│ Card Padding│ 14px (p-3.5)│ 24px (p-6) │ پدینگ داخلی کارت‌های آماری، فرم‌ها و جداول │ +│ Form Gap │ 12px (gap-3)│ 16px (gap-4)│ فاصله عمودی و افقی بین فیلدهای ورودی فرم │ +│ Section Gap │ 16px (gap-4)│ 24px (gap-6)│ فاصله بین بلوک‌های اصلی و سکشن‌های صفحه │ +│ Table Cell │ 12px (p-3) │ 16px (p-4) │ پدینگ سلول‌های جدول داده‌ها │ +└─────────────┴─────────────┴─────────────┴────────────────────────────────────────────────────────┘ ``` -### ۵.۱. شعاع انحناها (Border Radii) -- **تگ‌ها، بج‌ها و المان‌های کوچک:** `rounded-lg` (8px الی 10px) -- **فیلدهای ورودی و دکمه‌ها:** `rounded-xl` (12px) -- **کارت‌ها، جداول و بخش‌ها:** `rounded-2xl` (16px) -- **دیالوگ‌ها، پاپ‌آپ‌ها و مودال‌ها:** `rounded-2xl` الی `rounded-3xl` (20px - 24px) -- **بج‌های کپسولی / قرصی:** `rounded-full` (9999px) +### ۴.۱. حداقل ابعاد تعاملی برای لمس در موبایل (Mobile Touch Targets) +در موبایل، کاربر با انگشت با صفحه تعامل دارد نه با نشانگر دقیق ماوس. لذا: +- **ارتفاع دکمه‌ها و فیلدها:** حداقل `38px` الی `44px` در موبایل (`h-10 sm:h-9`). +- **آیکون‌باتن‌های عملیاتی:** کادر کلیک‌پذیر حداقل `36x36px` با پدینگ امن (`size-9 p-1.5`). +- **فاصله بین دکمه‌های مجاور:** حداقل `8px` (`gap-2`) تا از کلیک اشتباه جلوگیری شود. + +### ۴.۲. شعاع انحنای المان‌ها (Border Radii) +- **تگ‌ها، بج‌ها و المان‌های ریز:** `rounded-lg` (8px الی 10px). +- **فیلدهای ورودی و دکمه‌ها:** `rounded-xl` (12px الی 14px). +- **کارت‌ها، سکشن‌ها و جداول:** `rounded-2xl` (16px). +- **دیالوگ‌ها در دسکتاپ:** `rounded-2xl` الی `rounded-3xl` (20px الی 24px). +- **دیالوگ‌ها در موبایل (Bottom Sheet):** لبه‌های بالا گرد `rounded-t-2xl` و لبه‌های پایین صاف `rounded-b-none`. +- **بج‌های کپسولی / قرصی:** `rounded-full` (9999px). --- -## ۶. استاندارد جامع تگ‌ها و بج‌های وضعیتی (Tags & Status Badges) +## ۵. پالت رنگی خنثی، بدون مدادرنگی (Neutral Slate Color Palette) + +> [!CAUTION] +> **قانون قطعی ۹۰٪ خنثی (Strict 90% Neutral Rule):** +> بیش از **۹۰ درصد** محیط بصری پنل مدیریت باید از رنگ‌های خنثی (مشکی تیره مات، زغالی، اسلیت، خاکستری، سفید) تشکیل شود. استفاده از رنگ‌های تند و رنگین‌کمانی برای پس‌زمینه کارت‌ها، هدرها، ستون‌های جدول یا تگ‌های معمولی اکیداً ممنوع است. + +``` +┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ پالت رنگی رسمی پنل مدیریت عقیله │ +├───────────────────┬───────────────────────────────┬──────────────────────────────────────────────┤ +│ توکن معنایی │ تم دارک (Dark Mode - پیش‌فرض) │ تم لایت (Light Mode) │ +├───────────────────┼───────────────────────────────┼──────────────────────────────────────────────┤ +│ background │ #18191D (تیره مات و عمیق) │ #F8FAFC (سفید-خاکستری لطیف) │ +│ surface / card │ #22252C (کارت پایه زغالی) │ #FFFFFF (سفید خالص) │ +│ popover / panel │ #1E2128 (پنل‌های اسلاید) │ #FFFFFF │ +│ border │ rgba(255, 255, 255, 0.12) │ rgba(15, 23, 42, 0.12) │ +│ border-soft │ rgba(255, 255, 255, 0.07) │ rgba(15, 23, 42, 0.07) │ +│ primary (برنز برند)│ #D09460 │ #D09460 │ +│ primary-muted │ rgba(208, 148, 96, 0.15) │ rgba(208, 148, 96, 0.10) │ +│ text-foreground │ #FFFFFF │ #0F172A │ +│ text-muted │ #8B8B8B │ #64748B │ +│ text-subtle │ #4D4D4D │ #94A3B8 │ +└───────────────────┴───────────────────────────────┴──────────────────────────────────────────────┘ +``` -کلیه تگ‌ها باید ساختاری منسجم و یکدست داشته باشند: +### ۵.۱. بج‌های وضعیتی استاندارد (Semantic Badges) +رنگ‌های اشباع منحصراً و صرفاً برای ۴ حالت سیستم مجاز هستند: ```tsx -// ۱. تگ خنثی استاندارد (اطلاعات عمومی، دسته‌بندی، کشور، نسخه) - - عراق +// ۱. تگ خنثی معمولی (دسته‌بندی، کشور، نسخه، برچسب‌ها) - بیش از ۸۰٪ تگ‌ها + + بلیت و پرواز -// ۲. بج وضعیتی تایید شده / موفق (Approved / Success) - +// ۲. وضعیت موفق / فعال / تایید شده (Success) + تأیید شده -// ۳. بج وضعیتی در انتظار / بررسی (Pending / Warning) - +// ۳. وضعیت هشدار / در حال پردازش (Warning / Pending) + - در انتظار پرداخت + در انتظار بررسی -// ۴. بج وضعیتی رد شده / خطا (Rejected / Error) - +// ۴. وضعیت خطا / لغوشده / حذف (Error / Destructive) + رد شده @@ -176,112 +207,225 @@ --- -## ۷. استانداردهای کامپوننت‌های پایه (Core Components Standard) +## ۶. استاندارد جامع جداول داده‌ای و تطبیق با موبایل (Responsive Data Tables) -### ۷.۱. دکمه‌ها (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`. +### الگوی اول: نمایش دوگانه (جدول در دسکتاپ + لیست کارت در موبایل - الگو طلایی) +در این الگو، برای صفحات موبایل (`< 768px`) به جای جدول پهن ناخوانا، داده‌ها به صورت کارت‌های مدرن و شکیل نمایش داده می‌شوند: + +```tsx +{/* نمای دسکتاپ و تبلت (md به بالا) */} +
+
+ + + + + + + + + + + + {items.map((item) => ( + + + + + + + + ))} + +
عنوان سرویسدسته‌بندیمبلغوضعیتعملیات
{item.title}{item.category}{toFa(item.price)} د.ع{/* بج وضعیت */}{/* دکمه‌های ویرایش/حذف */}
+
+
+ +{/* نمای موبایل (زیر md) - کارت‌های مجزا */} +
+ {items.map((item) => ( + +
+
+

{item.title}

+ {item.category} +
+
{/* بج وضعیت */}
+
+
+ مبلغ کل: + {toFa(item.price)} د.ع +
+
+ + +
+
+ ))} +
+``` -### ۷.۳. جداول داده‌ای (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`. +### الگوی دوم: جدول با اسکرول افقی نرم و ستون عملیات چسبان (Sticky Action Column) +چنانچه اطلاعات جدول بسیار متراکم است، کل جدول درون کانتینر `overflow-x-auto` قرار گرفته و ستون عملیات در سمت چپ (یا انتهای RTL) چسبان (`sticky end-0`) می‌ماند: -### ۷.۴. کارت‌های شاخص آماری (KPI Metric Cards) -- ساختار: کانتینر خنثی با کادر ملایم، عنوان کوچک در بالا (`text-xs text-muted-foreground`)، عدد بزرگ مونو در مرکز (`text-2xl font-black font-mono`) و توضیح کوتاه در زیر. -- رنگ‌آمیزی: زمینه ملایم بدون اشباع شدید رنگی. +```tsx +
+
+ + {/* ردیف‌ها */} +
+
+
+``` --- -## ۸. استاندارد لودینگ و اسکلتون (Shimmer / Loading Skeleton Standard) +## ۷. نوار فیلترها، جستجو و فرم‌ها در وب و موبایل (Filters & Forms) + +### ۷.۱. نوار فیلترهای بالا (Filter Bar) +- **دسکتاپ (`sm` به بالا):** چینش افقی `flex flex-row items-center justify-between gap-3`. فیلد سرچ گسترده و فیلترهای دراپ‌داون در امتداد یک خط. +- **موبایل:** چینش عمودی استک‌شده `flex flex-col gap-2.5 items-stretch`. سرچ تمام‌عرض در بالا و دکمه‌های فیلتر/اکشن در زیر. + +```tsx +
+ {/* بخش جستجو و انتخابگرها */} +
+
+ + +
+
+ {/* دراپ‌داون وضعیت یا سرویس */} +
+
+ + {/* دکمه عملیات اصلی */} + +
+``` + +### ۷.۲. گرید فیلدهای فرم (Responsive Form Grids) +فرم‌ها در دیالوگ‌ها یا صفحات باید به صورت واکنش‌گرا ستون‌بندی شوند: +- موبایل: ۱ ستونه (`grid-cols-1`). +- تبلت و فبلت: ۲ ستونه (`sm:grid-cols-2`). +- دسکتاپ عریض: ۳ ستونه (`lg:grid-cols-3`). + +```tsx +
+
+ + +
+
+ + +
+
+ + +
+
+``` + +--- + +## ۸. دیالوگ‌ها، مودال‌ها و باتم‌شیت‌ها (Modals & Bottom Sheets) + +دیالوگ‌های پنل عقیله باید در دسکتاپ در مرکز تصویر باز شده و در موبایل به صورت سازگار با انگشت دست عمل کنند: + +```tsx + + + {/* هدر ثابت دیالوگ */} + + + ویرایش مشخصات سرویس + + + اطلاعات را به دقت در هر سه زبان تکمیل نمایید. + + + + {/* محتوای دارای اسکرول عمودی */} +
+ {/* فیلدهای ورودی */} +
+ + {/* فوتر چسبان دیالوگ با دکمه‌های منظم */} + + + + +
+
+``` + +--- + +## ۹. استاندارد سراسری اسکلتون لودینگ (Shimmer Loading Standard) > [!IMPORTANT] -> **الگوی واحد و سراسری لودینگ (Unified Shimmer Standard):** -> لودینگ تمامی صفحات، جداول و کارت‌ها در سامانه عقیله (هم در پنل وب و هم در اپلیکیشن فلاتر) باید منحصراً از اسکلتون یکپارچه شیمر با پالت خنثی (Neutral Slate) و ریتم پالس متناسب تبعیت کنند. هرگونه اسپینر ناهماهنگ، کانتینرهای بدون انیمیشن یا استفاده از رنگ‌های متفرقه در لودینگ اکیداً ممنوع است. - -### ۸.۱. پالت رنگی و زمان‌بندی استاندارد شیمر (Shimmer Color Tokens & Timing) -برای جلوگیری از چندپارگی بصری و پرهیز از لودینگ‌های کدر یا مدادرنگی، رنگ‌های شیمر در تمام کلاینت‌ها قفل شده است: -- **تم روشن (Light Mode):** - - رنگ پایه (Base): `#E2E8F0` (توکن `outline` / `muted-200`) - - رنگ درخشش (Highlight): `#F8FAFC` (توکن `muted-50`) -- **تم تاریک (Dark Mode):** - - رنگ پایه (Base): `#22252C` (توکن `card-base`) - - رنگ درخشش (Highlight): `#333742` (توکن `card-highlight`) -- **دوره تناوب انیمیشن (Cycle Period):** دقیقاً `1500ms` (۱.۵ ثانیه) برای ایجاد پالس موجی لطیف، طبیعی و یکنواخت. - -### ۸.۲. مشخصات پیاده‌سازی در وب و پنل مدیریت (`aqila_panel`) -- **کامپوننت مرجع:** `@/components/shared/Shimmer` -- **کانتینر:** `p-8 space-y-3` (یا درون `Card` با بوردر استاندارد). -- **ردیف‌ها:** `h-12 bg-muted/30 animate-pulse rounded-lg`. -- **رفتار در تم لایت و دارک:** پالس ملایم با کنتراست طبیعی متناسب با توکن `--muted`. +> **الگوی واحد لودینگ:** تمام صفحات، جداول و کارت‌ها در هنگام دریافت اطلاعات باید از کامپوننت سراسری `@/components/shared/Shimmer` با انیمیشن پالس و پالت خنثی استفاده کنند. + +### ۹.۱. توکن‌های رنگی و دوره تناوب شیمر +- **تم لایت:** رنگ پایه `#E2E8F0`، هایلایت `#F8FAFC`. +- **تم دارک:** رنگ پایه `#22252C`، هایلایت `#333742`. +- **دوره انیمیشن:** دقیقاً `1500ms` با انیمیشن ملایم پالس. ```tsx -// نحوه فراخوانی استاندارد در صفحات پنل import { Shimmer } from '@/components/shared/Shimmer' {isLoading ? ( - +
+ {/* اسکلتون کارت‌های آماری */} +
+ + + + +
+ {/* اسکلتون جدول داده‌ها */} + +
) : ( - + )} ``` -### ۸.۳. مشخصات پیاده‌سازی در اپلیکیشن فلاتر (`aqila_flutter`) -- **مرجع واحد هماهنگ‌کننده موج (Single Source of Truth):** ویجت **`AppShimmer`** در مسیر: - `lib/core/ui_kit/atoms/app_shimmer.dart` -- **ویجت‌های اتمیک درون اسکلتون:** - 1. **`ShimmerBox`:** جهت ترسیم کارت‌ها، بنرها، تصاویر و دکمه‌ها (`AppRadii.radiusSm` تا `AppRadii.radiusLg`). - 2. **`ShimmerLine`:** جهت ترسیم سطرهای متنی و عناوین با ارتفاع پیش‌فرض `14px` و انحنای `4px`. - 3. **`ShimmerCircle`:** جهت ترسیم آواتارها، آیکون‌ها و بج‌های دایره‌ای. -- **وضعیت ویجت `ShimmerWidget`:** - ویجت `ShimmerWidget` در مسیر `lib/core/widgets/shimmer_widget.dart` صرفاً یک **آداپتور سازگاری گذشته‌نگر (Legacy Adapter)** است تا کدهای قدیمی متوقف نشوند. در کلیه فیچرها، صفحات و پول‌ریکوئست‌های جدید باید **منحصراً از `AppShimmer`** استفاده شود. - -```dart -// نحوه فراخوانی استاندارد در اپلیکیشن فلاتر -import 'package:tourism/core/ui_kit/atoms/app_shimmer.dart'; - -return const AppShimmer( - child: Column( - children: [ - ShimmerBox(width: double.infinity, height: 160, borderRadius: 16), - SizedBox(height: 12), - ShimmerLine(width: 140, height: 16), - SizedBox(height: 8), - ShimmerLine(width: 220, height: 12), - ], - ), -); -``` - --- -## ۹. دسترسی‌پذیری و تم‌های تاریک و روشن (Accessibility, Light & Dark Mode) +## ۱۰. دسترسی‌پذیری، ارگونومی و مناطق امن (Accessibility & Safe Areas) -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` باشند تا ناوبری با کیبورد به درستی کار کند. +1. **کنتراست استاندارد متون (WCAG AA):** + - متن اصلی در برابر پس‌زمینه حداقل نسبت `4.5:1` داشته باشد. + - از متن‌های خاکستری کم‌کنتراست در تم دارک پرهیز شود. +2. **منطقه امن دکمه‌های پایین موبایل (Safe Area Inset):** + - نوارهای عملیاتی چسبان پایین صفحه (Sticky Bottom Bar) باید دارای پدینگ `pb-[calc(0.75rem+env(safe-area-inset-bottom))]` باشند تا با نوار هوم‌بار آیفون تداخل پیدا نکنند. +3. **حالت فوکوس کیبورد (Focus Visibility):** + - تمامی دکمه‌ها و فیلدها باید دارای کادر فوکوس واضح با `focus-visible:ring-2 focus-visible:ring-primary/50` باشند. --- -## ۱۰. چک‌لیست طلایی هوش مصنوعی قبل از تحویل هر تسک UI +## ۱۱. چک‌لیست طلایی هوش مصنوعی قبل از تحویل هر تسک وب و پنل (AI Golden Checklist) -قبل از تحویل هرگونه خروجی یا ثبت تغییرات در رابط کاربری، Agent باید این چک‌لیست را بررسی کند: +قبل از نهایی کردن هرگونه تغییر در پنل مدیریت، بررسی موارد زیر الزامی است: -- [ ] **بررسی عدم استفاده از رنگ‌های مدادرنگی:** آیا از رنگ‌های تند غیرضروری برای کارت‌ها و تگ‌های عادی پرهیز شده است؟ -- [ ] **بررسی تایپوگرافی چندزبانه:** آیا متون فارسی از `Vazirmatn` و اعداد/کدها از `font-mono` تبعیت می‌کنند؟ -- [ ] **بررسی ابعاد و فواصل:** آیا پدینگ‌ها و مارجین‌ها مضرب ۴ و ۸ بوده و پدینگ افقی صفحات `16px` است؟ -- [ ] **بررسی وضعیت لودینگ:** آیا لودینگ از کامپوننت استاندارد `Shimmer` با انیمیشن پالس استفاده می‌کند؟ -- [ ] **بررسی دوگانگی تم:** آیا صفحه در هر دو حالت Light Mode و Dark Mode کنتراست و خوانایی کامل دارد؟ -- [ ] **بررسی اصطلاحات و کلیدها:** آیا منطق برنامه صرفاً با کلیدهای انگلیسی کار کرده و هیچ متنی هاردکد نشده است؟ +- [ ] **۱. پالت خنثی و بدون رنگین‌کمان:** آیا بیش از ۹۰٪ المان‌ها دارای رنگ‌های خنثی بوده و از رنگ‌های فسفری/تند پرهیز شده است؟ +- [ ] **۲. سازگاری کامل با موبایل (Responsive):** آیا صفحه در ابعاد موبایل (`375px` و `414px`) تست شده و اسکرول افقی نامطلوب (Horizontal Overflow) ندارد؟ +- [ ] **۳. تایپوگرافی چندزبانه:** آیا فونت متون فارسی/عربی `Vazirmatn` و ارقام/مبالغ/کدها `font-mono tabular-nums` با `dir="ltr"` است؟ +- [ ] **۴. ابعاد کلیک‌پذیر (Touch Targets):** آیا دکمه‌ها و فیلدها در موبایل ارتفاع کافی (حداقل `38px` تا `44px`) دارند؟ +- [ ] **۵. لودینگ یکدست (Shimmer):** آیا در حالت لودینگ از کامپوننت استاندارد `Shimmer` با پالت خنثی استفاده شده است؟ +- [ ] **۶. دوگانگی تم:** آیا کنتراست صفحه در هر دو حالت Light Mode و Dark Mode خوانا و استاندارد است؟ +- [ ] **۷. منطق بیزینس و کلیدهای انگلیسی:** آیا تمامی مقایسه‌ها و شرط‌های کد بر اساس کلیدهای استاندارد انگلیسی API پیاده شده‌اند؟ diff --git a/src/app/router.tsx b/src/app/router.tsx index 5affb44..ab23c2a 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -8,7 +8,6 @@ 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')) @@ -26,9 +25,10 @@ export default function AppRouter() { } /> } /> } /> - } /> - } /> + } /> + } /> } /> + } /> } /> {/* هدایت مسیرهای ناشناخته به صفحه اصلی */} diff --git a/src/components/layout/AppSidebar.tsx b/src/components/layout/AppSidebar.tsx index 4431c9d..b39f0dc 100644 --- a/src/components/layout/AppSidebar.tsx +++ b/src/components/layout/AppSidebar.tsx @@ -14,19 +14,59 @@ import { SidebarMenuBadge, } from '@/components/ui/sidebar' import { useAuth, useLogout } from '@/features/auth/use-auth' -import { navigationConfig } from '@/config/navigation' +import { getNavigationConfig, type NavItemConfig, type NavSubItemConfig } from '@/config/navigation' import { siteConfig } from '@/config/site' import { AqilaLogo } from '@/components/AqilaLogo' +import { useTranslation } from '@/i18n' +import { useBillingBadgeStore } from '@/stores/billing-badge' import * as React from 'react' import { cn } from '@/lib/utils' -import type { NavItemConfig } from '@/config/navigation' + +function BillingSidebarBadges() { + const { pendingReviewCount, awaitingPaymentCount, fetchCounts } = useBillingBadgeStore() + const { t } = useTranslation() + + React.useEffect(() => { + fetchCounts() + // Poll every 30 seconds for live updates + const interval = setInterval(fetchCounts, 30000) + return () => clearInterval(interval) + }, [fetchCounts]) + + if (pendingReviewCount === 0 && awaitingPaymentCount === 0) return null + + return ( +
+ {/* ۱. دایره نارنجی: در انتظار بررسی ادمین (فیش پرداخت توسط کاربر آپلود شده) */} + {pendingReviewCount > 0 && ( + + {pendingReviewCount} + + )} + + {/* ۲. دایره آبی: در انتظار پرداخت کاربر */} + {awaitingPaymentCount > 0 && ( + + {awaitingPaymentCount} + + )} +
+ ) +} function Tail({ badge, soon }: { badge?: string; soon?: boolean }) { + const { t } = useTranslation() if (soon) { return ( - به‌زودی + {t('navigation.soon')} ) } @@ -34,22 +74,141 @@ function Tail({ badge, soon }: { badge?: string; soon?: boolean }) { return null } +function checkSubItemActive(sub: NavSubItemConfig, pathname: string): boolean { + if (sub.to) { + if (sub.to === '/tours' || sub.to === '/services') { + if (pathname === sub.to) return true + } else if (pathname === sub.to || pathname.startsWith(sub.to + '/')) { + return true + } + } + if (sub.children && sub.children.length > 0) { + return sub.children.some((c) => checkSubItemActive(c, pathname)) + } + return false +} + +function SubItem({ sub, pathname, dir }: { sub: NavSubItemConfig; pathname: string; dir: 'rtl' | 'ltr' }) { + const hasGrandChildren = Boolean(sub.children && sub.children.length > 0) + const isGrandChildActive = Boolean(hasGrandChildren && sub.children?.some((c) => checkSubItemActive(c, pathname))) + const [isOpen, setIsOpen] = React.useState(isGrandChildActive) + + React.useEffect(() => { + if (isGrandChildActive) { + setIsOpen(true) + } + }, [isGrandChildActive]) + + if (hasGrandChildren && sub.children) { + return ( +
+ + + {isOpen && ( +
+ {sub.children.map((grand) => { + const isGrandActive = grand.to + ? grand.to === '/services' || grand.to === '/tours' + ? pathname === grand.to + : pathname === grand.to || pathname.startsWith(grand.to + '/') + : false + return ( + + {grand.icon && } + {grand.title} + {grand.badge && ( + + {grand.badge} + + )} + + ) + })} +
+ )} +
+ ) + } + + const isSubActive = + sub.to === '/tours' || sub.to === '/services' + ? pathname === sub.to + : Boolean(sub.to && (pathname === sub.to || pathname.startsWith(sub.to + '/'))) + + return ( + + {sub.icon && } + {sub.title} + {sub.badge && ( + + {sub.badge} + + )} + + ) +} + function NavItem({ item }: { item: NavItemConfig }) { const { pathname } = useLocation() + const { dir } = useTranslation() const hasChildren = Boolean(item.children && item.children.length > 0) const isChildActive = - hasChildren && - item.children?.some((c) => - c.to === '/tours' || c.to === '/services' ? pathname === c.to : pathname.startsWith(c.to) - ) + hasChildren && item.children?.some((c) => checkSubItemActive(c, pathname)) const isAlwaysOpen = Boolean(item.alwaysOpen) const [isOpen, setIsOpen] = React.useState(isAlwaysOpen || Boolean(isChildActive)) React.useEffect(() => { - if (isAlwaysOpen || isChildActive) { + if (isChildActive) { setIsOpen(true) } - }, [isAlwaysOpen, isChildActive]) + }, [isChildActive]) if (hasChildren && item.children) { return ( @@ -79,45 +238,34 @@ function NavItem({ item }: { item: NavItemConfig }) { {(isAlwaysOpen || isOpen) && ( -
- {item.children.map((sub) => { - const isSubActive = - sub.to === '/tours' || sub.to === '/services' - ? pathname === sub.to - : pathname.startsWith(sub.to) - return ( - - - {sub.title} - {sub.badge && ( - - {sub.badge} - - )} - - ) - })} +
+ {item.children.map((sub, idx) => ( + + ))}
)} ) } - const isActive = item.to ? (item.to === '/' ? pathname === '/' : pathname.startsWith(item.to)) : false + const isActive = item.to + ? item.to === '/' || item.to === '/services' || item.to === '/tours' + ? pathname === item.to + : pathname === item.to || pathname.startsWith(item.to + '/') + : false + const isBilling = item.to === '/billing' const inner = ( <> {item.title} - + {isBilling ? ( + + ) : ( + + )} ) @@ -140,8 +288,9 @@ function NavItem({ item }: { item: NavItemConfig }) { function SidebarUserFooter() { const { user } = useAuth() const logout = useLogout() + const { t } = useTranslation() - const firstLetter = user?.name?.[0] ?? user?.email?.[0]?.toUpperCase() ?? 'م' + const firstLetter = user?.name?.[0] ?? user?.email?.[0]?.toUpperCase() ?? 'A' return ( @@ -152,7 +301,7 @@ function SidebarUserFooter() {
- {user?.name?.trim() || 'مدیر کل سیستم'} + {user?.name?.trim() || t('navigation.adminRole')}
{user?.email ?? 'admin@gmail.com'} @@ -162,12 +311,12 @@ function SidebarUserFooter() { - خروج از سیستم + {t('navigation.logout')} @@ -176,8 +325,11 @@ function SidebarUserFooter() { } export default function AppSidebar() { + const { t, dir } = useTranslation() + const navConfig = React.useMemo(() => getNavigationConfig(t), [t]) + return ( - +
@@ -185,7 +337,7 @@ export default function AppSidebar() {
- {siteConfig.name} + {t('navigation.brand')}
@@ -193,12 +345,12 @@ export default function AppSidebar() { - {navigationConfig.map((group, idx) => ( + {navConfig.map((group, idx) => ( {group.title ? {group.title} : null} {group.items.map((item) => ( - + ))} diff --git a/src/components/layout/LanguageSwitcher.tsx b/src/components/layout/LanguageSwitcher.tsx new file mode 100644 index 0000000..0607414 --- /dev/null +++ b/src/components/layout/LanguageSwitcher.tsx @@ -0,0 +1,67 @@ +import React from 'react' +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, +} from '@/components/ui/dropdown-menu' +import { Button } from '@/components/ui/button' +import { Ic } from '@/icons' +import { useTranslation, SupportedLanguage } from '@/i18n' + +export default function LanguageSwitcher() { + const { language, setLanguage, languages, t, currentLanguageInfo } = useTranslation() + + return ( + + + + + + + + {t('topbar.selectLanguage')} + + + + {languages.map((item) => { + const isSelected = item.code === language + return ( + setLanguage(item.code as SupportedLanguage)} + onClick={() => setLanguage(item.code as SupportedLanguage)} + className={`flex items-center justify-between px-2.5 py-2 rounded-xl text-xs font-medium cursor-pointer transition-colors ${ + isSelected + ? 'bg-primary/15 text-primary font-bold shadow-2xs' + : 'text-foreground hover:bg-muted/50' + }`} + > +
+ {item.flag} + {item.nativeName} + + ({item.code}) + +
+ {isSelected && } +
+ ) + })} +
+
+ ) +} diff --git a/src/components/layout/Topbar.tsx b/src/components/layout/Topbar.tsx index 0bead47..0f19fc4 100644 --- a/src/components/layout/Topbar.tsx +++ b/src/components/layout/Topbar.tsx @@ -1,41 +1,54 @@ +import React, { useMemo } from 'react' import { useLocation } from 'react-router-dom' import { SidebarTrigger } from '@/components/ui/sidebar' import ThemeToggle from '@/components/layout/ThemeToggle' import ServerHealthTrigger from '@/components/layout/ServerHealthTrigger' -import { navigationConfig } from '@/config/navigation' -import { siteConfig } from '@/config/site' +import LanguageSwitcher from '@/components/layout/LanguageSwitcher' +import { getNavigationConfig } from '@/config/navigation' +import { useTranslation } from '@/i18n' export default function Topbar() { const { pathname } = useLocation() + const { t } = useTranslation() - // پیدا کردن عنوان صفحه بر اساس مسیر از کانفیگ - let currentTitle = siteConfig.name - for (const group of navigationConfig) { - 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 + const navConfig = useMemo(() => getNavigationConfig(t), [t]) + + // پیدا کردن پویای عنوان صفحه بر اساس مسیر فعلی و زبان فعال + const currentTitle = useMemo(() => { + if (pathname === '/') return t('navigation.dashboardTitle') + + for (const group of navConfig) { + for (const it of group.items) { + if (it.to === pathname) { + return it.title + } + if (it.children) { + for (const sub of it.children) { + if (sub.to === pathname) { + return sub.title + } + if (sub.children) { + const grand = sub.children.find((g) => g.to === pathname) + if (grand) { + return grand.title + } + } + } } } } - } - if (pathname === '/') currentTitle = 'داشبورد مدیریت' + return t('navigation.brand') + }, [pathname, navConfig, t]) return (
- {/* عنوان و مسیر */} + {/* عنوان و مسیر با ترجمه بلادرنگ */}

- {siteConfig.shortName} / {currentTitle} + {t('navigation.brandShort')} / {currentTitle}

{currentTitle} @@ -43,9 +56,12 @@ export default function Topbar() {

- {/* ابزارهای هدر: وضعیت سرور، سوئیچ تم */} -
- {/* نشانگر کوچک وضعیت اتصال سرور */} + {/* ابزارهای هدر: انتخابگر زبان، وضعیت اتصال سرور، سوئیچ تم */} +
+ {/* انتخابگر زبان سه گانه (فارسی، عربی، انگلیسی) */} + + + {/* نشانگر وضعیت اتصال سرور */} {/* سوئیچ دارک و لایت */} diff --git a/src/config/navigation.ts b/src/config/navigation.ts index 0d1e34c..923f01e 100644 --- a/src/config/navigation.ts +++ b/src/config/navigation.ts @@ -1,14 +1,19 @@ import type { IconName } from '@/icons' +import { translate, useLanguageStore } from '@/i18n' export interface NavSubItemConfig { title: string - to: string - icon: IconName + titleKey?: string + to?: string + icon?: IconName badge?: string + soon?: boolean + children?: NavSubItemConfig[] } export interface NavItemConfig { title: string + titleKey?: string to?: string icon: IconName badge?: string @@ -22,39 +27,82 @@ export interface NavGroupConfig { items: NavItemConfig[] } -export const navigationConfig: NavGroupConfig[] = [ - { - title: '', - items: [ - { title: 'داشبورد', to: '/', icon: 'home' }, - { - title: 'تورها', - icon: 'map', - children: [ - { title: 'فهرست همه تورها', to: '/tours', icon: 'map' }, - { title: 'تورهای در حال برگزاری', to: '/tours/ongoing', icon: 'calendar' }, - { title: 'سفارشات و رزروها', to: '/tours/orders', icon: 'clipboardCheck' }, - ], - }, - { - title: 'خدمات سامانه', - icon: 'sparkles', - alwaysOpen: true, - 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: 'ادعیه و زیارات (Duas)', to: '/services/duas', icon: 'book' }, - { title: 'اماکن زیارتی (Shrines)', to: '/services/shrines', icon: 'shrine' }, - { title: 'فروشگاه معصومه (Store)', to: '/services/store', icon: 'store' }, - { title: 'تخمین هزینه (Cost Estimate)', to: '/services/cost-estimate', icon: 'calculator' }, - { title: 'سوالات متداول (FAQ)', to: '/services/faq', icon: 'helpCircle' }, - { title: 'پشتیبانی (Support)', to: '/services/consultant', icon: 'headset' }, - ], - }, - { title: 'صورت‌حساب‌ها و مالی', to: '/billing', icon: 'receipt' }, - { title: 'تنظیمات', to: '/settings', icon: 'settings' }, - ], - }, -] +export function getNavigationConfig(t: (key: string) => string): NavGroupConfig[] { + return [ + { + title: '', + items: [ + { title: t('navigation.dashboard'), titleKey: 'navigation.dashboard', to: '/', icon: 'home' }, + { title: t('navigation.billing'), titleKey: 'navigation.billing', to: '/billing', icon: 'receipt' }, + { + title: t('navigation.tours'), + titleKey: 'navigation.tours', + icon: 'map', + children: [ + { + title: t('navigation.allTours'), + titleKey: 'navigation.allTours', + to: '/tours', + icon: 'map', + }, + { + title: t('navigation.ongoingTours'), + titleKey: 'navigation.ongoingTours', + to: '/tours/ongoing', + icon: 'calendar', + }, + ], + }, + { title: t('navigation.simcard'), titleKey: 'navigation.simcard', to: '/services/simcard', icon: 'simcard' }, + { title: t('navigation.zayerGuide'), titleKey: 'navigation.zayerGuide', to: '/services/zayer-guide', icon: 'guide' }, + { title: t('navigation.exchange'), titleKey: 'navigation.exchange', to: '/services/exchange', icon: 'exchange' }, + { + title: t('navigation.duas'), + titleKey: 'navigation.duas', + icon: 'book', + children: [ + { + title: t('navigation.duasList'), + titleKey: 'navigation.duasList', + to: '/services/duas', + icon: 'book', + }, + { + title: t('navigation.categories'), + titleKey: 'navigation.categories', + to: '/services/categories', + icon: 'folder', + }, + ], + }, + { title: t('navigation.shrines'), titleKey: 'navigation.shrines', to: '/services/shrines', icon: 'shrine' }, + { title: t('navigation.store'), titleKey: 'navigation.store', to: '/services/store', icon: 'store' }, + { title: t('navigation.costEstimate'), titleKey: 'navigation.costEstimate', to: '/services/cost-estimate', icon: 'calculator' }, + { + title: t('navigation.support'), + titleKey: 'navigation.support', + icon: 'headset', + children: [ + { + title: t('navigation.supporters'), + titleKey: 'navigation.supporters', + to: '/services/consultant', + icon: 'headset', + }, + { + title: t('navigation.faq'), + titleKey: 'navigation.faq', + to: '/services/faq', + icon: 'helpCircle', + }, + ], + }, + { title: t('navigation.settings'), titleKey: 'navigation.settings', to: '/settings', icon: 'settings' }, + ], + }, + ] +} + +export const navigationConfig: NavGroupConfig[] = getNavigationConfig((key) => + translate(useLanguageStore.getState().language, key) +) diff --git a/src/features/billing/BillingPage.tsx b/src/features/billing/BillingPage.tsx index 9d9572e..06d83e8 100644 --- a/src/features/billing/BillingPage.tsx +++ b/src/features/billing/BillingPage.tsx @@ -1,16 +1,10 @@ import React, { useState, useEffect, useMemo, useCallback } from 'react' +import { useSearchParams } from 'react-router-dom' 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, @@ -20,7 +14,7 @@ import { TableHeader, TableRow, } from '@/components/ui/table' -import { Ic } from '@/icons' +import { Ic, IconName } from '@/icons' import { Shimmer } from '@/components/shared/Shimmer' import { fetchFactors, reviewFactor, clearFactorReceipt } from './services/billing-api' import type { FactorItem, FactorStatus, FactorService, BillingStats } from './types' @@ -29,21 +23,47 @@ import { BillingServiceBadge } from './components/BillingServiceBadge' import { BillingDetailModal } from './components/BillingDetailModal' import { RejectFactorDialog } from './components/RejectFactorDialog' import { ReceiptLightboxDialog } from './components/ReceiptLightboxDialog' +import { useTranslation } from '@/i18n' +import { useBillingBadgeStore } from '@/stores/billing-badge' const PAGE_SIZE = 15 +interface ServiceTabItem { + id: string + titleKey: string + icon: IconName + isCore?: boolean +} + +const SERVICE_TABS: ServiceTabItem[] = [ + { id: 'all', titleKey: 'billing.tabAll', icon: 'receipt' }, + { id: 'tour', titleKey: 'billing.tabTour', icon: 'map', isCore: true }, + { id: 'simcard', titleKey: 'billing.tabSimcard', icon: 'simcard' }, + { id: 'tasrif', titleKey: 'billing.tabExchange', icon: 'exchange' }, + { id: 'shop', titleKey: 'billing.tabShop', icon: 'store' }, + { id: 'custom_trip', titleKey: 'billing.tabCustomTrip', icon: 'calculator' }, +] + export default function BillingPage() { + const { t, language } = useTranslation() + const [searchParams, setSearchParams] = useSearchParams() + 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) + // Filters initialized from URL search params + const initialService = searchParams.get('service') || 'all' + const initialStatus = searchParams.get('status') || 'all' + const initialSearch = searchParams.get('search') || '' + const initialPage = parseInt(searchParams.get('page') || '1', 10) || 1 + + const [selectedService, setSelectedService] = useState(initialService) + const [selectedStatus, setSelectedStatus] = useState(initialStatus) + const [searchQuery, setSearchQuery] = useState(initialSearch) + const [currentPage, setCurrentPage] = useState(initialPage) // Modals state const [selectedFactorForDetail, setSelectedFactorForDetail] = useState(null) @@ -54,6 +74,32 @@ export default function BillingPage() { const [updatingId, setUpdatingId] = useState(null) const [isSubmittingReject, setIsSubmittingReject] = useState(false) + // Sync state to URL search params + const updateUrlParams = useCallback( + (service: string, status: string, search: string, page: number) => { + const params = new URLSearchParams() + if (service && service !== 'all') params.set('service', service) + if (status && status !== 'all') params.set('status', status) + if (search) params.set('search', search) + if (page > 1) params.set('page', page.toString()) + setSearchParams(params, { replace: true }) + }, + [setSearchParams] + ) + + // Sync URL changes back to state (e.g. browser back/forward or deep-link) + useEffect(() => { + const urlService = searchParams.get('service') || 'all' + const urlStatus = searchParams.get('status') || 'all' + const urlSearch = searchParams.get('search') || '' + const urlPage = parseInt(searchParams.get('page') || '1', 10) || 1 + + setSelectedService(urlService) + setSelectedStatus(urlStatus) + setSearchQuery(urlSearch) + setCurrentPage(urlPage) + }, [searchParams]) + // Load factors from API const loadFactors = useCallback(async () => { setIsLoading(true) @@ -70,27 +116,44 @@ export default function BillingPage() { setFactors(res.results || []) setTotalCount(res.count || 0) } catch (err: any) { - setError(err?.message || 'خطا در برقراری ارتباط با سرور و بارگذاری صورت‌حساب‌ها.') + setError(err?.message || t('common.error')) } finally { setIsLoading(false) } - }, [selectedStatus, selectedService, searchQuery, currentPage]) + }, [selectedStatus, selectedService, searchQuery, currentPage, t]) useEffect(() => { loadFactors() }, [loadFactors]) - // Reset page when filters change - const handleFilterChange = (setter: (val: string) => void) => (val: string) => { - setter(val) + // Change Service Tab + const handleSelectServiceTab = (serviceId: string) => { + setSelectedService(serviceId) + setCurrentPage(1) + updateUrlParams(serviceId, selectedStatus, searchQuery, 1) + } + + // Change Status Filter + const handleSelectStatus = (statusId: string) => { + setSelectedStatus(statusId) + setCurrentPage(1) + updateUrlParams(selectedService, statusId, searchQuery, 1) + } + + // Change Search Query + const handleSearchChange = (query: string) => { + setSearchQuery(query) setCurrentPage(1) + updateUrlParams(selectedService, selectedStatus, query, 1) } + // Reset Filters const handleResetFilters = () => { - setSearchQuery('') - setSelectedStatus('all') setSelectedService('all') + setSelectedStatus('all') + setSearchQuery('') setCurrentPage(1) + updateUrlParams('all', 'all', '', 1) } // KPI Statistics calculations @@ -139,6 +202,7 @@ export default function BillingPage() { setSelectedFactorForLightbox(updated) } showToastMessage(`صورت‌حساب شماره #${factorId} با موفقیت تایید شد.`) + useBillingBadgeStore.getState().fetchCounts() } catch (err: any) { alert(`خطا در تایید صورت‌حساب: ${err?.message || 'مشکل غیرمنتظره'}`) } finally { @@ -162,6 +226,7 @@ export default function BillingPage() { setSelectedFactorForLightbox(updated) } showToastMessage(`صورت‌حساب شماره #${factorId} با ثبت دلیل رد شد.`) + useBillingBadgeStore.getState().fetchCounts() } catch (err: any) { alert(`خطا در رد صورت‌حساب: ${err?.message || 'مشکل غیرمنتظره'}`) } finally { @@ -171,7 +236,7 @@ export default function BillingPage() { } const handleClearReceipt = async (factorId: number) => { - if (!window.confirm('آیا از حذف تصویر فیش بارگذاری‌شده برای این صورت‌حساب اطمینان دارید؟')) { + if (!window.confirm(t('billing.confirmClearReceipt'))) { return } setUpdatingId(factorId) @@ -186,9 +251,9 @@ export default function BillingPage() { if (selectedFactorForLightbox?.id === factorId) { setSelectedFactorForLightbox((prev) => (prev ? { ...prev, transaction_receipt: null } : null)) } - showToastMessage('فیش واریزی با موفقیت پاک شد.') + showToastMessage(t('billing.receiptClearedSuccess')) } catch (err: any) { - alert(`خطا در حذف فیش: ${err?.message || 'مشکل غیرمنتظره'}`) + alert(`Error: ${err?.message || 'Unexpected issue'}`) } finally { setUpdatingId(null) } @@ -202,7 +267,7 @@ export default function BillingPage() { const totalPages = Math.ceil(totalCount / PAGE_SIZE) || 1 return ( -
+
{/* پیام بازخورد عملیات */} {actionSuccessMessage && (
@@ -219,45 +284,83 @@ export default function BillingPage() {
)} - {/* هدر صفحه */} -
-
+ {/* سربرگ اصلی صفحه صورت‌حساب‌ها */} +
+
-
+
-

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

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

+ {t('billing.title')} +

+ + {totalCount} {t('billing.invoices')}
-

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

+ {t('billing.description')}

-
+
- {/* کارت‌های KPI شاخص‌های کلیدی */} + {/* نوار تب‌های تفکیک سرویس‌ها (Service Category Tabs) با اولویت محور اصلی تورها */} +
+ {SERVICE_TABS.map((tab) => { + const isActive = selectedService === tab.id + return ( + + ) + })} +
+ + {/* کارت‌های KPI شاخص‌های کلیدی مالی */}
{/* کل فاکتورها */} - +
-

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

-

{stats.total}

+

{t('billing.totalInvoices')}

+

{stats.total}

@@ -270,14 +373,14 @@ export default function BillingPage() {

- در انتظار بررسی فیش + {t('billing.pendingReview')}

{stats.pendingReview}

- +
@@ -287,7 +390,7 @@ export default function BillingPage() {

- در انتظار پرداخت مشتری + {t('billing.awaitingPayment')}

{stats.awaitingPayment} @@ -304,7 +407,7 @@ export default function BillingPage() {

- پرداخت‌های تایید شده + {t('billing.approvedPayments')}

{stats.approved} @@ -321,7 +424,7 @@ export default function BillingPage() {

- رد شده / منقضی + {t('billing.rejectedOrExpired')}

{stats.rejectedOrExpired} @@ -334,101 +437,90 @@ export default function BillingPage() {

- {/* نوار جستجو و فیلترها */} - + {/* نوار جستجو و فیلترهای وضعیت لحظه‌ای */} + -
+
{/* جستجوی متنی */}
{ - setSearchQuery(e.target.value) - setCurrentPage(1) - }} - className="pr-9 h-9 text-xs" + onChange={(e) => handleSearchChange(e.target.value)} + className="ps-9 pe-9 h-9 text-xs bg-surface-2 border-border-soft rounded-xl" /> {searchQuery && ( )}
- {/* فیلتر وضعیت */} -
- -
- - {/* فیلتر سرویس */} -
- + {/* چیپ‌های فیلتر سریع وضعیت */} +
+ {[ + { id: 'all', label: t('billing.allStatuses') }, + { id: 'pending', label: t('billing.statusPending') }, + { id: 'awaiting_payment', label: t('billing.statusAwaiting') }, + { id: 'approved', label: t('billing.statusApproved') }, + { id: 'rejected', label: t('billing.statusRejected') }, + { id: 'expired', label: t('billing.statusExpired') }, + ].map((st) => { + const isSelected = selectedStatus === st.id + return ( + + ) + })}
- {/* بازنشانی فیلترها */} + {/* دکمه بازنشانی فیلترها */} {(searchQuery || selectedStatus !== 'all' || selectedService !== 'all') && ( )}
- {/* جدول داده‌ای فاکتورها */} - - - + {/* جدول داده‌ای جامع صورت‌حساب‌ها و سفارشات */} + + + - فهرست فاکتورها + {t('billing.invoiceList')} + {selectedService !== 'all' && ( + + {t(`billing.tab${selectedService.charAt(0).toUpperCase() + selectedService.slice(1)}` as any) || selectedService} + + )} - - صفحه {currentPage} از {totalPages} + + {t('common.pageOf', { current: currentPage, total: totalPages })} @@ -439,24 +531,24 @@ export default function BillingPage() {

{error}

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

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

-

+

{t('billing.noFactorsFound')}

+

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

{(searchQuery || selectedStatus !== 'all' || selectedService !== 'all') && ( - )}
@@ -464,18 +556,18 @@ export default function BillingPage() {
- - شناسه - مشتری - سرویس - مبلغ فاکتور - وضعیت - فیش واریزی - تاریخ صدور - عملیات + + {t('billing.id')} + {t('billing.customer')} + {t('billing.service')} + {t('billing.amount')} + {t('billing.liveStatus')} + {t('billing.receipt')} + {t('billing.date')} + {t('common.actions')} - + {factors.map((factor) => { const isPending = factor.status === 'pending' const hasReceipt = Boolean(factor.transaction_receipt) @@ -484,29 +576,29 @@ export default function BillingPage() { return ( {/* شناسه */} - + #{factor.id} - {/* مشخصات مشتری */} + {/* مشخصات مشتری و خریدار */}

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

-

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

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

@@ -520,11 +612,11 @@ export default function BillingPage() { {/* مبلغ */} - {Number(factor.amount).toLocaleString()} $ + ${Number(factor.amount).toLocaleString()} - {/* وضعیت */} + {/* وضعیت لحظه‌ای */} @@ -534,12 +626,12 @@ export default function BillingPage() { {hasReceipt ? ( ) : ( - ندارد + {t('billing.noReceipt')} )}
{/* تاریخ صدور */} - - {new Date(factor.created_at).toLocaleDateString('fa-IR')} + + {new Date(factor.created_at).toLocaleDateString( + language === 'fa' ? 'fa-IR' : language === 'ar' ? 'ar-SA' : 'en-US' + )} {/* دکمه‌های عملیات */}
- {/* دکمه مشاهده جزئیات */} + {/* دکمه مشاهده جزئیات کامل خریدار و پرداخت */} {/* تایید سریع فیش */} @@ -581,10 +675,10 @@ export default function BillingPage() { @@ -595,10 +689,10 @@ export default function BillingPage() { @@ -615,20 +709,24 @@ export default function BillingPage() { {/* صفحه‌بندی */} {totalPages > 1 && ( -
- - نمایش {(currentPage - 1) * PAGE_SIZE + 1} تا{' '} - {Math.min(currentPage * PAGE_SIZE, totalCount)} از {totalCount} فاکتور +
+ + {t('common.showing')} {(currentPage - 1) * PAGE_SIZE + 1} {t('common.to')}{' '} + {Math.min(currentPage * PAGE_SIZE, totalCount)} {t('common.of')} {totalCount} {t('billing.invoices')}
{currentPage} / {totalPages} @@ -636,11 +734,15 @@ export default function BillingPage() {
@@ -648,7 +750,7 @@ export default function BillingPage() { - {/* ۱. مودال جزئیات جامع صورت‌حساب */} + {/* ۱. مودال جزئیات جامع صورت‌حساب و شخص خریدار */} صورت‌حساب شماره #{factor.id}
- ثبت شده در: {new Date(factor.created_at).toLocaleString('fa-IR')} + ثبت شده در: {new Date(factor.created_at).toLocaleString(language === 'fa' ? 'fa-IR' : language === 'ar' ? 'ar-SA' : 'en-US')}
diff --git a/src/features/billing/components/BillingServiceBadge.tsx b/src/features/billing/components/BillingServiceBadge.tsx index a3e72e9..4d36a4c 100644 --- a/src/features/billing/components/BillingServiceBadge.tsx +++ b/src/features/billing/components/BillingServiceBadge.tsx @@ -2,6 +2,7 @@ import React from 'react' import { Badge } from '@/components/ui/badge' import { Ic } from '@/icons' import type { FactorService } from '../types' +import { useTranslation } from '@/i18n' interface Props { service: FactorService @@ -9,6 +10,8 @@ interface Props { } export function BillingServiceBadge({ service, className }: Props) { + const { t } = useTranslation() + switch (service) { case 'tour': return ( @@ -17,7 +20,7 @@ export function BillingServiceBadge({ service, className }: Props) { className={`bg-purple-500/15 text-purple-700 dark:text-purple-300 border-purple-500/30 gap-1.5 font-medium ${className || ''}`} > - تور + {t('billing.serviceTour')} ) case 'simcard': @@ -26,8 +29,8 @@ export function BillingServiceBadge({ service, className }: Props) { variant="outline" className={`bg-cyan-500/15 text-cyan-700 dark:text-cyan-300 border-cyan-500/30 gap-1.5 font-medium ${className || ''}`} > - - سیم‌کارت + + {t('billing.serviceSimcard')} ) case 'shop': @@ -36,8 +39,8 @@ export function BillingServiceBadge({ service, className }: Props) { variant="outline" className={`bg-amber-500/15 text-amber-700 dark:text-amber-300 border-amber-500/30 gap-1.5 font-medium ${className || ''}`} > - - فروشگاه + + {t('billing.serviceShop')} ) case 'tasrif': @@ -46,8 +49,8 @@ export function BillingServiceBadge({ service, className }: Props) { variant="outline" className={`bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border-emerald-500/30 gap-1.5 font-medium ${className || ''}`} > - - صرافی (تسریف) + + {t('billing.serviceExchange')} ) case 'custom_trip': @@ -56,8 +59,8 @@ export function BillingServiceBadge({ service, className }: Props) { variant="outline" className={`bg-indigo-500/15 text-indigo-700 dark:text-indigo-300 border-indigo-500/30 gap-1.5 font-medium ${className || ''}`} > - - سفر اختصاصی + + {t('billing.serviceCustomTrip')} ) default: diff --git a/src/features/billing/components/BillingStatusBadge.tsx b/src/features/billing/components/BillingStatusBadge.tsx index 1821e80..a6b5e1c 100644 --- a/src/features/billing/components/BillingStatusBadge.tsx +++ b/src/features/billing/components/BillingStatusBadge.tsx @@ -3,12 +3,16 @@ import { Badge } from '@/components/ui/badge' import { Ic } from '@/icons' import type { FactorStatus } from '../types' +import { useTranslation } from '@/i18n' + interface Props { status: FactorStatus className?: string } export function BillingStatusBadge({ status, className }: Props) { + const { t } = useTranslation() + switch (status) { case 'awaiting_payment': return ( @@ -17,7 +21,7 @@ export function BillingStatusBadge({ status, className }: Props) { className={`bg-amber-500/15 text-amber-700 dark:text-amber-300 border-amber-500/30 gap-1.5 font-medium ${className || ''}`} > - در انتظار پرداخت + {t('billing.statusAwaiting')} ) case 'pending': @@ -27,7 +31,7 @@ export function BillingStatusBadge({ status, className }: Props) { className={`bg-blue-500/15 text-blue-700 dark:text-blue-300 border-blue-500/30 gap-1.5 font-medium animate-pulse ${className || ''}`} > - در انتظار بررسی فیش + {t('billing.statusPending')} ) case 'approved': @@ -37,7 +41,7 @@ export function BillingStatusBadge({ status, className }: Props) { className={`bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border-emerald-500/30 gap-1.5 font-medium ${className || ''}`} > - پرداخت تایید شده + {t('billing.statusApproved')} ) case 'rejected': @@ -47,7 +51,7 @@ export function BillingStatusBadge({ status, className }: Props) { className={`bg-rose-500/15 text-rose-700 dark:text-rose-300 border-rose-500/30 gap-1.5 font-medium ${className || ''}`} > - رد شده + {t('billing.statusRejected')} ) case 'expired': @@ -57,7 +61,7 @@ export function BillingStatusBadge({ status, className }: Props) { className={`bg-slate-500/15 text-slate-600 dark:text-slate-400 border-slate-500/30 gap-1.5 font-medium ${className || ''}`} > - منقضی شده + {t('billing.statusExpired')} ) default: diff --git a/src/features/billing/components/RejectFactorDialog.tsx b/src/features/billing/components/RejectFactorDialog.tsx index 1f6caa8..6a4e716 100644 --- a/src/features/billing/components/RejectFactorDialog.tsx +++ b/src/features/billing/components/RejectFactorDialog.tsx @@ -11,6 +11,7 @@ import { Button } from '@/components/ui/button' import { Textarea } from '@/components/ui/textarea' import { Ic } from '@/icons' import type { FactorItem } from '../types' +import { useTranslation } from '@/i18n' interface Props { factor: FactorItem | null @@ -27,6 +28,7 @@ export function RejectFactorDialog({ onConfirm, isSubmitting, }: Props) { + const { t, dir } = useTranslation() const [reason, setReason] = useState('') const [error, setError] = useState(null) @@ -34,7 +36,7 @@ export function RejectFactorDialog({ const handleConfirm = async () => { if (!reason.trim()) { - setError('لطفاً دلیل رد فاکتور را وارد فرمایید تا به مشتری اطلاع داده شود.') + setError(t('billing.rejectReasonPlaceholder')) return } setError(null) @@ -47,7 +49,7 @@ export function RejectFactorDialog({ !val && onClose()}> {/* هدر دیالوگ با پس‌زمینه و حاشیه متناسب */} @@ -57,19 +59,19 @@ export function RejectFactorDialog({
- رد صورت‌حساب + {t('billing.rejectTitle')} #{factor.id} - مشتری:{' '} + {t('billing.customer')}:{' '} - {factor.user_info?.fullname || factor.user_info?.email || 'نامشخص'} + {factor.user_info?.fullname || factor.user_info?.email || t('common.noData')} • - مبلغ:{' '} + {t('billing.amount')}:{' '} ${Number(factor.amount).toLocaleString()} @@ -82,7 +84,7 @@ export function RejectFactorDialog({ type="button" onClick={onClose} className="size-8 rounded-xl flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors" - title="بستن" + title={t('common.close')} > @@ -93,7 +95,7 @@ export function RejectFactorDialog({