From 1eb794c6ebfbc11e238ded0219d3fa2d85b4e38d Mon Sep 17 00:00:00 2001 From: "Muhammad A. Ghorbani" Date: Wed, 30 Sep 2026 17:13:43 +0330 Subject: [PATCH] feat: add admin panel modules for tours, users, settings, billing, and services --- src/app/router.tsx | 6 + src/components/ui/dialog.tsx | 2 +- src/config/navigation.ts | 1 + .../billing/components/BillingDetailModal.tsx | 8 +- .../components/ReceiptLightboxDialog.tsx | 4 +- src/features/services/ServicesPage.tsx | 740 +++++++++++++--- .../services/components/DuasServiceView.tsx | 104 ++- .../services/components/FaqServiceView.tsx | 50 +- .../components/LocationDetailSheet.tsx | 2 +- .../services/components/SimcardFormDialog.tsx | 97 ++- .../components/SimcardServiceView.tsx | 69 +- .../services/components/StoreServiceView.tsx | 84 +- .../components/ZayerGuideServiceView.tsx | 82 +- src/features/settings/SettingsPage.tsx | 87 ++ .../components/AdminManagementTab.tsx | 273 ++++++ .../settings/components/AdminStatsCards.tsx | 103 +++ .../settings/components/AdminUserTable.tsx | 321 +++++++ .../settings/components/CreateAdminModal.tsx | 283 ++++++ .../settings/components/NotificationsTab.tsx | 44 + .../settings/components/SetPasswordModal.tsx | 281 ++++++ .../settings/services/settings-api.ts | 108 +++ src/features/settings/types.ts | 36 + src/features/tours/OngoingToursPage.tsx | 6 +- src/features/tours/ToursPage.tsx | 16 +- .../tours/components/CreateTourDialog.tsx | 26 +- .../tours/components/TourDetailDialog.tsx | 20 +- .../tours/components/TourDetailSheet.tsx | 14 +- .../components/TourTranslationsDialog.tsx | 2 +- src/features/users/UserDetailPage.tsx | 239 +++++ src/features/users/UsersPage.tsx | 822 ++++++++++++++++++ .../users/components/UserFactorsTab.tsx | 297 +++++++ .../users/components/UserProfileTab.tsx | 216 +++++ .../users/components/UserToursTab.tsx | 236 +++++ src/features/users/services/users-api.ts | 93 ++ src/features/users/types.ts | 120 +++ src/icons.tsx | 3 + src/index.css | 17 +- src/lib/date-utils.ts | 8 +- src/lib/utils.ts | 22 +- src/locales/ar.json | 172 +++- src/locales/en.json | 170 ++++ src/locales/fa.json | 176 +++- 42 files changed, 5136 insertions(+), 324 deletions(-) create mode 100644 src/features/settings/SettingsPage.tsx create mode 100644 src/features/settings/components/AdminManagementTab.tsx create mode 100644 src/features/settings/components/AdminStatsCards.tsx create mode 100644 src/features/settings/components/AdminUserTable.tsx create mode 100644 src/features/settings/components/CreateAdminModal.tsx create mode 100644 src/features/settings/components/NotificationsTab.tsx create mode 100644 src/features/settings/components/SetPasswordModal.tsx create mode 100644 src/features/settings/services/settings-api.ts create mode 100644 src/features/settings/types.ts create mode 100644 src/features/users/UserDetailPage.tsx create mode 100644 src/features/users/UsersPage.tsx create mode 100644 src/features/users/components/UserFactorsTab.tsx create mode 100644 src/features/users/components/UserProfileTab.tsx create mode 100644 src/features/users/components/UserToursTab.tsx create mode 100644 src/features/users/services/users-api.ts create mode 100644 src/features/users/types.ts diff --git a/src/app/router.tsx b/src/app/router.tsx index ab23c2a..36de01e 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -6,10 +6,13 @@ import RequireAuth from '@/features/auth/RequireAuth' /* لود صفحات اصلی */ const LoginPage = lazy(() => import('@/features/auth/LoginPage')) const DashboardHome = lazy(() => import('@/features/dashboard/DashboardHome')) +const UsersPage = lazy(() => import('@/features/users/UsersPage')) +const UserDetailPage = lazy(() => import('@/features/users/UserDetailPage')) const ToursPage = lazy(() => import('@/features/tours/ToursPage')) const OngoingToursPage = lazy(() => import('@/features/tours/OngoingToursPage')) const ServicesPage = lazy(() => import('@/features/services/ServicesPage')) const BillingPage = lazy(() => import('@/features/billing/BillingPage')) +const SettingsPage = lazy(() => import('@/features/settings/SettingsPage')) export default function AppRouter() { return ( @@ -23,6 +26,8 @@ export default function AppRouter() { {/* تمام مسیرهای محافظت‌شده پشت گارد امنیتی */} }> } /> + } /> + } /> } /> } /> } /> @@ -30,6 +35,7 @@ export default function AppRouter() { } /> } /> } /> + } /> {/* هدایت مسیرهای ناشناخته به صفحه اصلی */} } /> diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index b20df6c..8a8b8c6 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -78,7 +78,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) { function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) { return (
) diff --git a/src/config/navigation.ts b/src/config/navigation.ts index 758a35f..49d7894 100644 --- a/src/config/navigation.ts +++ b/src/config/navigation.ts @@ -33,6 +33,7 @@ export function getNavigationConfig(t: (key: string) => string): NavGroupConfig[ title: '', items: [ { title: t('navigation.dashboard'), titleKey: 'navigation.dashboard', to: '/', icon: 'home' }, + { title: t('navigation.users'), titleKey: 'navigation.users', to: '/users', icon: 'users' }, { title: t('navigation.billing'), titleKey: 'navigation.billing', to: '/billing', icon: 'receipt' }, { title: t('navigation.tours'), diff --git a/src/features/billing/components/BillingDetailModal.tsx b/src/features/billing/components/BillingDetailModal.tsx index 774b2bf..503ce8e 100644 --- a/src/features/billing/components/BillingDetailModal.tsx +++ b/src/features/billing/components/BillingDetailModal.tsx @@ -307,13 +307,13 @@ export function BillingDetailModal({

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

- {activeFactor.rejected_description || 'انقضای مهلت پرداخت (سپری شدن مهلت ۷۲ ساعته)'} + {activeFactor.rejected_description || 'انقضای مهلت پرداخت (سپری شدن مهلت 72 ساعته)'}

)} - {/* اطلاع‌رسانی تمدید خودکار مهلت ۳ روزه در صورت فعال‌سازی مجدد فاکتور منقضی شده یا رد شده */} + {/* اطلاع‌رسانی تمدید خودکار مهلت 3 روزه در صورت فعال‌سازی مجدد فاکتور منقضی شده یا رد شده */} {(activeFactor.status === 'expired' || activeFactor.status === 'rejected') && (selectedStatus === 'awaiting_payment' || selectedStatus === 'pending') && (
@@ -325,11 +325,11 @@ export function BillingDetailModal({ تمدید مجدد مهلت پرداخت صورت‌حساب: - +۳ روز مهلت جدید + +3 روز مهلت جدید

- با ثبت وضعیت «{selectedStatus === 'awaiting_payment' ? 'در انتظار پرداخت' : 'در انتظار بررسی فیش'}»، این صورت‌حساب مجدداً فعال شده و یک مهلت پرداخت ۳ روزهٔ جدید (۷۲ ساعت از تاریخ امروز) برای مسافر در نظر گرفته می‌شود. + با ثبت وضعیت «{selectedStatus === 'awaiting_payment' ? 'در انتظار پرداخت' : 'در انتظار بررسی فیش'}»، این صورت‌حساب مجدداً فعال شده و یک مهلت پرداخت 3 روزهٔ جدید (72 ساعت از تاریخ امروز) برای مسافر در نظر گرفته می‌شود.

diff --git a/src/features/billing/components/ReceiptLightboxDialog.tsx b/src/features/billing/components/ReceiptLightboxDialog.tsx index 59b0541..0b51063 100644 --- a/src/features/billing/components/ReceiptLightboxDialog.tsx +++ b/src/features/billing/components/ReceiptLightboxDialog.tsx @@ -120,7 +120,7 @@ export function ReceiptLightboxDialog({ size="sm" className="h-7.5 px-2.5 rounded-lg text-xs gap-1 border-border-soft bg-card shadow-2xs hover:bg-muted/60" onClick={handleRotate} - title="چرخش ۹۰ درجه" + title="چرخش 90 درجه" > چرخش @@ -134,7 +134,7 @@ export function ReceiptLightboxDialog({ onClick={handleReset} title="بازنشانی اندازه" > - اندازه اولیه (۱۰۰٪) + اندازه اولیه (100%) )} diff --git a/src/features/services/ServicesPage.tsx b/src/features/services/ServicesPage.tsx index e569cbe..90c1a2e 100644 --- a/src/features/services/ServicesPage.tsx +++ b/src/features/services/ServicesPage.tsx @@ -96,7 +96,7 @@ export const SERVICES_LIST: ServiceMeta[] = [ color: 'text-primary', bgColor: 'bg-primary/10', borderColor: 'border-primary/20', - description: 'فهرست مستقیم ادعیه، تخصیص چندگانه به حرم‌ها، مدیریت متن عربی با اعراب و ترجمه‌های ۵‌زبانه', + description: 'فهرست مستقیم ادعیه، تخصیص چندگانه به حرم‌ها، مدیریت متن عربی با اعراب و ترجمه‌های 5 زبانه', endpoint: '/trip-etiquette/duas/', statsLabel: 'دعا و زیارت ثبت‌شده', defaultCount: 4, @@ -152,7 +152,7 @@ export const SERVICES_LIST: ServiceMeta[] = [ color: 'text-primary', bgColor: 'bg-primary/10', borderColor: 'border-primary/20', - description: 'مدیریت جامع دسته‌بندی‌های موضوعی، پرسش و پاسخ‌ها، پشتیبانی کامل ۵ زبانه و فعال/غیرفعال‌سازی نمایش در اپلیکیشن', + description: 'مدیریت جامع دسته‌بندی‌های موضوعی، پرسش و پاسخ‌ها، پشتیبانی کامل 5 زبانه و فعال/غیرفعال‌سازی نمایش در اپلیکیشن', endpoint: '/home/faq/categories/', statsLabel: 'دسته‌بندی فعال', defaultCount: 6, @@ -166,7 +166,7 @@ export const SERVICES_LIST: ServiceMeta[] = [ color: 'text-primary', bgColor: 'bg-primary/10', borderColor: 'border-primary/20', - description: 'شبکه کارشناسان تخصصی پزشکی سفر، احکام، امور حقوقی، گمشدگان و پشتیبانی ۲۴ ساعته زائرین', + description: 'شبکه کارشناسان تخصصی پزشکی سفر، احکام، امور حقوقی، گمشدگان و پشتیبانی 24 ساعته زائرین', endpoint: '/consultants/', statsLabel: 'کارشناس فعال', defaultCount: 12, @@ -217,7 +217,7 @@ export default function ServicesPage() {
{/* سربرگ بخش خدمات (برای شهرهای زیارتی به صورت یکپارچه داخل کامپوننت با دکمه لود مجدد مدیریت می‌شود) */} {serviceKey !== 'cities' && ( -
+
@@ -228,7 +228,7 @@ export default function ServicesPage() { {currentService.titleEn} @@ -237,6 +237,16 @@ export default function ServicesPage() { {currentService.description}

+ +
+ + + {currentService.badge} + +
)} @@ -297,69 +307,260 @@ function ServiceDetailRenderer({ // ۳. صرافی آنلاین و تشریفات (Online Exchange) // ───────────────────────────────────────────────────────────────────────────── function ExchangeServiceView({ service }: { service: ServiceMeta }) { - const [amount, setAmount] = useState('10000000') - const [fromCurr, setFromCurr] = useState('IRR') - const [toCurr, setToCurr] = useState('IQD') + const [searchTerm, setSearchTerm] = useState('') + const [statusFilter, setStatusFilter] = useState<'ALL' | 'CONFIRMED' | 'DONE' | 'PENDING'>('ALL') const rates = [ - { pair: 'IRR / IQD', rate: '0.021', change: '+1.2%', base: '۱۰۰ هزار تومان = ۲,۱۰۰ دینار' }, - { pair: 'USD / IQD', rate: '1,530', change: '0.0%', base: '۱ دلار = ۱,۵۳۰ دینار عراق' }, - { pair: 'IRR / SAR', rate: '0.000058', change: '-0.4%', base: '۱۰۰ هزار تومان = ۵.۸ ریال سعودی' }, - { pair: 'USD / SAR', rate: '3.75', change: '0.0%', base: '۱ دلار = ۳.۷۵ ریال سعودی' }, + { pair: 'IRR / IQD', name: 'دینار عراق', rate: '0.021', change: '+1.2%', base: '100 هزار تومان = 2,100 دینار', flag: '🇮🇶' }, + { pair: 'USD / IQD', name: 'دلار به دینار', rate: '1,530', change: '0.0%', base: '1 دلار آمریکا = 1,530 دینار', flag: '💵' }, + { pair: 'IRR / SAR', name: 'ریال سعودی', rate: '0.000058', change: '-0.4%', base: '100 هزار تومان = 5.8 ریال سعودی', flag: '🇸🇦' }, + { pair: 'USD / SAR', name: 'دلار به ریال', rate: '3.75', change: '0.0%', base: '1 دلار آمریکا = 3.75 ریال سعودی', flag: '🇸🇦' }, ] const recentOrders = [ - { id: 981, user: 'محمد حسینی', from: '۵۰,۰۰۰,۰۰۰ تومان', to: '۱,۰۵۰,۰۰۰ دینار عراق', deliverType: 'تحویل نقدی در فرودگاه نجف', status: 'تایید شده و آماده تحویل' }, - { id: 982, user: 'علی کاظمی', from: '۲۰,۰۰۰,۰۰۰ تومان', to: '۴۲۰,۰۰۰ دینار عراق', deliverType: 'حواله به کارت کی‌کارت عراق', status: 'انجام شد' }, - { id: 983, user: 'سارا احمدی', from: '۱۰۰,۰۰۰,۰۰۰ تومان', to: '۵,۸۰۰ ریال سعودی', deliverType: 'تحویل نقدی در هتل مدینه', status: 'در حال بررسی' }, + { id: 981, user: 'محمد حسینی', from: '50,000,000 تومان', to: '1,050,000 دینار عراق', deliverType: 'تحویل نقدی در فرودگاه نجف', status: 'تایید شده و آماده تحویل', statusCode: 'CONFIRMED', date: '1403/07/08' }, + { id: 982, user: 'علی کاظمی', from: '20,000,000 تومان', to: '420,000 دینار عراق', deliverType: 'حواله به کارت کی‌کارت عراق', status: 'انجام شد', statusCode: 'DONE', date: '1403/07/08' }, + { id: 983, user: 'سارا احمدی', from: '100,000,000 تومان', to: '5,800 ریال سعودی', deliverType: 'تحویل نقدی در هتل مدینه', status: 'در حال بررسی', statusCode: 'PENDING', date: '1403/07/07' }, + { id: 984, user: 'مهدی کریمی', from: '15,000,000 تومان', to: '315,000 دینار عراق', deliverType: 'تحویل نقدی در صحن حضرت زهرا (س)', status: 'انجام شد', statusCode: 'DONE', date: '1403/07/07' }, + { id: 985, user: 'زهرا موسوی', from: '35,000,000 تومان', to: '2,030 ریال سعودی', deliverType: 'تحویل در فرودگاه جده', status: 'تایید شده و آماده تحویل', statusCode: 'CONFIRMED', date: '1403/07/06' }, ] + const filteredOrders = useMemo(() => { + return recentOrders.filter((ord) => { + if (statusFilter !== 'ALL' && ord.statusCode !== statusFilter) return false + if (searchTerm.trim()) { + const q = searchTerm.toLowerCase().trim() + const matchUser = ord.user.toLowerCase().includes(q) + const matchId = String(ord.id).includes(q) + const matchType = ord.deliverType.toLowerCase().includes(q) + if (!matchUser && !matchId && !matchType) return false + } + return true + }) + }, [recentOrders, statusFilter, searchTerm]) + return (
- {/* جدول نرخ‌های لحظه‌ای ارز */} + {/* ۱. کارت‌های آمار و شاخص‌های کلیدی صرافی */}
- {rates.map((r) => ( -
-
- {r.pair} - {r.change} -
-
{r.rate}
-
{r.base}
+
+
+ سفارشات ثبت‌شده + + امروز: {toFa(5)} +
- ))} +
+ {toFa(28)} +
+
+ +12% افزایش نسبت به هفته قبل +
+
+ +
+
+ نرخ مبادله دینار عراق + 🇮🇶 +
+
+ {toFa('2,100')} دینار +
+
+ به‌ازای هر 100 هزار تومان +
+
+ +
+
+ در انتظار تحویل مقصد + + نیازمند اقدام + +
+
+ {toFa(4)} +
+
+ فرودگاه نجف و هتل‌های مدینه +
+
+ +
+
+ حجم تبادلات ارزی + +
+
+ {toFa(450)} م.تومان +
+
+ گردش مالی ماه جاری +
+
- {/* سفارشات اخیر ارزی */} + {/* ۲. کارت‌های لحظه‌ای نرخ برابری ارزها */}
-

سفارشات اخیر تبدیل و تحویل ارز

-
+
+

تابلوی زنده نرخ برابری ارزهای زیارتی

+ همگام با بازار نجف و صرافی مرکزی +
+
+ {rates.map((r) => ( +
+
+
+ {r.flag} + {r.pair} +
+ + {r.change} + +
+
{r.rate}
+
{r.base}
+
+ ))} +
+
+ + {/* ۳. نوار جستجو و فیلترهای سفارشات */} +
+
+ + + + +
+ +
+ + setSearchTerm(e.target.value)} + placeholder="جستجوی زائر، کد سفارش یا محل تحویل..." + className="h-9 ps-9 text-xs bg-surface-base rounded-xl border-border-soft" + /> + {searchTerm && ( + + )} +
+
+ + {/* ۴. جدول سفارشات ارزی با طراحی استاندارد پنل */} +
+
+
+
+ فهرست سفارشات تبدیل و تحویل ارز زیارتی + + {toFa(filteredOrders.length)} سفارش + +
+ آخرین همگام‌سازی: چند لحظه پیش +
+ +
- + - - - - + + + + + - {recentOrders.map((ord) => ( - - - - - - - + - ))} + ) : ( + filteredOrders.map((ord) => ( + + + + + + + + + + )) + )}
کد سفارشکد پیگیری زائر متقاضیمبلغ پرداختیمبلغ دریافتی مقصدنحوه تحویلوضعیت سفارشمبلغ مبدا (تومان)مبلغ مقصد (ارز تحویلی)محل و نحوه تحویلتاریخوضعیت
#{ord.id}{ord.user}{ord.from}{ord.to}{ord.deliverType} - - {ord.status} - + {filteredOrders.length === 0 ? ( +
+ هیچ سفارشی با این مشخصات یافت نشد.
#{ord.id} +
+
+ {ord.user.slice(0, 1)} +
+ {ord.user} +
+
{ord.from}{ord.to} +
+ + {ord.deliverType} +
+
{ord.date} + + {ord.status} + +
@@ -383,38 +584,42 @@ function TripEtiquetteServiceView({ service }: { service: ServiceMeta }) { return (
-
+
مجموع زیارات و ادعیه
{toFa(94)}
+
فهرست کامل مناسک
-
- فایل‌های صوتی و صوتی‌متنی +
+ فایل‌های صوتی و ترتیل
{toFa(86)}
+
کیفیت استودیویی
-
+
زبان‌های ترجمه -
۵ زبان
+
5 زبان
+
فارسی، عربی، انگلیسی، روسی، اندونزیایی
-
+
شهرهای تحت پوشش
{toFa(6)}
+
عراق و عربستان سعودی
{rituals.map((r) => ( -
+
- {r.city} -
- - همراه با صوت + {r.city} +
+ + همراه با صوت و ترجمه

{r.title}

-
+
شامل {toFa(r.prayersCount)} فراز و دعای وارده - مشاهده متن و صوت > + مشاهده متن و صوت >
))} @@ -424,55 +629,211 @@ function TripEtiquetteServiceView({ service }: { service: ServiceMeta }) { } // ───────────────────────────────────────────────────────────────────────────── -// ۵. مشاوره و پشتیبانی (Consultant) +// ۵. مشاوره و پشتیبانی (Consultant / Support) // ───────────────────────────────────────────────────────────────────────────── function ConsultantServiceView({ service }: { service: ServiceMeta }) { + const [searchTerm, setSearchTerm] = useState('') + const [specialtyFilter, setSpecialtyFilter] = useState('ALL') + const consultants = [ - { id: 1, name: 'دکتر علیرضا رضایی', specialty: 'پزشکی سفر، گرمازدگی و مراقبت‌های بهداشتی', status: 'آنلاین و آماده پاسخ', activeChats: 4 }, - { id: 2, name: 'حجت‌الاسلام سید هادی موسوی', specialty: 'احکام و مناسک حج، عمره و عتبات', status: 'آنلاین و آماده پاسخ', activeChats: 6 }, - { id: 3, name: 'مهندس مهدی کمالی', specialty: 'لجستیک، حمل‌ونقل، گذرنامه و امور گمشدگان', status: 'مشغول در تماس', activeChats: 2 }, + { id: 1, name: 'دکتر علیرضا رضایی', specialty: 'پزشکی سفر، گرمازدگی و مراقبت‌های بهداشتی', category: 'medical', status: 'آنلاین و آماده پاسخ', isOnline: true, activeChats: 4, satisfaction: '99%', experience: '8 سال' }, + { id: 2, name: 'حجت‌الاسلام سید هادی موسوی', specialty: 'احکام و مناسک حج، عمره و عتبات عالیات', category: 'rituals', status: 'آنلاین و آماده پاسخ', isOnline: true, activeChats: 6, satisfaction: '98%', experience: '12 سال' }, + { id: 3, name: 'مهندس مهدی کمالی', specialty: 'لجستیک، حمل‌ونقل بین‌شهری، گذرنامه و امور گمشدگان', category: 'logistics', status: 'مشغول در تماس', isOnline: false, activeChats: 2, satisfaction: '97%', experience: '5 سال' }, + { id: 4, name: 'سرکار خانم فاطمه سعیدی', specialty: 'مشاوره خانواده، بانوان، اقامت و کودکان در سفر', category: 'family', status: 'آنلاین و آماده پاسخ', isOnline: true, activeChats: 3, satisfaction: '100%', experience: '6 سال' }, ] + const filteredConsultants = useMemo(() => { + return consultants.filter((c) => { + if (specialtyFilter !== 'ALL' && c.category !== specialtyFilter) return false + if (searchTerm.trim()) { + const q = searchTerm.toLowerCase().trim() + const matchName = c.name.toLowerCase().includes(q) + const matchSpec = c.specialty.toLowerCase().includes(q) + if (!matchName && !matchSpec) return false + } + return true + }) + }, [consultants, specialtyFilter, searchTerm]) + return (
+ {/* ۱. کارت‌های شاخص عملکرد مرکز پشتیبانی و مشاوره */}
-
- مشاوران شیفت جاری -
{toFa(8)}
+
+
+ مشاوران شیفت جاری + + + آنلاین + +
+
+ {toFa(8)} نفر +
+
+ از 12 کارشناس پشتیبانی 24 ساعته +
+
+ +
+
+ مشاوره‌های امروز + + موفق + +
+
+ {toFa(64)} +
+
+ پاسخگویی به مسائل بهداشتی و شرعی +
+
+ +
+
+ میانگین زمان انتظار + +
+
+ 45 ثانیه +
+
+ استاندارد عالی (زیر 1 دقیقه) +
-
- مشاوره‌های موفق امروز -
{toFa(63)}
+ +
+
+ رضایت‌مندی زائرین + +
+
+ 98.7% +
+
+ بر اساس 1,420 بازخورد ثبت‌شده +
-
- میانگین زمان انتظار زائر -
۴۵ ثانیه
+
+ + {/* ۲. فیلترها و نوار جستجوی کارشناسان */} +
+
+ + + +
-
- رضایت‌مندی زائرین -
۹۸.۵٪
+ +
+ + setSearchTerm(e.target.value)} + placeholder="جستجوی نام یا حوزه تخصص..." + className="h-9 ps-9 text-xs bg-surface-base rounded-xl border-border-soft" + /> + {searchTerm && ( + + )}
+ {/* ۳. کارت‌های کارشناسان با استایل مدرن */}
-

کادر مشاوران تخصصی عقیله

-
- {consultants.map((c) => ( -
-
-
- +
+
+

کادر کارشناسان پشتیبانی و مشاوره عقیله

+ + {toFa(filteredConsultants.length)} کارشناس + +
+ پشتیبانی زنده صوتی و متنی زائرین +
+ +
+ {filteredConsultants.map((c) => ( +
+
+
+
+ +
+ + {c.status} +
+

{c.name}

-

{c.specialty}

+

{c.specialty}

-
- - {c.status} - - {toFa(c.activeChats)} گفتگوی فعال + +
+
+ گفتگوهای فعال: + {toFa(c.activeChats)} جلسه +
+
+ رضایت مخاطبان: + {c.satisfaction} +
+
+ سابقه تخصصی: + {c.experience} +
))} @@ -490,55 +851,190 @@ function ConsultantServiceView({ service }: { service: ServiceMeta }) { // ۷. تخمین هزینه سفر (Cost Estimate) // ───────────────────────────────────────────────────────────────────────────── function CostEstimateServiceView({ service }: { service: ServiceMeta }) { + const [selectedDestination, setSelectedDestination] = useState('ALL') + const [searchTerm, setSearchTerm] = useState('') + const estimateCategories = [ - { id: 1, title: 'هتل و اقامتگاه (نجف / کربلا / مکه / مدینه)', standard: 'شبی ۲۵ الی ۴۰ دلار', vip: 'شبی ۶۰ الی ۱۲۰ دلار', notes: 'شامل صبحانه و اینترنت' }, - { id: 2, title: 'ترانسفر بین شهری و فرودگاهی (ون و سواری)', standard: 'هر مسیر ۱۰ الی ۱۵ دلار', vip: 'خودروی اختصاصی ۳۰ الی ۵۰ دلار', notes: 'مسیر نجف به کربلا، بغداد' }, - { id: 3, title: 'خوراک و پذیرایی روزانه', standard: 'روزانه ۱۰ الی ۱۵ دلار', vip: 'روزانه ۲۵ الی ۴۰ دلار', notes: 'رستوران‌های منتخب و هتل' }, - { id: 4, title: 'اینترنت و ارتباطات سیم‌کارت', standard: '۱۰ الی ۱۵ دلار برای کل سفر', vip: '۲۵ دلار بسته نامحدود', notes: 'سیم‌کارت‌های زین و آسیاسل' }, + { id: 1, title: 'هتل و اقامتگاه (نجف / کربلا / مکه / مدینه)', category: 'stay', standard: 'شبی 25 الی 40 دلار', vip: 'شبی 60 الی 120 دلار', notes: 'شامل صبحانه، اینترنت رایگان و نزدیکی به حرم' }, + { id: 2, title: 'ترانسفر بین شهری و فرودگاهی (ون و سواری)', category: 'transfer', standard: 'هر مسیر 10 الی 15 دلار', vip: 'خودروی اختصاصی 30 الی 50 دلار', notes: 'مسیر نجف به کربلا، کاظمین، سامرا و بغداد' }, + { id: 3, title: 'خوراک و پذیرایی روزانه', category: 'food', standard: 'روزانه 10 الی 15 دلار', vip: 'روزانه 25 الی 40 دلار', notes: 'رستوران‌های منتخب، غذای ایرانی و بوفه هتل' }, + { id: 4, title: 'اینترنت و ارتباطات سیم‌کارت', category: 'simcard', standard: '10 الی 15 دلار برای کل سفر', vip: '25 دلار بسته نامحدود', notes: 'سیم‌کارت‌های زین، آسیاسل و کورک با دیتا پرسرعت' }, + { id: 5, title: 'ویزا، عوارض خروج و بیمه زائر', category: 'visa', standard: 'رایگان (عراق) / 120 دلار (عربستان)', vip: 'ویزای تجاری و ترانزیت VIP', notes: 'بیمه درمان و حوادث تا سقف تعهدات کامل' }, ] + const filteredCategories = useMemo(() => { + return estimateCategories.filter((c) => { + if (selectedDestination !== 'ALL' && c.category !== selectedDestination) return false + if (searchTerm.trim()) { + const q = searchTerm.toLowerCase().trim() + const matchTitle = c.title.toLowerCase().includes(q) + const matchNotes = c.notes.toLowerCase().includes(q) + if (!matchTitle && !matchNotes) return false + } + return true + }) + }, [estimateCategories, selectedDestination, searchTerm]) + return (
+ {/* ۱. کارت‌های شاخص‌های آماری تخمین هزینه */}
-
- محاسبات انجام شده این ماه -
{toFa('12.8K')}
+
+
+ استعلام‌های این ماه + + زائرین + +
+
+ {toFa('12.8K')} +
+
+ +18% رشد استعلام‌های اربعین و عمره +
-
- دقت میانگین برآورد هوشمند -
۹۵٪
+ +
+
+ دقت برآورد هوشمند + +
+
+ 96.2% +
+
+ مطابقت با هزینه‌های واقعی سفر +
+
+ +
+
+ سرفصل‌های هزینه‌ای + + پوشش کامل + +
+
+ {toFa(estimateCategories.length)} بخش اصلی +
+
+ هتل، ترانسفر، خوراک، اینترنت، بیمه +
+
+ +
+
+ وضعیت نرخ‌ها + +
+
+ امروز +
+
+ همگام‌سازی لحظه‌ای با هتل‌ها و ترابری +
-
- پکیج‌های پیش‌فرض اقتصادی و ویژه -
{toFa(8)}
+
+ + {/* ۲. فیلترها و نوار جستجو */} +
+
+ + + +
-
- بروزرسانی نرخ‌ها -
امروز
+ +
+ + setSearchTerm(e.target.value)} + placeholder="جستجوی سرفصل یا نکات هزینه..." + className="h-9 ps-9 text-xs bg-surface-base rounded-xl border-border-soft" + /> + {searchTerm && ( + + )}
+ {/* ۳. جدول تخمین و بودجه‌بندی سفر با هدر کارتی مدرن */}
- - - - - - - - - - - {estimateCategories.map((c) => ( - - - - - +
+
+ + جدول برآورد تفکیکی مخارج سفر (اقتصادی و VIP) + + {toFa(filteredCategories.length)} آیتم + +
+ ارز مبنا: دلار و دینار +
+ +
+
بخش هزینهبرآورد بسته اقتصادی (Standard)برآورد بسته ویژه (VIP)توضیحات و نکات
{c.title}{c.standard}{c.vip}{c.notes}
+ + + + + + - ))} - -
بخش هزینهبسته اقتصادی (Standard)بسته ویژه (VIP)توضیحات و نکات اجرایی
+ + + {filteredCategories.map((c) => ( + + {c.title} + {c.standard} + {c.vip} + {c.notes} + + ))} + + +
) diff --git a/src/features/services/components/DuasServiceView.tsx b/src/features/services/components/DuasServiceView.tsx index b3fba84..f08267a 100644 --- a/src/features/services/components/DuasServiceView.tsx +++ b/src/features/services/components/DuasServiceView.tsx @@ -735,8 +735,97 @@ export function DuasServiceView() { return list } + // محاسبه شاخص‌های آماری زنده ادعیه + const duasStats = useMemo(() => { + const total = duas.length + const active = duas.filter((d) => d.is_active !== false).length + const withAudio = duas.filter((d) => (d.verses_count ?? 0) > 0).length + const citiesSet = new Set() + duas.forEach((d) => { + d.cities?.forEach((c) => citiesSet.add(c.name)) + d.shrines?.forEach((s) => citiesSet.add(s.city_name)) + }) + return { + total, + active, + withAudio, + citiesCount: citiesSet.size || 6, + } + }, [duas]) + return (
+ {/* ۰. کارت‌های آماری کلان ادعیه و زیارات */} +
+ {/* کل ادعیه */} +
+
+

کل ادعیه و زیارات

+ {isLoading ? ( + + ) : ( +

+ {toFa(duasStats.total)} +

+ )} +
+
+ +
+
+ + {/* شهرهای زیارتی */} +
+
+

شهرهای تحت پوشش

+ {isLoading ? ( + + ) : ( +

+ {toFa(duasStats.citiesCount)} +

+ )} +
+
+ +
+
+ + {/* ادعیه با صوت و فراز */} +
+
+

دارای متن و صوت

+ {isLoading ? ( + + ) : ( +

+ {toFa(duasStats.withAudio)} +

+ )} +
+
+ +
+
+ + {/* ادعیه فعال در اپ */} +
+
+

فعال در اپلیکیشن

+ {isLoading ? ( + + ) : ( +

+ {toFa(duasStats.active)} +

+ )} +
+
+ +
+
+
+ {/* نوار فیلتر و جستجوی پیشرفته */}
{/* اینپوت جستجو */} @@ -842,7 +931,12 @@ export function DuasServiceView() { )} {/* جدول اصلی لیست ادعیه */} -
+
+ {(isLoading || isRefreshing) && ( +
+
+
+ )}
@@ -1091,9 +1185,9 @@ export function DuasServiceView() { onChange={(e) => setPageSize(Number(e.target.value))} className="h-7 px-2 bg-card border border-border-soft rounded-lg text-xs text-foreground cursor-pointer" > - - - + + + @@ -1283,7 +1377,7 @@ export function DuasServiceView() { نام زیارت / دعا (چندزبانه) - ۵ زبان سامانه + 5 زبان سامانه {/* عربی پیش‌فرض و الزامی */} diff --git a/src/features/services/components/FaqServiceView.tsx b/src/features/services/components/FaqServiceView.tsx index 06dd0be..3c243a0 100644 --- a/src/features/services/components/FaqServiceView.tsx +++ b/src/features/services/components/FaqServiceView.tsx @@ -336,7 +336,7 @@ export function FaqServiceView() { }) return next }) - toast.success(`محتوای زبان ${catActiveLang.toUpperCase()} با موفقیت در تمامی ۵ زبان همگام‌سازی شد.`) + toast.success(`محتوای زبان ${catActiveLang.toUpperCase()} با موفقیت در تمامی 5 زبان همگام‌سازی شد.`) } const handleSaveCategory = async () => { @@ -478,7 +478,7 @@ export function FaqServiceView() { }) return next }) - toast.success(`محتوای سوال زبان ${itemActiveLang.toUpperCase()} با موفقیت در تمامی ۵ زبان همگام‌سازی شد.`) + toast.success(`محتوای سوال زبان ${itemActiveLang.toUpperCase()} با موفقیت در تمامی 5 زبان همگام‌سازی شد.`) } const handleSaveItem = async () => { @@ -727,11 +727,11 @@ export function FaqServiceView() { )) ) : ( <> - +

پرسش‌های این دسته‌بندی

-

+

{toFa(items.filter((i) => i.category === selectedCategory.id).length)}

@@ -741,11 +741,11 @@ export function FaqServiceView() {
- +

فعال در اپلیکیشن

-

+

{toFa(items.filter((i) => i.category === selectedCategory.id && i.is_active).length)}

@@ -755,11 +755,11 @@ export function FaqServiceView() {
- +

ترتیب نمایش دسته‌بندی

-

{toFa(selectedCategory.order)}

+

{toFa(selectedCategory.order)}

@@ -767,11 +767,11 @@ export function FaqServiceView() { - +
-

پوشش ۵ زبانه سامانه

-

۱۰۰٪

+

پوشش 5 زبانه سامانه

+

100%

@@ -912,7 +912,7 @@ export function FaqServiceView() { {/* وضعیت پوشش ۵ زبانه */}
- ترجمه‌های ۵ زبانه: + ترجمه‌های 5 زبانه:
{SUPPORTED_LANGUAGES.map((lang) => { const hasTrans = @@ -1005,11 +1005,11 @@ export function FaqServiceView() { )) ) : ( <> - +

کل دسته‌بندی‌ها

-

{toFa(categories.length)}

+

{toFa(categories.length)}

@@ -1017,11 +1017,11 @@ export function FaqServiceView() { - +

دسته‌بندی‌های فعال

-

+

{toFa(categories.filter((c) => c.is_active).length)}

@@ -1031,11 +1031,11 @@ export function FaqServiceView() {
- +

کل پرسش و پاسخ‌ها

-

{toFa(items.length)}

+

{toFa(items.length)}

@@ -1043,11 +1043,11 @@ export function FaqServiceView() { - +

سوالات فعال در اپ

-

+

{toFa(items.filter((i) => i.is_active).length)}

@@ -1325,7 +1325,7 @@ export function FaqServiceView() {
- عناوین چندزبانه دسته‌بندی (پوشش ۵ زبان) + عناوین چندزبانه دسته‌بندی (پوشش 5 زبان)
@@ -1337,7 +1337,7 @@ export function FaqServiceView() { size="sm" onClick={handleCopyCatTranslationsToAll} className="h-6 text-[10px] px-2 text-primary hover:bg-primary/10 rounded-lg gap-1" - title="کپی کردن متن این زبان به تمامی ۴ زبان دیگر" + title="کپی کردن متن این زبان به تمامی 4 زبان دیگر" > کپی در تمام زبان‌ها @@ -1466,7 +1466,7 @@ export function FaqServiceView() { {editingItem ? 'ویرایش پرسش و پاسخ' : 'ایجاد پرسش و پاسخ جدید'} - مدیریت متن پرسش و پاسخ به همراه پوشش ۵ زبان همزمان + مدیریت متن پرسش و پاسخ به همراه پوشش 5 زبان همزمان
@@ -1537,7 +1537,7 @@ export function FaqServiceView() {
- متن سوال و پاسخ (پوشش ۵ زبان) + متن سوال و پاسخ (پوشش 5 زبان)
@@ -1549,7 +1549,7 @@ export function FaqServiceView() { size="sm" onClick={handleCopyItemTranslationsToAll} className="h-6 text-[10px] px-2 text-primary hover:bg-primary/10 rounded-lg gap-1" - title="کپی کردن متن این زبان به تمامی ۴ زبان دیگر" + title="کپی کردن متن این زبان به تمامی 4 زبان دیگر" > کپی در تمام زبان‌ها diff --git a/src/features/services/components/LocationDetailSheet.tsx b/src/features/services/components/LocationDetailSheet.tsx index d548d2e..353017d 100644 --- a/src/features/services/components/LocationDetailSheet.tsx +++ b/src/features/services/components/LocationDetailSheet.tsx @@ -250,7 +250,7 @@ export function LocationDetailSheet({ ساعات کاری: {is24Hours ? ( - ۲۴ ساعته (شبانه‌روزی) + 24 ساعته (شبانه‌روزی) ) : ( {location.working_hours_from ? location.working_hours_from.substring(0, 5) : '08:00'} -{' '} diff --git a/src/features/services/components/SimcardFormDialog.tsx b/src/features/services/components/SimcardFormDialog.tsx index 6e480de..d50c6c7 100644 --- a/src/features/services/components/SimcardFormDialog.tsx +++ b/src/features/services/components/SimcardFormDialog.tsx @@ -37,21 +37,21 @@ export const LANGUAGES = [ { code: 'id', name: 'Bahasa Indonesia', flag: '🇮🇩', badge: 'ID', dir: 'ltr', label: 'اندونزیایی' }, ] as const -const EMPTY_TITLES: Record = { +const createEmptyTitles = (): Record => ({ ar: '', fa: '', en: '', ru: '', id: '', -} +}) -const EMPTY_DETAILS: Record = { +const createEmptyDetails = (): Record => ({ ar: [], fa: [], en: [], ru: [], id: [], -} +}) export function SimcardFormDialog({ open, @@ -68,10 +68,10 @@ export function SimcardFormDialog({ const [status, setStatus] = useState(1) // عناوین چندزبانه - const [titles, setTitles] = useState>({ ...EMPTY_TITLES }) + const [titles, setTitles] = useState>(createEmptyTitles) // ویژگی‌های چندزبانه - const [details, setDetails] = useState>({ ...EMPTY_DETAILS }) + const [details, setDetails] = useState>(createEmptyDetails) // افزودن ویژگی جدید در زبان فعال const [newDetailText, setNewDetailText] = useState('') @@ -86,6 +86,8 @@ export function SimcardFormDialog({ const [errorMsg, setErrorMsg] = useState(null) useEffect(() => { + if (!open) return + setActiveLangTab('ar') if (item) { const cId = item.country?.id || 1 @@ -94,7 +96,7 @@ export function SimcardFormDialog({ setStatus(item.status ?? 1) // استخراج عناوین ۵ زبانه از دیتابیس - const parsedTitles: Record = { ...EMPTY_TITLES } + const parsedTitles = createEmptyTitles() if (Array.isArray(item.raw_title)) { for (const t of item.raw_title) { if (t && typeof t === 'object' && t.language_code && t.title) { @@ -115,36 +117,54 @@ export function SimcardFormDialog({ } setTitles(parsedTitles) - // استخراج ویژگی‌های ۵ زبانه از دیتابیس - const parsedDetails: Record = { ...EMPTY_DETAILS } + // استخراج ویژگی‌های ۵ زبانه از دیتابیس (آرایه‌های کاملاً مستقل جهت جلوگیری از Mutation) + const parsedDetails = createEmptyDetails() if (Array.isArray(item.raw_detail)) { for (const d of item.raw_detail) { if (d && typeof d === 'object' && d.language_code && d.detail) { const lang = d.language_code as SupportedLanguage if (lang in parsedDetails) { - parsedDetails[lang].push(d.detail) + const text = String(d.detail).trim() + // جلوگیری از موارد تکراری ناشی از ثبت‌های قبلی + if (text && !parsedDetails[lang].includes(text)) { + parsedDetails[lang].push(text) + } } } } } else if (item.raw_detail && typeof item.raw_detail === 'object') { for (const lang of ['ar', 'fa', 'en', 'ru', 'id'] as SupportedLanguage[]) { if (Array.isArray(item.raw_detail[lang])) { - parsedDetails[lang] = [...item.raw_detail[lang]] + const uniqueList: string[] = [] + for (const x of item.raw_detail[lang]) { + const text = String(x || '').trim() + if (text && !uniqueList.includes(text)) { + uniqueList.push(text) + } + } + parsedDetails[lang] = uniqueList } } } if (parsedDetails.ar.length === 0 && parsedDetails.fa.length === 0 && Array.isArray(item.simcard_detail)) { - parsedDetails.ar = [...item.simcard_detail] - parsedDetails.fa = [...item.simcard_detail] + const uniqueList: string[] = [] + for (const x of item.simcard_detail) { + const text = String(x || '').trim() + if (text && !uniqueList.includes(text)) { + uniqueList.push(text) + } + } + parsedDetails.ar = [...uniqueList] + parsedDetails.fa = [...uniqueList] } setDetails(parsedDetails) } else { setCountryId(1) setPrice('10.00') setStatus(1) - setTitles({ ...EMPTY_TITLES }) - setDetails({ ...EMPTY_DETAILS }) + setTitles(createEmptyTitles()) + setDetails(createEmptyDetails()) } setNewDetailText('') @@ -508,7 +528,7 @@ export function SimcardFormDialog({ هنوز ویژگی خاصی به زبان {currentLang.name} اضافه نشده است. می‌توانید با فیلد بالا ویژگی جدید ثبت کنید.
) : ( -
+
{activeDetails.map((itemText, idx) => (
{/* فوتر فیکس‌شده در پایین */} - + + {/* دکمه حذف در سمت راست (Start در RTL) */} +
+ {isEditing && onDelete && ( + + )} +
+ + {/* دکمه‌های انصراف و ذخیره در سمت چپ (End در RTL) */}
- -
- - {/* دکمه حذف بسته در گوشه فرم برای حالت ویرایش */} - {isEditing && onDelete && ( - )} +
diff --git a/src/features/services/components/SimcardServiceView.tsx b/src/features/services/components/SimcardServiceView.tsx index 65d7c8d..840b559 100644 --- a/src/features/services/components/SimcardServiceView.tsx +++ b/src/features/services/components/SimcardServiceView.tsx @@ -98,6 +98,7 @@ export function SimcardServiceView({ service }: { service: ServiceMeta }) { ) } setIsFormOpen(false) + setEditingItem(null) loadData() } @@ -107,6 +108,7 @@ export function SimcardServiceView({ service }: { service: ServiceMeta }) { await deleteSimCard(id) toast.success('بسته سیم‌کارت با موفقیت از دیتابیس حذف شد.', 'حذف موفق') setIsFormOpen(false) + setEditingItem(null) loadData() } catch (err: any) { toast.error(err?.message || 'خطایی در حذف سیم‌کارت رخ داد.', 'خطا') @@ -150,28 +152,39 @@ export function SimcardServiceView({ service }: { service: ServiceMeta }) {
{/* کارت‌های آمار واقعی منطبق با دیتابیس */}
-
+
بسته‌های فعال سیم‌کارت - در اپلیکیشن + + در اپلیکیشن +
{isLoading ? : toFa(simcards.length)}
+
+ تنوع اپراتورها و دیتای سفر +
-
- کشورهای تحت پوشش +
+
+ کشورهای تحت پوشش + 🌍 +
- {toFa(3)}{' '} - - (🇮🇷 ایران، 🇮🇶 عراق، 🇸🇦 عربستان) - + {toFa(3)} کشور +
+
+ 🇮🇷 ایران، 🇮🇶 عراق، 🇸🇦 عربستان
-
- میانگین قیمت بسته‌ها +
+
+ میانگین قیمت بسته‌ها + +
{isLoading ? ( @@ -179,9 +192,12 @@ export function SimcardServiceView({ service }: { service: ServiceMeta }) { `${toFa(averagePrice)}$` )}
+
+ قیمت‌گذاری رقابتی زائر +
-
+
سفارش‌های ثبت‌شده @@ -200,6 +216,9 @@ export function SimcardServiceView({ service }: { service: ServiceMeta }) { toFa(ordersCount) )}
+
+ همگام با صورت‌حساب‌ها +
@@ -276,6 +295,27 @@ export function SimcardServiceView({ service }: { service: ServiceMeta }) { {/* جدول بسته‌های سیم‌کارت مطابق با دیتابیس */}
+
+
+ + فهرست بسته‌های اینترنت و سیم‌کارت + + {toFa(filtered.length)} بسته + +
+ {isLoading && ( + + + در حال دریافت از سرور... + + )} +
+ {isLoading && ( +
+
+
+ )} +
@@ -431,7 +471,12 @@ export function SimcardServiceView({ service }: { service: ServiceMeta }) { {/* فرم ایجاد و ویرایش سیم‌کارت */} { + setIsFormOpen(open) + if (!open) { + setEditingItem(null) + } + }} item={editingItem} onSave={handleSave} onDelete={handleDelete} diff --git a/src/features/services/components/StoreServiceView.tsx b/src/features/services/components/StoreServiceView.tsx index 1828ebe..5200d5d 100644 --- a/src/features/services/components/StoreServiceView.tsx +++ b/src/features/services/components/StoreServiceView.tsx @@ -500,60 +500,104 @@ export function StoreServiceView({ {/* کارت‌های شاخص‌های کلیدی (KPIs) بر اساس نمای فعال */} {activeTab === 'categories' ? (
-
- کل دسته‌بندی‌های فروشگاه +
+
+ کل دسته‌بندی‌های فروشگاه + +
{isLoadingCategories ? : toFa(totalCategoriesCount)}
+
مدیریت ردیف‌های کالایی
-
- دسته‌بندی‌های فعال (قابل نمایش) + +
+
+ دسته‌بندی‌های فعال + + نمایش در اپ + +
{isLoadingCategories ? : toFa(activeCategoriesCount)}
+
در دسترس زائرین
-
- دسته‌بندی‌های غیرفعال (پنهان) + +
+
+ دسته‌بندی‌های پنهان + + عدم نمایش + +
{isLoadingCategories ? : toFa(inactiveCategoriesCount)}
+
موقت یا خارج از فصل
-
- کالاهای متصل به دسته‌ها + +
+
+ کالاهای تخصیص‌یافته + +
{isLoadingCategories ? : toFa(totalConnectedProducts)}
+
اقلام فعال در دسته‌ها
) : (
-
- کل کالاهای موجود در انبار +
+
+ کالاهای ثبت‌شده انبار + + انبار معصومه + +
{isLoadingProducts ? : toFa(products.length || totalConnectedProducts)}
+
سوغات و ملزومات زیارت
-
- دسته‌بندی‌های در دسترس + +
+
+ دسته‌بندی‌های موجود + +
{isLoadingCategories ? : toFa(activeCategoriesCount)}
+
گروه‌های فعال فروشگاهی
-
- مجموع موجودی فیزیکی -
+ +
+
+ مجموع موجودی فیزیکی + +
+
{isLoadingProducts ? ( ) : ( toFa(products.reduce((acc, p) => acc + (Number(p.stock) || 0), 0)) )}
+
تعداد کل اقلام در انبار
-
- امکان ارسال به هتل‌های عتبات -
+ +
+
+ ارسال مستقیم در مقصد + +
+
فعال
+
تحویل در هتل‌های نجف، کربلا، مکه
)} @@ -1075,7 +1119,7 @@ export function StoreServiceView({
- عناوین چندزبانه دسته‌بندی (پوشش ۵ زبان) + عناوین چندزبانه دسته‌بندی (پوشش 5 زبان)
@@ -1087,7 +1131,7 @@ export function StoreServiceView({ size="sm" onClick={handleCopyCatTranslationsToAll} className="h-6 text-[10px] px-2 text-primary hover:bg-primary/10 rounded-lg gap-1" - title="کپی کردن عنوان این زبان به تمامی ۴ زبان دیگر" + title="کپی کردن عنوان این زبان به تمامی 4 زبان دیگر" > کپی در تمام زبان‌ها diff --git a/src/features/services/components/ZayerGuideServiceView.tsx b/src/features/services/components/ZayerGuideServiceView.tsx index 4f1676e..813acdd 100644 --- a/src/features/services/components/ZayerGuideServiceView.tsx +++ b/src/features/services/components/ZayerGuideServiceView.tsx @@ -342,45 +342,51 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
{/* ۱. سربرگ آماری کلان دیتابیس */}
- - - کل اماکن ثبت‌شده در دیتابیس -
- {toFa(stats.total)} -
-
داده‌های رسمی و همگام با اپ
-
-
- - - - شهرهای زیارتی فعال -
- {toFa(stats.citiesCount || allCitiesList.length)} -
-
عراق، عربستان، ایران، سوریه
-
-
- - - - دسته‌بندی‌های خدماتی -
- {toFa(categories.length || stats.categoriesCount || 8)} -
-
مساجد، درمانی، تاریخی و...
-
-
- - - +
+
+ اماکن ثبت‌شده در سامانه + + دیتابیس آنلاین + +
+
+ {toFa(stats.total)} +
+
داده‌های رسمی و همگام با اپ
+
+ +
+
+ شهرهای زیارتی فعال + 📍 +
+
+ {toFa(stats.citiesCount || allCitiesList.length)} +
+
عراق، عربستان، ایران، سوریه
+
+ +
+
+ دسته‌بندی‌های خدماتی + +
+
+ {toFa(categories.length || stats.categoriesCount || 8)} +
+
مساجد، درمانی، تاریخی و خرید
+
+ +
+
کشورهای تحت پوشش -
- {toFa(countries.length || 4)} -
-
عراق، عربستان، ایران، سوریه
- - + 🌍 +
+
+ {toFa(countries.length || 4)} کشور +
+
🇮🇶 عراق، 🇸🇦 عربستان، 🇮🇷 ایران، 🇸🇾 سوریه
+
{/* ۲. نوار جستجو، فیلتر شهر/کشور و دسته‌بندی */} diff --git a/src/features/settings/SettingsPage.tsx b/src/features/settings/SettingsPage.tsx new file mode 100644 index 0000000..b4318ce --- /dev/null +++ b/src/features/settings/SettingsPage.tsx @@ -0,0 +1,87 @@ +import * as React from 'react' +import { useTranslation } from '@/i18n' +import { Card, CardContent } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Ic, IconName } from '@/icons' +import { AdminManagementTab } from './components/AdminManagementTab' +import { NotificationsTab } from './components/NotificationsTab' + +interface SettingsTabConfig { + id: string + labelKey: string + icon: IconName + isCore?: boolean +} + +const SETTINGS_TABS: SettingsTabConfig[] = [ + { id: 'admins', labelKey: 'settings.tabAdmins', icon: 'users', isCore: true }, + { id: 'notifications', labelKey: 'settings.tabNotifications', icon: 'bell' }, +] + +export default function SettingsPage() { + const { t } = useTranslation() + const [activeTab, setActiveTab] = React.useState('admins') + + return ( +
+ {/* ۱. سربرگ اصلی صفحه تنظیمات (مطابق الگوی طراحی جامع عقیله) */} +
+
+
+
+ +
+

+ {t('settings.pageTitle')} +

+ + {t('settings.badgeRoleControl')} + +
+

+ {t('settings.pageSubtitle')} +

+
+
+ + {/* ۲. نوار تب‌های تفکیک بخش‌های تنظیمات */} +
+ {SETTINGS_TABS.map((tab) => { + const isActive = activeTab === tab.id + return ( + + ) + })} +
+ + {/* ۳. محتوای تب فعال */} + {activeTab === 'admins' ? : } +
+ ) +} diff --git a/src/features/settings/components/AdminManagementTab.tsx b/src/features/settings/components/AdminManagementTab.tsx new file mode 100644 index 0000000..3e0f9ac --- /dev/null +++ b/src/features/settings/components/AdminManagementTab.tsx @@ -0,0 +1,273 @@ +import * as React from 'react' +import { useTranslation } from '@/i18n' +import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button' +import { Ic } from '@/icons' +import { AdminStatsCards } from './AdminStatsCards' +import { AdminUserTable } from './AdminUserTable' +import { SetPasswordModal } from './SetPasswordModal' +import { CreateAdminModal } from './CreateAdminModal' +import { fetchAdminUsers, fetchAdminStats } from '../services/settings-api' +import type { AdminUserItem, AdminStats } from '../types' + +export function AdminManagementTab() { + const { t } = useTranslation() + + const [admins, setAdmins] = React.useState([]) + const [totalCount, setTotalCount] = React.useState(0) + const [stats, setStats] = React.useState(null) + const [loading, setLoading] = React.useState(true) + const [loadingStats, setLoadingStats] = React.useState(true) + + // Filters + const [search, setSearch] = React.useState('') + const [debouncedSearch, setDebouncedSearch] = React.useState('') + const [selectedStatus, setSelectedStatus] = React.useState('all') + const [offset, setOffset] = React.useState(0) + const limit = 15 + + // Modals state + const [selectedAdminForPassword, setSelectedAdminForPassword] = React.useState(null) + const [setPasswordOpen, setSetPasswordOpen] = React.useState(false) + const [createAdminOpen, setCreateAdminOpen] = React.useState(false) + + // Debounce search + React.useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearch(search) + setOffset(0) + }, 350) + return () => clearTimeout(timer) + }, [search]) + + const loadStats = React.useCallback(async () => { + setLoadingStats(true) + try { + const data = await fetchAdminStats() + setStats(data) + } catch (err) { + console.error('Failed to load admin stats:', err) + } finally { + setLoadingStats(false) + } + }, []) + + const loadAdmins = React.useCallback(async () => { + setLoading(true) + try { + const res = await fetchAdminUsers({ + search: debouncedSearch, + is_active: selectedStatus === 'all' ? undefined : selectedStatus === 'active' ? 'true' : 'false', + limit, + offset, + }) + setAdmins(res.results || []) + setTotalCount(res.count || 0) + } catch (err) { + console.error('Failed to load admin users:', err) + setAdmins([]) + setTotalCount(0) + } finally { + setLoading(false) + } + }, [debouncedSearch, selectedStatus, offset]) + + React.useEffect(() => { + loadStats() + }, [loadStats]) + + React.useEffect(() => { + loadAdmins() + }, [loadAdmins]) + + const handleOpenSetPassword = (admin: AdminUserItem) => { + setSelectedAdminForPassword(admin) + setSetPasswordOpen(true) + } + + const handlePasswordUpdated = (adminId: number, newPassword: string) => { + setAdmins((prev) => + prev.map((adm) => + adm.id === adminId ? { ...adm, available_password: newPassword } : adm + ) + ) + } + + const handleAdminUpdated = (updatedAdmin: AdminUserItem) => { + setAdmins((prev) => + prev.map((adm) => (adm.id === updatedAdmin.id ? { ...adm, ...updatedAdmin } : adm)) + ) + loadStats() + } + + const handleAdminCreated = (newAdmin: AdminUserItem) => { + setAdmins((prev) => [newAdmin, ...prev]) + setTotalCount((prev) => prev + 1) + loadStats() + } + + return ( +
+ {/* کارت‌های شاخص آمار و تعداد ادمین‌ها */} + + + {/* نوار جستجو، فیلترها و دکمه افزودن مدیر جدید */} +
+
+ {/* فیلد جستجوی زنده */} +
+ setSearch(e.target.value)} + placeholder={t('settings.searchAdminsPlaceholder')} + className="ps-9 h-9 text-xs" + /> + + {search && ( + + )} +
+ + {/* فیلتر وضعیت */} +
+ + + +
+
+ + {/* دکمه‌های اکشن: افزودن مدیر + تازه‌سازی */} +
+ + + +
+
+ + {/* جدول نمایش ادمین‌ها */} + + + {/* پیجینیشن صفحه‌بندی */} + {totalCount > limit && ( +
+

+ {t('users.page')} {Math.floor(offset / limit) + 1} {t('users.of')}{' '} + {Math.ceil(totalCount / limit)} ({totalCount} {t('settings.adminsCountLabel')}) +

+
+ + +
+
+ )} + + {/* مودال تعیین و تغییر رمز عبور */} + + + {/* مودال ایجاد ادمین جدید */} + +
+ ) +} diff --git a/src/features/settings/components/AdminStatsCards.tsx b/src/features/settings/components/AdminStatsCards.tsx new file mode 100644 index 0000000..888e6f9 --- /dev/null +++ b/src/features/settings/components/AdminStatsCards.tsx @@ -0,0 +1,103 @@ +import * as React from 'react' +import { useTranslation } from '@/i18n' +import { Card, CardContent } from '@/components/ui/card' +import { Ic } from '@/icons' +import { ShimmerBox } from '@/components/ui/shimmer' +import type { AdminStats } from '../types' + +interface AdminStatsCardsProps { + stats: AdminStats | null + loading: boolean +} + +export function AdminStatsCards({ stats, loading }: AdminStatsCardsProps) { + const { t } = useTranslation() + + return ( +
+ {/* ۱. کل مدیران سیستم */} + + +
+

+ {t('settings.kpiTotalAdmins')} +

+ {loading && !stats ? ( + + ) : ( +

+ {stats?.staff_users ?? 0} +

+ )} +
+
+ +
+
+
+ + {/* ۲. مدیران فعال */} + + +
+

+ {t('settings.kpiActiveAdmins')} +

+ {loading && !stats ? ( + + ) : ( +

+ {stats?.active_staff_users ?? 0} +

+ )} +
+
+ +
+
+
+ + {/* ۳. مدیران ارشد (سوپراوزر) */} + + +
+

+ {t('settings.kpiSuperusers')} +

+ {loading && !stats ? ( + + ) : ( +

+ {stats?.superusers_count ?? 0} +

+ )} +
+
+ +
+
+
+ + {/* ۴. مدیران غیرفعال */} + + +
+

+ {t('settings.kpiInactiveAdmins')} +

+ {loading && !stats ? ( + + ) : ( +

+ {stats?.inactive_staff_users ?? 0} +

+ )} +
+
+ +
+
+
+
+ ) +} diff --git a/src/features/settings/components/AdminUserTable.tsx b/src/features/settings/components/AdminUserTable.tsx new file mode 100644 index 0000000..bcce3dd --- /dev/null +++ b/src/features/settings/components/AdminUserTable.tsx @@ -0,0 +1,321 @@ +import * as React from 'react' +import { Link } from 'react-router-dom' +import { useTranslation } from '@/i18n' +import { Button } from '@/components/ui/button' +import { Ic } from '@/icons' +import { ShimmerBox } from '@/components/ui/shimmer' +import { toast } from '@/stores/toast' +import { updateAdminUser } from '../services/settings-api' +import type { AdminUserItem } from '../types' + +interface AdminUserTableProps { + admins: AdminUserItem[] + loading: boolean + onSetPassword: (admin: AdminUserItem) => void + onAdminUpdated: (updatedAdmin: AdminUserItem) => void +} + +export function AdminUserTable({ + admins, + loading, + onSetPassword, + onAdminUpdated, +}: AdminUserTableProps) { + const { t } = useTranslation() + const [togglingId, setTogglingId] = React.useState(null) + const [copiedId, setCopiedId] = React.useState(null) + const [revealedIds, setRevealedIds] = React.useState>({}) + + const handleToggleStatus = async (admin: AdminUserItem) => { + setTogglingId(admin.id) + const newStatus = !admin.is_active + const adminName = admin.fullname || admin.email || admin.phone_number || '' + try { + const updated = await updateAdminUser(admin.id, { is_active: newStatus }) + toast.success( + newStatus + ? t('settings.adminActivatedNotice', { name: adminName }) + : t('settings.adminDeactivatedNotice', { name: adminName }), + t('settings.statusUpdatedTitle') + ) + onAdminUpdated(updated) + } catch (err: any) { + toast.error(err?.message || t('settings.errorStatusToggleFailed')) + } finally { + setTogglingId(null) + } + } + + const handleCopyPassword = (admin: AdminUserItem) => { + if (admin.available_password) { + navigator.clipboard.writeText(admin.available_password) + setCopiedId(admin.id) + toast.success(t('settings.passwordCopiedSuccess')) + setTimeout(() => setCopiedId(null), 2000) + } + } + + const toggleRevealPassword = (id: number) => { + setRevealedIds((prev) => ({ ...prev, [id]: !prev[id] })) + } + + const formatDate = (isoString?: string | null) => { + if (!isoString) return '—' + try { + const date = new Date(isoString) + return new Intl.DateTimeFormat('fa-IR', { + dateStyle: 'medium', + timeStyle: 'short', + }).format(date) + } catch { + return isoString + } + } + + return ( +
+
+
+ + + + + + + + + + + + + {loading ? ( + Array.from({ length: 4 }).map((_, idx) => ( + + + + + + + + + + )) + ) : admins.length === 0 ? ( + + + + ) : ( + admins.map((admin) => { + const isRevealed = !!revealedIds[admin.id] + const isToggling = togglingId === admin.id + + return ( + + {/* ۱. ادمین */} + + + {/* ۲. تماس و ارتباط */} + + + {/* ۳. نقش */} + + + {/* ۴. وضعیت فعال/غیرفعال */} + + + {/* ۵. رمز عبور / رمز در دسترس */} + + + {/* ۶. تاریخ ثبت */} + + + {/* ۷. دکمه‌های عملیات */} + + + ) + }) + )} + +
{t('settings.colAdmin')}{t('settings.colContact')}{t('settings.colRole')}{t('settings.colStatus')}{t('settings.colPassword')}{t('settings.colDateJoined')}{t('settings.colActions')}
+
+ +
+ + +
+
+
+ + + + + + + + + + + +
+
+
+ +
+

+ {t('settings.noAdminsFoundTitle')} +

+

+ {t('settings.noAdminsFoundDesc')} +

+
+
+
+
+ {admin.avatar ? ( + {admin.fullname} + ) : ( + (admin.fullname || admin.email || 'A').charAt(0).toUpperCase() + )} +
+
+
+ + {admin.fullname || t('users.unnamedUser')} + + + #{admin.id} + +
+ {admin.last_login && ( +

+ {t('users.lastLogin')}: {formatDate(admin.last_login)} +

+ )} +
+
+
+ {admin.email && ( +
+ {admin.email} +
+ )} + {admin.phone_number && ( +
+ {admin.phone_number} +
+ )} + {!admin.email && !admin.phone_number && '—'} +
+ + + + {admin.is_superuser + ? t('users.admin') + : t('settings.roleStaff')} + + + + + + {admin.available_password ? ( +
+ + + {isRevealed ? admin.available_password : '••••••••'} + + + +
+ ) : ( + + + {t('settings.passwordCustomOrEncrypted')} + + )} +
+ {formatDate(admin.date_joined)} + +
+ + + + + +
+
+
+
+ ) +} diff --git a/src/features/settings/components/CreateAdminModal.tsx b/src/features/settings/components/CreateAdminModal.tsx new file mode 100644 index 0000000..33c7729 --- /dev/null +++ b/src/features/settings/components/CreateAdminModal.tsx @@ -0,0 +1,283 @@ +import * as React from 'react' +import { useTranslation } from '@/i18n' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Switch } from '@/components/ui/switch' +import { Ic } from '@/icons' +import { toast } from '@/stores/toast' +import { createAdminUser } from '../services/settings-api' +import type { AdminUserItem } from '../types' + +interface CreateAdminModalProps { + open: boolean + onOpenChange: (open: boolean) => void + onSuccess: (newAdmin: AdminUserItem) => void +} + +export function CreateAdminModal({ + open, + onOpenChange, + onSuccess, +}: CreateAdminModalProps) { + const { t } = useTranslation() + + const [fullname, setFullname] = React.useState('') + const [email, setEmail] = React.useState('') + const [phoneNumber, setPhoneNumber] = React.useState('') + const [password, setPassword] = React.useState('') + const [isSuperuser, setIsSuperuser] = React.useState(false) + const [isActive, setIsActive] = React.useState(true) + const [showPassword, setShowPassword] = React.useState(false) + const [submitting, setSubmitting] = React.useState(false) + + React.useEffect(() => { + if (open) { + setFullname('') + setEmail('') + setPhoneNumber('') + setPassword('') + setIsSuperuser(false) + setIsActive(true) + setShowPassword(false) + } + }, [open]) + + const generateStrongPassword = () => { + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%^&*' + let result = '' + for (let i = 0; i < 12; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)) + } + setPassword(result) + setShowPassword(true) + toast.info(t('settings.passwordGeneratedNotice'), t('settings.generatedPasswordTitle')) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + const trimmedName = fullname.trim() + const trimmedEmail = email.trim() + const trimmedPhone = phoneNumber.trim() + const trimmedPassword = password.trim() + + if (!trimmedName) { + toast.error(t('settings.errorNameRequired')) + return + } + + if (!trimmedEmail && !trimmedPhone) { + toast.error(t('settings.errorEmailOrPhoneRequired')) + return + } + + if (!trimmedPassword || trimmedPassword.length < 6) { + toast.error(t('settings.errorPasswordMinLength')) + return + } + + setSubmitting(true) + try { + const created = await createAdminUser({ + fullname: trimmedName, + email: trimmedEmail || undefined, + phone_number: trimmedPhone || undefined, + password: trimmedPassword, + is_staff: true, + is_superuser: isSuperuser, + is_active: isActive, + }) + + toast.success( + t('settings.adminCreatedSuccess', { name: created.fullname || trimmedName }), + t('settings.successTitle') + ) + onSuccess(created) + onOpenChange(false) + } catch (err: any) { + toast.error(err?.message || t('settings.errorAdminCreateFailed')) + } finally { + setSubmitting(false) + } + } + + return ( + + + +
+
+ +
+
+ + {t('settings.createAdminModalTitle')} + + + {t('settings.createAdminModalDesc')} + +
+
+
+ +
+ {/* نام و نام خانوادگی */} +
+ + setFullname(e.target.value)} + placeholder={t('settings.adminFullnamePlaceholder')} + className="text-xs" + /> +
+ + {/* دو ستونه: ایمیل و شماره موبایل */} +
+
+ + setEmail(e.target.value)} + placeholder="admin@example.com" + className="text-xs font-mono" + dir="ltr" + /> +
+ +
+ + setPhoneNumber(e.target.value)} + placeholder="+98912..." + className="text-xs font-mono" + dir="ltr" + /> +
+
+

+ {t('settings.adminEmailOrPhoneHint')} +

+ + {/* رمز عبور اولیه */} +
+
+ + +
+
+ setPassword(e.target.value)} + placeholder="••••••••••••" + className="pe-10 font-mono text-sm tracking-wider" + dir="ltr" + /> + +
+

+ {t('settings.passwordMinLengthHint')} +

+
+ + {/* نقش و سطح دسترسی + وضعیت حساب */} +
+
+
+

+ {t('settings.roleSuperuserTitle')} +

+

+ {t('settings.roleSuperuserDesc')} +

+
+ +
+ +
+ +
+
+

+ {t('settings.accountActiveTitle')} +

+

+ {t('settings.accountActiveDesc')} +

+
+ +
+
+ + + + + + + +
+ ) +} diff --git a/src/features/settings/components/NotificationsTab.tsx b/src/features/settings/components/NotificationsTab.tsx new file mode 100644 index 0000000..9719773 --- /dev/null +++ b/src/features/settings/components/NotificationsTab.tsx @@ -0,0 +1,44 @@ +import { useTranslation } from '@/i18n' +import { Card, CardContent } from '@/components/ui/card' +import { Ic } from '@/icons' + +export function NotificationsTab() { + const { t } = useTranslation() + + return ( + + +
+ +
+ +
+
+ + {t('navigation.soon')} +
+ +

+ {t('settings.notificationsTabTitle')} +

+ +

+ {t('settings.notificationsTabDesc')} +

+
+ +
+
+ + {t('settings.notificationsUpcomingFeaturesTitle')} +
+
    +
  • {t('settings.notificationsFeature1')}
  • +
  • {t('settings.notificationsFeature2')}
  • +
  • {t('settings.notificationsFeature3')}
  • +
+
+
+
+ ) +} diff --git a/src/features/settings/components/SetPasswordModal.tsx b/src/features/settings/components/SetPasswordModal.tsx new file mode 100644 index 0000000..8de6be5 --- /dev/null +++ b/src/features/settings/components/SetPasswordModal.tsx @@ -0,0 +1,281 @@ +import * as React from 'react' +import { useTranslation } from '@/i18n' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Ic } from '@/icons' +import { toast } from '@/stores/toast' +import { setAdminPassword } from '../services/settings-api' +import type { AdminUserItem } from '../types' + +interface SetPasswordModalProps { + admin: AdminUserItem | null + open: boolean + onOpenChange: (open: boolean) => void + onSuccess: (updatedAdminId: number, newPassword: string) => void +} + +export function SetPasswordModal({ + admin, + open, + onOpenChange, + onSuccess, +}: SetPasswordModalProps) { + const { t } = useTranslation() + + const [password, setPassword] = React.useState('') + const [confirmPassword, setConfirmPassword] = React.useState('') + const [showPassword, setShowPassword] = React.useState(false) + const [showConfirmPassword, setShowConfirmPassword] = React.useState(false) + const [submitting, setSubmitting] = React.useState(false) + const [copiedCurrent, setCopiedCurrent] = React.useState(false) + + // Reset form when opened or admin changes + React.useEffect(() => { + if (open) { + setPassword('') + setConfirmPassword('') + setShowPassword(false) + setShowConfirmPassword(false) + setCopiedCurrent(false) + } + }, [open, admin]) + + const generateStrongPassword = () => { + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%^&*' + let result = '' + for (let i = 0; i < 12; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)) + } + setPassword(result) + setConfirmPassword(result) + setShowPassword(true) + setShowConfirmPassword(true) + toast.info(t('settings.passwordGeneratedNotice'), t('settings.generatedPasswordTitle')) + } + + const handleCopyCurrent = () => { + if (admin?.available_password) { + navigator.clipboard.writeText(admin.available_password) + setCopiedCurrent(true) + toast.success(t('settings.passwordCopiedSuccess')) + setTimeout(() => setCopiedCurrent(false), 2500) + } + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!admin) return + + const trimmedPassword = password.trim() + const trimmedConfirm = confirmPassword.trim() + + if (!trimmedPassword) { + toast.error(t('settings.errorPasswordRequired')) + return + } + + if (trimmedPassword.length < 6) { + toast.error(t('settings.errorPasswordMinLength')) + return + } + + if (trimmedPassword !== trimmedConfirm) { + toast.error(t('settings.errorPasswordMismatch')) + return + } + + setSubmitting(true) + try { + await setAdminPassword(admin.id, { password: trimmedPassword }) + const targetName = admin.fullname || admin.phone_number || admin.email || '' + toast.success( + t('settings.passwordUpdateSuccess', { name: targetName }), + t('settings.successTitle') + ) + onSuccess(admin.id, trimmedPassword) + onOpenChange(false) + } catch (err: any) { + toast.error(err?.message || t('settings.errorPasswordUpdateFailed')) + } finally { + setSubmitting(false) + } + } + + if (!admin) return null + + return ( + + + +
+
+ +
+
+ + {t('settings.setPasswordModalTitle')} + + + {t('settings.setPasswordModalDesc')} + +
+
+
+ +
+ {/* مشخصات ادمین هدف */} +
+
+
+ {(admin.fullname || admin.email || 'A').charAt(0).toUpperCase()} +
+
+

+ {admin.fullname || t('users.unnamedUser')} +

+

+ {admin.email || admin.phone_number || `#${admin.id}`} +

+
+
+ + {admin.is_superuser ? t('users.admin') : t('settings.roleStaff')} + +
+ + {/* اگر رمز فعلی ذخیره‌شده دارد، نمایش و دکمه کپی */} + {admin.available_password && ( +
+
+ + + {t('settings.currentAvailablePassword')}: + + + {admin.available_password} + +
+ +
+ )} + + {/* فیلد رمز عبور جدید */} +
+
+ + +
+
+ setPassword(e.target.value)} + placeholder="••••••••••••" + className="pe-10 font-mono text-sm tracking-wider" + dir="ltr" + /> + +
+

+ {t('settings.passwordMinLengthHint')} +

+
+ + {/* فیلد تکرار رمز عبور */} +
+ +
+ setConfirmPassword(e.target.value)} + placeholder="••••••••••••" + className="pe-10 font-mono text-sm tracking-wider" + dir="ltr" + /> + +
+ {confirmPassword && password !== confirmPassword && ( +

+ {t('settings.errorPasswordMismatch')} +

+ )} +
+ + + + + +
+
+
+ ) +} diff --git a/src/features/settings/services/settings-api.ts b/src/features/settings/services/settings-api.ts new file mode 100644 index 0000000..3edab15 --- /dev/null +++ b/src/features/settings/services/settings-api.ts @@ -0,0 +1,108 @@ +import { apiFetch } from '@/services/http' +import type { DrfPaginatedResponse } from '@/features/users/types' +import type { + AdminStats, + AdminUserItem, + SetPasswordPayload, + CreateAdminPayload, + UpdateAdminPayload, +} from '../types' + +/** + * دریافت فهرست صفحه‌بندی‌شده مدیران سیستم (Staff / Superusers) + */ +export async function fetchAdminUsers(params?: { + search?: string + is_active?: string + ordering?: string + limit?: number + offset?: number +}): Promise> { + const query = new URLSearchParams() + query.set('role', 'staff') + if (params?.search && params.search.trim()) { + query.set('search', params.search.trim()) + } + if (params?.is_active && params.is_active !== 'all') { + query.set('is_active', params.is_active) + } + if (params?.ordering) { + query.set('ordering', params.ordering) + } + if (params?.limit !== undefined) { + query.set('limit', String(params.limit)) + } + if (params?.offset !== undefined) { + query.set('offset', String(params.offset)) + } + + const queryString = query.toString() + const path = queryString ? `account/admin/users/?${queryString}` : 'account/admin/users/?role=staff' + return apiFetch>(path) +} + +/** + * دریافت آمارهای کلان مربوط به مدیران سیستم + */ +export async function fetchAdminStats(): Promise { + const data = await apiFetch<{ + staff_users?: number + active_staff_users?: number + inactive_staff_users?: number + superusers_count?: number + total_users?: number + }>('account/admin/users/stats/') + + const staff_users = data.staff_users ?? 0 + const active_staff_users = data.active_staff_users ?? staff_users + const inactive_staff_users = data.inactive_staff_users ?? Math.max(0, staff_users - active_staff_users) + const superusers_count = data.superusers_count ?? 0 + const total_users = data.total_users ?? 0 + + return { + staff_users, + active_staff_users, + inactive_staff_users, + superusers_count, + total_users, + } +} + +/** + * تعیین یا تغییر رمز عبور برای ادمین یا کاربر + */ +export async function setAdminPassword( + userId: number, + payload: SetPasswordPayload +): Promise<{ detail: string; id: number; available_password?: string }> { + return apiFetch<{ detail: string; id: number; available_password?: string }>( + `account/admin/users/${userId}/set-password/`, + { + method: 'POST', + body: payload, + } + ) +} + +/** + * ایجاد یک مدیر جدید در سیستم همراه با رمز عبور اولیه + */ +export async function createAdminUser(payload: CreateAdminPayload): Promise { + return apiFetch('account/admin/users/create/', { + method: 'POST', + body: payload, + }) +} + +/** + * ویرایش اطلاعات یا تغییر وضعیت فعال/غیرفعال ادمین + */ +export async function updateAdminUser( + userId: number, + payload: UpdateAdminPayload +): Promise { + return apiFetch(`account/admin/users/${userId}/`, { + method: 'PATCH', + body: payload, + }) +} diff --git a/src/features/settings/types.ts b/src/features/settings/types.ts new file mode 100644 index 0000000..ab773e7 --- /dev/null +++ b/src/features/settings/types.ts @@ -0,0 +1,36 @@ +import type { UserListItem } from '@/features/users/types' + +export interface AdminStats { + staff_users: number + active_staff_users: number + inactive_staff_users: number + superusers_count: number + total_users: number +} + +export interface AdminUserItem extends UserListItem { + available_password?: string | null +} + +export interface SetPasswordPayload { + password: string +} + +export interface CreateAdminPayload { + fullname: string + email?: string + phone_number?: string + password: string + is_staff: boolean + is_superuser: boolean + is_active: boolean +} + +export interface UpdateAdminPayload { + fullname?: string + email?: string + phone_number?: string + is_active?: boolean + is_staff?: boolean + is_superuser?: boolean +} diff --git a/src/features/tours/OngoingToursPage.tsx b/src/features/tours/OngoingToursPage.tsx index 5fea074..7e36f05 100644 --- a/src/features/tours/OngoingToursPage.tsx +++ b/src/features/tours/OngoingToursPage.tsx @@ -390,7 +390,7 @@ export default function OngoingToursPage() {
-

نزدیک به شروع (۲۴-۴۸ ساعت)

+

نزدیک به شروع (24-48 ساعت)

{isLoading ? ( ) : ( @@ -458,7 +458,7 @@ export default function OngoingToursPage() { : 'text-grey-3' }`} > - نزدیک به شروع ۲۴-۴۸ ساعت ({toFa(stats.startingSoon)}) + نزدیک به شروع 24-48 ساعت ({toFa(stats.startingSoon)})
@@ -502,7 +502,7 @@ export default function OngoingToursPage() {

توری در حال برگزاری یا نزدیک به شروع یافت نشد

- در بازه زمانی جاری (هم‌اکنون یا ۲۴ تا ۴۸ ساعت آینده)، هیچ توری مطابق با فیلترهای انتخابی شما در حال + در بازه زمانی جاری (هم‌اکنون یا 24 تا 48 ساعت آینده)، هیچ توری مطابق با فیلترهای انتخابی شما در حال برگزاری نیست.

@@ -389,7 +389,7 @@ export function CreateTourDialog({ : 'text-grey-3 hover:text-foreground' }`} > - ۱. مشخصات و قیمت + 1. مشخصات و قیمت
@@ -568,7 +568,7 @@ export function CreateTourDialog({ برای انتخاب تصویر شاخص کلیک کنید یا فایل را اینجا رها کنید (Drag & Drop)

- فرمت‌های پشتیبانی‌شده: JPG، PNG، WEBP — حداکثر حجم: ۱۵ مگابایت + فرمت‌های پشتیبانی‌شده: JPG، PNG، WEBP — حداکثر حجم: 15 مگابایت

{imageUploadError && (

@@ -633,7 +633,7 @@ export function CreateTourDialog({ min="1" value={capacity} onChange={(e) => setCapacity(e.target.value)} - placeholder="۴۰" + placeholder="40" className="bg-surface-2 border-border/50 text-sm font-mono" required /> @@ -692,7 +692,7 @@ export function CreateTourDialog({

setNewFeature(e.target.value)} - placeholder="مثال: اقامت در هتل ۵ ستاره نزدیک حرم..." + placeholder="مثال: اقامت در هتل 5 ستاره نزدیک حرم..." className="bg-surface-2 text-xs" onKeyDown={(e) => { if (e.key === 'Enter') { @@ -1051,9 +1051,9 @@ export function CreateTourDialog({
- {activeTab === 'basic' && 'مرحله ۱ از ۳: اطلاعات پایه و قیمت'} - {activeTab === 'features_tips' && 'مرحله ۲ از ۳: ویژگی‌ها و نکات'} - {activeTab === 'translations' && 'مرحله ۳ از ۳: ترجمه‌های چندزبانه'} + {activeTab === 'basic' && 'مرحله 1 از 3: اطلاعات پایه و قیمت'} + {activeTab === 'features_tips' && 'مرحله 2 از 3: ویژگی‌ها و نکات'} + {activeTab === 'translations' && 'مرحله 3 از 3: ترجمه‌های چندزبانه'}
diff --git a/src/features/tours/components/TourDetailDialog.tsx b/src/features/tours/components/TourDetailDialog.tsx index 9c4b0bf..9a6e315 100644 --- a/src/features/tours/components/TourDetailDialog.tsx +++ b/src/features/tours/components/TourDetailDialog.tsx @@ -853,7 +853,7 @@ export function TourDetailDialog({ setInitialTranslations(JSON.parse(JSON.stringify(allTranslations))) setInitialItinDrafts(JSON.parse(JSON.stringify(allItinDrafts))) - setSuccessMessage('تمامی تغییرات مشخصات تور، تصاویر، ترجمه‌های ۵ زبانه و برنامه سفر با موفقیت ذخیره شدند.') + setSuccessMessage('تمامی تغییرات مشخصات تور، تصاویر، ترجمه‌های 5 زبانه و برنامه سفر با موفقیت ذخیره شدند.') if (onUpdated) onUpdated() } catch (err: any) { setError(err?.message || 'خطا در ذخیره یکپارچه اطلاعات تور') @@ -1330,7 +1330,7 @@ export function TourDetailDialog({
- جدول ۳ ردیف نرخ‌های مصوب و تخفیف تور + جدول 3 ردیف نرخ‌های مصوب و تخفیف تور
{discNum > 0 && (
- +
)} - خدمات کامل (۷ سال به بالا) + خدمات کامل (7 سال به بالا)
- +
$
- صندلی و خدمات کودک (۲ تا ۷ سال کامل) + صندلی و خدمات کودک (2 تا 7 سال کامل)
- +
$
- بیمه و ترانسفر نوزاد (۰ تا ۲ سال کامل) + بیمه و ترانسفر نوزاد (0 تا 2 سال کامل)
@@ -1458,7 +1458,7 @@ export function TourDetailDialog({
{hasNoImages && ( - حداقل ۱ تصویر الزامی است + حداقل 1 تصویر الزامی است )}
@@ -1620,7 +1620,7 @@ export function TourDetailDialog({
- مدیریت ترجمه‌ها (۵ زبان) + مدیریت ترجمه‌ها (5 زبان)
@@ -277,7 +277,7 @@ export function TourDetailSheet({ tourId, isOpen, onClose }: TourDetailSheetProp

جدول نرخ‌های مصوب

- بزرگسال (۷+ سال / Adult 7+) + بزرگسال (7+ سال / Adult 7+)
${parseFloat(tour.price).toLocaleString()}
@@ -291,23 +291,23 @@ export function TourDetailSheet({ tourId, isOpen, onClose }: TourDetailSheetProp
)} - خدمات کامل بزرگسال (۷ سال به بالا) + خدمات کامل بزرگسال (7 سال به بالا)
- کودک (۲ تا ۷ سال / Child 2-7) + کودک (2 تا 7 سال / Child 2-7)
${parseFloat(tour.price_child || '0').toLocaleString()}
- صندلی و خدمات کودک (۲ تا ۷ سال کامل) + صندلی و خدمات کودک (2 تا 7 سال کامل)
- نوزاد (۰ تا ۲ سال / Infant 0-2) + نوزاد (0 تا 2 سال / Infant 0-2)
${parseFloat(tour.price_infant || '0').toLocaleString()}
- بیمه و ترانسفر نوزاد (۰ تا ۲ سال کامل) + بیمه و ترانسفر نوزاد (0 تا 2 سال کامل)
diff --git a/src/features/tours/components/TourTranslationsDialog.tsx b/src/features/tours/components/TourTranslationsDialog.tsx index aae18fd..7eda6c3 100644 --- a/src/features/tours/components/TourTranslationsDialog.tsx +++ b/src/features/tours/components/TourTranslationsDialog.tsx @@ -270,7 +270,7 @@ export function TourTranslationsDialog({
- مدیریت ترجمه‌های ۵ زبانه تور + مدیریت ترجمه‌های 5 زبانه تور {tourTitle || `تور شناسه #${tourId}`} • ذخیره‌سازی پویا در سرور و پایگاه‌داده diff --git a/src/features/users/UserDetailPage.tsx b/src/features/users/UserDetailPage.tsx new file mode 100644 index 0000000..b9284ea --- /dev/null +++ b/src/features/users/UserDetailPage.tsx @@ -0,0 +1,239 @@ +import * as React from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import { useTranslation } from '@/i18n' +import type { UserDetail } from './types' +import { fetchUserDetail } from './services/users-api' +import { UserProfileTab } from './components/UserProfileTab' +import { UserToursTab } from './components/UserToursTab' +import { UserFactorsTab } from './components/UserFactorsTab' +import { Ic } from '@/icons' + +type TabType = 'profile' | 'tours' | 'invoices' + +export default function UserDetailPage() { + const { id } = useParams<{ id: string }>() + const navigate = useNavigate() + const { t } = useTranslation() + + const [user, setUser] = React.useState(null) + const [loading, setLoading] = React.useState(true) + const [activeTab, setActiveTab] = React.useState('profile') + + const userId = Number(id) + + React.useEffect(() => { + if (!userId) return + + setLoading(true) + fetchUserDetail(userId) + .then((data) => setUser(data)) + .catch((err) => { + console.error('Failed to fetch user dossier:', err) + setUser(null) + }) + .finally(() => setLoading(false)) + }, [userId]) + + if (loading) { + return ( +
+
+
+
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+
+
+ ) + } + + if (!user) { + return ( +
+
+ +
+

+ {t('users.noUsersFound')} +

+ +
+ ) + } + + return ( +
+ {/* دکمه بازگشت و عنوان پرونده */} +
+ +
+ + {/* بنر اطلاعات هویت کاربر */} +
+
+ {user.avatar ? ( + {user.fullname + ) : ( +
+ {user.fullname ? user.fullname.slice(0, 2).toUpperCase() : 'U'} +
+ )} + +
+
+

+ {user.fullname || t('users.noData')} +

+ + #{user.id} + + + + {user.is_active ? t('users.active') : t('users.inactive')} + +
+ +
+ {user.phone_number && ( +
+ + {user.phone_number.split(':deleted')[0].trim()} +
+ )} + {user.email && ( +
+ + {user.email.split(':deleted')[0].trim()} + {user.email.includes(':deleted') && ( + + {t('users.deletedAccount')} + + )} +
+ )} +
+
+
+ + {/* کارت‌های آماری سریع */} +
+
+ + {t('users.totalFactorsCount')} + + + {user.stats?.total_factors ?? 0} + +
+ +
+ + {t('users.totalToursCount')} + + + {user.stats?.total_tours ?? 0} + +
+ +
+ + {t('users.totalApprovedSpent')} + + + ${(user.stats?.approved_spent ?? 0).toLocaleString()} + +
+
+
+ + {/* سیستم تب‌بندی جامع پرونده کاربر (User Dossier Tabs) */} +
+ +
+ + {/* محتوای تب فعال */} +
+ {activeTab === 'profile' && } + {activeTab === 'tours' && } + {activeTab === 'invoices' && } +
+
+ ) +} diff --git a/src/features/users/UsersPage.tsx b/src/features/users/UsersPage.tsx new file mode 100644 index 0000000..d008268 --- /dev/null +++ b/src/features/users/UsersPage.tsx @@ -0,0 +1,822 @@ +import * as React from 'react' +import { useNavigate } from 'react-router-dom' +import { useTranslation } from '@/i18n' +import type { UserListItem, UserGlobalStats } from './types' +import { fetchUsers, fetchUserStats } from './services/users-api' +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 { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { Ic, IconName } from '@/icons' +import { ShimmerBox } from '@/components/shared/shimmer_frontend' +import { formatDateByLanguage } from '@/lib/date-utils' + +const PAGE_SIZE = 50 + +interface UserCategoryTab { + id: string + labelKey: string + icon: IconName + isCore?: boolean + role?: string +} + +const CATEGORY_TABS: UserCategoryTab[] = [ + { id: 'all', labelKey: 'users.filterAll', icon: 'users' }, + { id: 'has_tours', labelKey: 'users.filterTourBuyers', icon: 'map', isCore: true, role: 'has_tours' }, + { id: 'has_factors', labelKey: 'users.filterFactorBuyers', icon: 'receipt', role: 'has_factors' }, + { id: 'staff', labelKey: 'users.filterStaff', icon: 'shield', role: 'staff' }, +] + +function getPaginationItems(current: number, total: number): (number | 'ellipsis')[] { + if (total <= 7) { + return Array.from({ length: total }, (_, i) => i + 1) + } + + const items: (number | 'ellipsis')[] = [] + items.push(1) + + if (current > 3) { + items.push('ellipsis') + } + + const start = Math.max(2, current - 1) + const end = Math.min(total - 1, current + 1) + + for (let i = start; i <= end; i++) { + items.push(i) + } + + if (current < total - 2) { + items.push('ellipsis') + } + + items.push(total) + return items +} + +export default function UsersPage() { + const { t, language } = useTranslation() + const navigate = useNavigate() + + const [users, setUsers] = React.useState([]) + const [totalCount, setTotalCount] = React.useState(0) + const [loading, setLoading] = React.useState(true) + const [stats, setStats] = React.useState(null) + + // Filters state + const [search, setSearch] = React.useState('') + const [debouncedSearch, setDebouncedSearch] = React.useState('') + const [selectedCategory, setSelectedCategory] = React.useState('all') + const [selectedStatus, setSelectedStatus] = React.useState('all') + const [offset, setOffset] = React.useState(0) + + // Search debounce + React.useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearch(search) + setOffset(0) + }, 350) + return () => clearTimeout(timer) + }, [search]) + + // Load KPI stats once + const loadStats = React.useCallback(async () => { + try { + const data = await fetchUserStats() + setStats(data) + } catch (err) { + console.error('Failed to load user stats:', err) + } + }, []) + + React.useEffect(() => { + loadStats() + }, [loadStats]) + + // Compute active API filter parameters + const activeParams = React.useMemo(() => { + let is_active: string | undefined = undefined + if (selectedStatus === 'active') is_active = 'true' + else if (selectedStatus === 'inactive') is_active = 'false' + + const activeTab = CATEGORY_TABS.find((t) => t.id === selectedCategory) + const role = activeTab?.role + + return { is_active, role } + }, [selectedStatus, selectedCategory]) + + const loadUsers = React.useCallback(async () => { + setLoading(true) + try { + const res = await fetchUsers({ + search: debouncedSearch, + is_active: activeParams.is_active, + role: activeParams.role, + limit: PAGE_SIZE, + offset, + }) + setUsers(res.results || []) + setTotalCount(res.count || 0) + } catch (err) { + console.error('Failed to load users:', err) + setUsers([]) + setTotalCount(0) + } finally { + setLoading(false) + } + }, [debouncedSearch, activeParams, offset]) + + React.useEffect(() => { + loadUsers() + }, [loadUsers]) + + const handleRefresh = () => { + loadUsers() + loadStats() + } + + const handleSelectCategory = (categoryId: string) => { + setSelectedCategory(categoryId) + setOffset(0) + } + + const handleSelectStatus = (statusId: string) => { + setSelectedStatus(statusId) + setOffset(0) + } + + const handleResetFilters = () => { + setSearch('') + setSelectedCategory('all') + setSelectedStatus('all') + setOffset(0) + } + + const currentPage = Math.floor(offset / PAGE_SIZE) + 1 + const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)) + const paginationItems = React.useMemo( + () => getPaginationItems(currentPage, totalPages), + [currentPage, totalPages] + ) + + const handleNextPage = () => { + if (offset + PAGE_SIZE < totalCount) { + setOffset((prev) => prev + PAGE_SIZE) + } + } + + const handlePrevPage = () => { + if (offset >= PAGE_SIZE) { + setOffset((prev) => prev - PAGE_SIZE) + } + } + + // Formatting helpers + const cleanPhoneNumber = (phone: string | null) => { + if (!phone) return '—' + const cleaned = phone.split(':deleted')[0].trim() + return cleaned || '—' + } + + const cleanEmail = (email: string | null) => { + if (!email) return { text: '—', isDeleted: false } + const isDeleted = email.includes(':deleted') + const cleaned = email.split(':deleted')[0].trim() + return { text: cleaned || '—', isDeleted } + } + + return ( +
+ {/* ۱. سربرگ اصلی صفحه کاربران (مطابق با ساختار سربرگ در BillingPage) */} +
+
+
+
+ +
+

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

+ + {loading && totalCount === 0 ? ( + + ) : ( + `${totalCount} ${t('users.colUser')}` + )} + +
+

+ {t('users.subtitle')} +

+
+ +
+ +
+
+ + {/* ۲. نوار تب‌های تفکیک دسته‌بندی کاربران (مطابق Service Category Tabs در BillingPage) */} +
+ {CATEGORY_TABS.map((tab) => { + const isActive = selectedCategory === tab.id + return ( + + ) + })} +
+ + {/* ۳. کارت‌های KPI شاخص‌های کلیدی آماری کاربران (۵ کارت مشابه پالت رنگی BillingPage) */} +
+ {/* ۱. کل کاربران (خنثی) */} + + +
+

{t('users.kpiTotalUsers')}

+ {loading && !stats ? ( + + ) : ( +

+ {stats?.total_users ?? totalCount} +

+ )} +
+
+ +
+
+
+ + {/* ۲. خریداران تور (آبی) */} + + +
+

+ {t('users.kpiTourUsers')} +

+ {loading && !stats ? ( + + ) : ( +

+ {stats?.users_with_tours ?? 0} +

+ )} +
+
+ +
+
+
+ + {/* ۳. دارای فاکتور و خرید (کهربایی) */} + + +
+

+ {t('users.kpiFactorUsers')} +

+ {loading && !stats ? ( + + ) : ( +

+ {stats?.users_with_factors ?? 0} +

+ )} +
+
+ +
+
+
+ + {/* ۴. کاربران فعال (سبز) */} + + +
+

+ {t('users.kpiActiveUsers')} +

+ {loading && !stats ? ( + + ) : ( +

+ {stats?.active_users ?? 0} +

+ )} +
+
+ +
+
+
+ + {/* ۵. مدیران سیستم (بنفش/رز) */} + + +
+

+ {t('users.kpiStaffUsers')} +

+ {loading && !stats ? ( + + ) : ( +

+ {stats?.staff_users ?? 0} +

+ )} +
+
+ +
+
+
+
+ + {/* ۴. نوار جستجو و فیلترهای وضعیت لحظه‌ای (مطابق با Card جستجو در BillingPage) */} + + +
+ {/* جستجوی متنی زنده */} +
+ + setSearch(e.target.value)} + className="ps-9 pe-9 h-9 text-xs bg-surface-2 border-border-soft rounded-xl text-foreground placeholder:text-grey-3" + /> + {search && ( + + )} +
+ + {/* چیپ‌های فیلتر سریع وضعیت */} +
+ {[ + { id: 'all', label: t('users.allStatuses') }, + { id: 'active', label: t('users.filterActive') }, + { id: 'inactive', label: t('users.filterInactive') }, + ].map((st) => { + const isSelected = selectedStatus === st.id + return ( + + ) + })} +
+ + {/* دکمه پاک کردن فیلترها */} + {(search || selectedStatus !== 'all' || selectedCategory !== 'all') && ( + + )} +
+
+
+ + {/* ۵. جدول جامع داده‌های کاربران (دقیقاً مطابق استایل Card و جدول در BillingPage) */} + + + + + {t('users.userList')} + {selectedCategory !== 'all' && ( + + {t(CATEGORY_TABS.find((tab) => tab.id === selectedCategory)?.labelKey as any) || selectedCategory} + + )} + + + {t('common.pageOf', { current: currentPage, total: totalPages })} + + + + + {/* خط انیمیشن لودینگ در بالای جدول هنگام بارگذاری مجدد */} + {loading && ( +
+
+
+ )} + + {/* حالت عدم وجود داده */} + {!loading && users.length === 0 ? ( +
+
+ +
+

{t('users.noUsersFound')}

+

+ {search || selectedStatus !== 'all' || selectedCategory !== 'all' + ? t('billing.noFactorsFilter') + : t('billing.noFactorsEmpty')} +

+ {(search || selectedStatus !== 'all' || selectedCategory !== 'all') && ( + + )} +
+ ) : ( +
+ + + + {t('users.colId')} + {t('users.colUser')} + {t('users.colPhone')} + {t('users.colEmail')} + {t('users.colDateJoined')} + {t('users.colLastLogin')} + {t('billing.liveStatus')} + {t('common.actions')} + + + + {loading && users.length === 0 ? ( + Array.from({ length: 6 }).map((_, i) => ( + +
+ +
+
+
+
+
+
+
+ +
+
+
+
+
+
+ + )) + ) : ( + users.map((user) => { + const emailData = cleanEmail(user.email) + const phoneText = cleanPhoneNumber(user.phone_number) + + return ( + navigate(`/users/${user.id}`)} + className="hover:bg-muted/30 transition-colors cursor-pointer group" + > + {/* شناسه */} + + #{user.id} + + + {/* اطلاعات کاربر */} + +
+ {user.avatar ? ( + {user.fullname + ) : ( +
+ {user.fullname && user.fullname.trim() + ? user.fullname.slice(0, 2).toUpperCase() + : 'U'} +
+ )} + +
+
+ {user.fullname && user.fullname.trim() + ? user.fullname + : t('users.unnamedUser')} +
+ +
+ {user.is_staff && ( + + {t('users.admin')} + + )} + + + + {user.tour_orders_count}{' '} + {t('users.toursCount')} + + + + + + {user.factors_count}{' '} + {t('users.invoicesCount')} + + +
+
+
+
+ + {/* شماره موبایل */} + + + {phoneText} + + + + {/* ایمیل */} + +
+ + {emailData.text} + + {emailData.isDeleted && ( + + {t('users.deletedAccount')} + + )} +
+
+ + {/* تاریخ عضویت */} + + {formatDateByLanguage(user.date_joined, language as any)} + + + {/* آخرین ورود */} + + {formatDateByLanguage(user.last_login, language as any)} + + + {/* وضعیت زنده (کپسولی مطابق BillingStatusBadge) */} + + {user.is_active ? ( + + + {t('users.active')} + + ) : ( + + + {t('users.inactive')} + + )} + + + {/* دکمه عملیات (با آیکون چشم مشابه BillingPage) */} + e.stopPropagation()}> + + +
+ ) + }) + )} + +
+
+ )} + + {/* نمای کارت‌های موبایل (زیر md) */} +
+ {loading && users.length === 0 ? ( + Array.from({ length: 4 }).map((_, i) => ( +
+ )) + ) : ( + users.map((user) => { + const emailData = cleanEmail(user.email) + const phoneText = cleanPhoneNumber(user.phone_number) + + return ( +
navigate(`/users/${user.id}`)} + className="bg-card border border-border-soft rounded-2xl p-4 shadow-xs space-y-3 cursor-pointer hover:border-primary/40 transition-colors" + > +
+
+
+ {user.fullname && user.fullname.trim() ? user.fullname.slice(0, 2).toUpperCase() : 'U'} +
+
+
+ {user.fullname && user.fullname.trim() ? user.fullname : t('users.unnamedUser')} +
+ #{user.id} +
+
+ +
+ {user.is_active ? ( + + + {t('users.active')} + + ) : ( + + + {t('users.inactive')} + + )} +
+
+ +
+
+ {t('users.colPhone')}: + {phoneText} +
+
+ {t('users.colEmail')}: + {emailData.text} +
+
+ {t('users.colDateJoined')}: + {formatDateByLanguage(user.date_joined, language as any)} +
+
+ +
+
+ + {user.tour_orders_count}{' '} + {t('users.toursCount')} + + • + + {user.factors_count}{' '} + {t('users.invoicesCount')} + +
+ + +
+
+ ) + }) + )} +
+ + {/* ۶. صفحه‌بندی هوشمند شماره‌دار با قابلیت کلیک (پاورقی کارت جدول مطابق با BillingPage) */} +
+ + {t('common.showing')} {(currentPage - 1) * PAGE_SIZE + 1} {t('common.to')}{' '} + {Math.min(currentPage * PAGE_SIZE, totalCount)} {t('common.of')} {totalCount} {t('users.colUser')} + + +
+ {/* دکمه قبلی */} + + + {/* شماره صفحات هوشمند و قابل کلیک */} + {paginationItems.map((item, idx) => { + if (item === 'ellipsis') { + return ( + + … + + ) + } + + const pageNum = item + const isActive = pageNum === currentPage + + return ( + + ) + })} + + {/* دکمه بعدی */} + +
+
+ + +
+ ) +} diff --git a/src/features/users/components/UserFactorsTab.tsx b/src/features/users/components/UserFactorsTab.tsx new file mode 100644 index 0000000..c23abf3 --- /dev/null +++ b/src/features/users/components/UserFactorsTab.tsx @@ -0,0 +1,297 @@ +import * as React from 'react' +import { useTranslation } from '@/i18n' +import type { UserFactorItem } from '../types' +import { fetchUserFactors } from '../services/users-api' +import { Ic } from '@/icons' + +interface UserFactorsTabProps { + userId: number +} + +type ServiceFilter = 'all' | 'simcard' | 'tasrif' | 'shop' | 'tour' | 'custom_trip' + +export function UserFactorsTab({ userId }: UserFactorsTabProps) { + const { t } = useTranslation() + const [service, setService] = React.useState('all') + const [loading, setLoading] = React.useState(true) + const [factors, setFactors] = React.useState([]) + const [receiptModalUrl, setReceiptModalUrl] = React.useState(null) + + const loadFactors = React.useCallback(async () => { + setLoading(true) + try { + const res = await fetchUserFactors(userId, service === 'all' ? undefined : service) + setFactors(res.results || []) + } catch (err) { + console.error('Failed to load user factors:', err) + setFactors([]) + } finally { + setLoading(false) + } + }, [userId, service]) + + React.useEffect(() => { + loadFactors() + }, [loadFactors]) + + const formatDate = (isoString: string) => { + try { + const d = new Date(isoString) + return d.toLocaleDateString('fa-IR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) + } catch { + return isoString?.slice(0, 10) || '—' + } + } + + const getServiceLabel = (srv: string) => { + switch (srv) { + case 'simcard': + return t('users.invoicesSimcard') + case 'tasrif': + return t('users.invoicesExchange') + case 'shop': + return t('users.invoicesShop') + case 'tour': + return t('users.invoicesTour') + case 'custom_trip': + return t('users.invoicesOther') + default: + return srv + } + } + + const getStatusBadge = (status: string) => { + switch (status) { + case 'approved': + return ( + + + {t('billing.statusApproved')} + + ) + case 'pending': + return ( + + + {t('billing.statusPending')} + + ) + case 'awaiting_payment': + return ( + + + {t('billing.statusAwaiting')} + + ) + case 'rejected': + return ( + + + {t('billing.statusRejected')} + + ) + case 'expired': + return ( + + + {t('billing.statusExpired')} + + ) + default: + return ( + + {status} + + ) + } + } + + return ( +
+ {/* ساب‌تب‌های کپسولی برای دسته‌بندی فاکتورها بر اساس نوع سرویس */} +
+ + + + + + +
+ + {/* لیست فاکتورها */} + {loading ? ( +
+ {[1, 2, 3, 4].map((i) => ( +
+ ))} +
+ ) : factors.length === 0 ? ( +
+
+ +
+

+ {t('users.noInvoicesFound')} +

+

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

+
+ ) : ( +
+
+ + + + + + + + + + + + + {factors.map((f) => ( + + + + + + + + + ))} + +
{t('users.invoiceId')}{t('users.serviceType')}{t('users.amount')}{t('users.createdDate')}{t('users.status')}{t('users.receipt')}
+ #{f.id} + + + {getServiceLabel(f.service)} + + + ${Number(f.amount || 0).toLocaleString()} + + {formatDate(f.created_at)} + {getStatusBadge(f.status)} + {f.transaction_receipt ? ( + + ) : ( + — + )} +
+
+
+ )} + + {/* مدال مشاهده فیش پرداخت */} + {receiptModalUrl && ( +
setReceiptModalUrl(null)} + > +
e.stopPropagation()} + > +
+

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

+ +
+
+ Receipt +
+
+
+ )} +
+ ) +} diff --git a/src/features/users/components/UserProfileTab.tsx b/src/features/users/components/UserProfileTab.tsx new file mode 100644 index 0000000..c82cbab --- /dev/null +++ b/src/features/users/components/UserProfileTab.tsx @@ -0,0 +1,216 @@ +import * as React from 'react' +import { useTranslation } from '@/i18n' +import type { UserDetail } from '../types' +import { Ic } from '@/icons' + +interface UserProfileTabProps { + user: UserDetail +} + +export function UserProfileTab({ user }: UserProfileTabProps) { + const { t } = useTranslation() + + const formatDate = (isoString: string | null) => { + if (!isoString) return t('users.noData') + try { + const d = new Date(isoString) + return d.toLocaleDateString('fa-IR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) + } catch { + return isoString + } + } + + return ( +
+ {/* ۱. کارت اطلاعات پایه حساب کاربری */} +
+
+ + + +
+

+ {t('users.profileInfo')} +

+

+ {t('users.subtitle')} +

+
+
+ +
+ {/* نام و نام خانوادگی */} +
+ + {t('users.fullname')} + + + {user.fullname || t('users.noData')} + +
+ + {/* شماره تلفن همراه */} +
+ + {t('users.phone')} + + + {user.phone_number ? user.phone_number.split(':deleted')[0].trim() : t('users.noData')} + +
+ + {/* ایمیل حساب */} +
+
+ + {t('users.email')} + + {user.email && user.email.includes(':deleted') && ( + + {t('users.deletedAccount')} + + )} +
+ + {user.email ? user.email.split(':deleted')[0].trim() : t('users.noData')} + +
+ + {/* سطح دسترسی */} +
+ + {t('users.role')} + + + + {user.is_staff || user.is_superuser ? t('users.admin') : t('users.regularUser')} + +
+ + {/* وضعیت اکانت */} +
+ + {t('users.colStatus')} + + + + {user.is_active ? t('users.active') : t('users.inactive')} + +
+ + {/* زبان انتخابی کاربر */} +
+ + {t('users.language')} + + + {user.language || 'fa'} + +
+ + {/* تاریخ ثبت‌نام */} +
+ + {t('users.colDateJoined')} + + + {formatDate(user.date_joined)} + +
+ + {/* آخرین ورود به سیستم */} +
+ + {t('users.colLastLogin')} + + + {formatDate(user.last_login)} + +
+ + {/* شناسه دستگاه */} +
+ + {t('users.deviceId')} + + + {user.device_id || t('users.noData')} + +
+
+
+ + {/* ۲. بخش مسافران و همراهان ثبت‌شده */} +
+
+
+ + + +
+

+ {t('users.passengersTitle')} +

+

+ {user.passengers.length} {t('users.passengersTitle')} +

+
+
+
+ + {user.passengers.length === 0 ? ( +
+ {t('users.noPassengers')} +
+ ) : ( +
+ + + + + + + + + + + + + {user.passengers.map((p, idx) => ( + + + + + + + + + ))} + +
#{t('users.fullname')}{t('users.passportNumber')}{t('users.birthdate')}{t('users.phone')}{t('users.ageCategory')}
{idx + 1}{p.fullname || t('users.noData')}{p.passport_number || '—'}{p.birthdate || '—'}{p.phone_number || '—'} + + {p.age_category} + +
+
+ )} +
+
+ ) +} diff --git a/src/features/users/components/UserToursTab.tsx b/src/features/users/components/UserToursTab.tsx new file mode 100644 index 0000000..83b77eb --- /dev/null +++ b/src/features/users/components/UserToursTab.tsx @@ -0,0 +1,236 @@ +import * as React from 'react' +import { useTranslation } from '@/i18n' +import type { UserTourItem } from '../types' +import { fetchUserTours } from '../services/users-api' +import { Ic } from '@/icons' + +interface UserToursTabProps { + userId: number +} + +type TimelineFilter = 'all' | 'active' | 'upcoming' | 'past' + +export function UserToursTab({ userId }: UserToursTabProps) { + const { t } = useTranslation() + const [timeline, setTimeline] = React.useState('all') + const [loading, setLoading] = React.useState(true) + const [tours, setTours] = React.useState([]) + + const loadTours = React.useCallback(async () => { + setLoading(true) + try { + const res = await fetchUserTours(userId, timeline) + setTours(res.results || []) + } catch (err) { + console.error('Failed to load user tours:', err) + setTours([]) + } finally { + setLoading(false) + } + }, [userId, timeline]) + + React.useEffect(() => { + loadTours() + }, [loadTours]) + + const formatDate = (isoDate: string | null | undefined) => { + if (!isoDate) return '—' + return isoDate + } + + const getOrderStatusBadge = (status: string) => { + switch (status) { + case 'APPROVED': + return ( + + + {t('billing.statusApproved')} + + ) + case 'PENDING': + return ( + + + {t('billing.statusPending')} + + ) + case 'AWAITING_PAYMENT': + return ( + + + {t('billing.statusAwaiting')} + + ) + case 'REJECTED': + return ( + + + {t('billing.statusRejected')} + + ) + case 'EXPIRED': + return ( + + + {t('billing.statusExpired')} + + ) + default: + return ( + + {status} + + ) + } + } + + const getTripStatusBadge = (tripStatus: string | null | undefined) => { + if (!tripStatus) return null + let colorClass = 'bg-muted text-muted-foreground' + if (tripStatus === 'TRAVELING') { + colorClass = 'bg-primary/10 text-primary font-semibold' + } else if (tripStatus === 'FINISHED' || tripStatus === 'COMPLETED') { + colorClass = 'bg-muted/60 text-muted-foreground' + } else if (tripStatus === 'PENDING') { + colorClass = 'bg-amber-500/10 text-amber-600 dark:text-amber-400' + } + + return ( + + {tripStatus} + + ) + } + + return ( +
+ {/* ساب‌تب‌های کپسولی برای فیلتر وضعیت‌های زمانی تور */} +
+ + + + +
+ + {/* محتوای تورها */} + {loading ? ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ) : tours.length === 0 ? ( +
+
+ +
+

+ {t('users.noToursFound')} +

+

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

+
+ ) : ( +
+ {tours.map((order) => ( +
+ {/* سمت راست: عنوان و جزئیات */} +
+
+

+ {order.tour_title || `سفارش #${order.id}`} +

+ {getOrderStatusBadge(order.status)} + {getTripStatusBadge(order.tour_trip_status)} +
+ +
+
+ + + {formatDate(order.tour_started_at)} الی {formatDate(order.tour_ended_at)} + +
+
+ ثبت: + {formatDate(order.created?.slice(0, 10))} +
+
+
+ + {/* سمت چپ: قیمت و وضعیت مالی */} +
+
+ + {t('users.tourPrice')} + + + ${Number(order.total_price || 0).toLocaleString()} + +
+
+ + {order.is_paid ? 'پرداخت شده' : 'پرداخت نشده'} + +
+
+
+ ))} +
+ )} +
+ ) +} diff --git a/src/features/users/services/users-api.ts b/src/features/users/services/users-api.ts new file mode 100644 index 0000000..c22af00 --- /dev/null +++ b/src/features/users/services/users-api.ts @@ -0,0 +1,93 @@ +import { apiFetch } from '@/services/http' +import type { + UserListItem, + UserDetail, + UserTourItem, + UserFactorItem, + DrfPaginatedResponse, + UsersFilterParams, +} from '../types' + +/** + * دریافت فهرست صفحه‌بندی‌شده کاربران ویژه ادمین + */ +export async function fetchUsers( + params?: UsersFilterParams +): Promise> { + const query = new URLSearchParams() + + if (params?.search && params.search.trim()) { + query.set('search', params.search.trim()) + } + if (params?.is_active && params.is_active !== 'all') { + query.set('is_active', params.is_active) + } + if (params?.role && params.role !== 'all') { + query.set('role', params.role) + } + if (params?.ordering) { + query.set('ordering', params.ordering) + } + if (params?.limit !== undefined) { + query.set('limit', String(params.limit)) + } + if (params?.offset !== undefined) { + query.set('offset', String(params.offset)) + } + + const queryString = query.toString() + const path = queryString ? `account/admin/users/?${queryString}` : 'account/admin/users/' + return apiFetch>(path) +} + +/** + * دریافت آمارهای کلان کاربران برای کارت‌های شاخص عملکرد (KPI) + */ +export async function fetchUserStats(): Promise { + return apiFetch('account/admin/users/stats/') +} + +/** + * دریافت اطلاعات تفصیلی پرونده کاربر شامل همراهان و آمار + */ +export async function fetchUserDetail(userId: number): Promise { + return apiFetch(`account/admin/users/${userId}/`) +} + +/** + * دریافت سفارشات و تورهای کاربر با پشتیبانی از فیلتر وضعیت زمانی (فعال، آینده، گذشته) + */ +export async function fetchUserTours( + userId: number, + timeline?: 'all' | 'active' | 'upcoming' | 'past' +): Promise> { + const query = new URLSearchParams() + query.set('view', 'admin') + query.set('user_id', String(userId)) + + if (timeline && timeline !== 'all') { + query.set('timeline', timeline) + } + + const path = `tours/orders/?${query.toString()}` + return apiFetch>(path) +} + +/** + * دریافت فاکتورهای مالی کاربر با تفکیک بر اساس نوع سرویس + */ +export async function fetchUserFactors( + userId: number, + service?: string +): Promise> { + const query = new URLSearchParams() + query.set('view', 'admin') + query.set('user_id', String(userId)) + + if (service && service !== 'all') { + query.set('service', service) + } + + const path = `factors/list/?${query.toString()}` + return apiFetch>(path) +} diff --git a/src/features/users/types.ts b/src/features/users/types.ts new file mode 100644 index 0000000..6baf2f4 --- /dev/null +++ b/src/features/users/types.ts @@ -0,0 +1,120 @@ +export interface UserListItem { + id: number + fullname: string + email: string | null + phone_number: string | null + avatar: string | null + date_joined: string + last_login: string | null + is_active: boolean + is_staff: boolean + is_superuser: boolean + factors_count: number + tour_orders_count: number +} + +export interface PassengerItem { + id: number + fullname: string | null + passport_number: string + birthdate: string + phone_number: string + age_category: string + created_at: string +} + +export interface UserStats { + total_factors: number + total_tours: number + approved_spent: number +} + +export interface UserGlobalStats { + total_users: number + active_users: number + staff_users: number + users_with_tours: number + users_with_factors: number +} + +export interface UserDetail { + id: number + fullname: string + email: string | null + phone_number: string | null + avatar: string | null + date_joined: string + last_login: string | null + is_active: boolean + is_staff: boolean + is_superuser: boolean + language: string | null + device_id: string | null + passengers: PassengerItem[] + stats: UserStats +} + +export interface UserTourItem { + id: number + tour_id?: number + user: string + tour_slug: string + tour_title: string + tour_started_at: string | null + tour_ended_at: string | null + tour_trip_status: string | null + tour_status: string | null + price?: string + total_price: string + status: string + created: string + updated?: string + request_details?: string + is_paid: boolean + payment_method?: string + transaction_id?: string + payment_receipt?: string | null + receipt_verified?: boolean +} + +export type FactorStatus = 'awaiting_payment' | 'pending' | 'rejected' | 'approved' | 'expired' +export type FactorService = 'shop' | 'simcard' | 'tour' | 'tasrif' | 'custom_trip' + +export interface UserFactorItem { + id: number + user: { + id: number + fullname: string + email: string | null + phone_number: string | null + avatar: string | null + } + status: FactorStatus + service: FactorService + description: string | null + amount: string + card_number: string | null + rejected_description: string | null + transaction_receipt: string | null + object_id: number + order_id: number | null + expiration_date: string | null + created_at: string + updated_at: string +} + +export interface DrfPaginatedResponse { + count: number + next: string | null + previous: string | null + results: T[] +} + +export interface UsersFilterParams { + search?: string + is_active?: string + role?: string + ordering?: string + limit?: number + offset?: number +} diff --git a/src/icons.tsx b/src/icons.tsx index c60a4d5..c76958f 100644 --- a/src/icons.tsx +++ b/src/icons.tsx @@ -99,6 +99,9 @@ const PATHS: Record = { save: '', bookmark: '', mapPin: '', + key: '', + lock: '', + copy: '', } export type IconName = keyof typeof PATHS diff --git a/src/index.css b/src/index.css index b08c959..7b659bf 100644 --- a/src/index.css +++ b/src/index.css @@ -103,8 +103,8 @@ html.light { } @theme inline { - --font-sans: 'Vazirmatn', 'Inter', system-ui, sans-serif; - --font-mono: 'JetBrains Mono', 'Inter', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + --font-sans: 'Inter', 'Vazirmatn', system-ui, -apple-system, sans-serif; + --font-mono: 'Inter', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; --color-background: var(--background); --color-foreground: var(--foreground); --color-card: var(--card); @@ -151,15 +151,22 @@ html.light { } html { direction: rtl; - font-family: 'Vazirmatn', 'Inter', system-ui, -apple-system, sans-serif; + font-family: 'Inter', 'Vazirmatn', system-ui, -apple-system, sans-serif; + font-feature-settings: 'tnum' 1, 'cv05' 1; } body { @apply bg-background text-foreground antialiased; - font-family: 'Vazirmatn', 'Inter', system-ui, -apple-system, sans-serif; + font-family: 'Inter', 'Vazirmatn', system-ui, -apple-system, sans-serif; font-size: 14px; + font-feature-settings: 'tnum' 1, 'cv05' 1; } input, button, select, optgroup, textarea, option { - font-family: 'Vazirmatn', 'Inter', system-ui, -apple-system, sans-serif !important; + font-family: 'Inter', 'Vazirmatn', system-ui, -apple-system, sans-serif !important; + } + .font-mono, .tabular-nums, table td, table th, [class*="tabular-nums"] { + font-family: 'Inter', ui-monospace, SFMono-Regular, monospace; + font-variant-numeric: tabular-nums; + font-feature-settings: 'tnum' 1, 'cv05' 1; } ::-webkit-scrollbar { width: 8px; diff --git a/src/lib/date-utils.ts b/src/lib/date-utils.ts index e7a43d8..921347f 100644 --- a/src/lib/date-utils.ts +++ b/src/lib/date-utils.ts @@ -61,13 +61,13 @@ export function formatDateByLanguage( try { if (lang === 'fa') { if (style === 'numeric') { - return new Intl.DateTimeFormat('fa-IR-u-ca-persian', { + return new Intl.DateTimeFormat('fa-IR-u-ca-persian-nu-latn', { year: 'numeric', month: '2-digit', day: '2-digit', }).format(d) } - return new Intl.DateTimeFormat('fa-IR-u-ca-persian', { + return new Intl.DateTimeFormat('fa-IR-u-ca-persian-nu-latn', { year: 'numeric', month: style === 'short' ? 'short' : 'long', day: 'numeric', @@ -76,13 +76,13 @@ export function formatDateByLanguage( if (lang === 'ar') { if (style === 'numeric') { - return new Intl.DateTimeFormat('ar-SA-u-ca-islamic-umalqura', { + return new Intl.DateTimeFormat('ar-SA-u-ca-islamic-umalqura-nu-latn', { year: 'numeric', month: '2-digit', day: '2-digit', }).format(d) } - return new Intl.DateTimeFormat('ar-SA-u-ca-islamic-umalqura', { + return new Intl.DateTimeFormat('ar-SA-u-ca-islamic-umalqura-nu-latn', { year: 'numeric', month: style === 'short' ? 'short' : 'long', day: 'numeric', diff --git a/src/lib/utils.ts b/src/lib/utils.ts index b576808..2205aa4 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -5,8 +5,26 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } -const FA_DIGITS = '۰۱۲۳۴۵۶۷۸۹' -export const toFa = (n: number | string) => String(n).replace(/\d/g, (d) => FA_DIGITS[+d]) +const AR_FA_TO_EN_DIGITS: Record = { + '۰': '0', '۱': '1', '۲': '2', '۳': '3', '۴': '4', + '۵': '5', '۶': '6', '۷': '7', '۸': '8', '۹': '9', + '٠': '0', '١': '1', '٢': '2', '٣': '3', '٤': '4', + '٥': '5', '٦': '6', '٧': '7', '٨': '8', '٩': '9', +} + +/** + * تبدیل تمامی ارقام فارسی و عربی به ارقام استاندارد انگلیسی + */ +export const toEn = (n: number | string | null | undefined): string => { + if (n == null) return '' + return String(n).replace(/[۰-۹٠-٩]/g, (d) => AR_FA_TO_EN_DIGITS[d] ?? d) +} + +/** + * طبق استاندارد پنل عقیله، کلیه اعداد در سراسر پنل به صورت انگلیسی نمایش داده می‌شوند. + * این تابع به عنوان آداپتور به toEn متصل است تا هیچ کدی نشکند و ارقام انگلیسی تولید شوند. + */ +export const toFa = toEn /** * استخراج URL تصویر از thumbnailِ خامِ بک‌اند. diff --git a/src/locales/ar.json b/src/locales/ar.json index 9c0806a..3921491 100644 --- a/src/locales/ar.json +++ b/src/locales/ar.json @@ -4,6 +4,7 @@ "brandShort": "عقيلة", "dashboard": "الرئيسية", "dashboardTitle": "لوحة التحكم", + "users": "المستخدمون", "tours": "الرحلات", "allTours": "جميع الرحلات", "ongoingTours": "الرحلات الجارية", @@ -159,7 +160,7 @@ "filterOrderStatus": "حالة الطلب", "createTour": "إنشاء رحلة جديدة", "tourDetails": "تفاصيل الرحلة", - "manageTranslations": "إدارة الترجمات (٥ لغات)", + "manageTranslations": "إدارة الترجمات (5 لغات)", "tabOverview": "المواصفات", "tabItinerary": "برنامج الرحلة", "tabPassengers": "المسافرون", @@ -269,6 +270,175 @@ "liveStatus": "الحالة المباشرة", "quickFilter": "تصفية الحالة" }, + "users": { + "title": "إدارة المستخدمين", + "subtitle": "عرض والبحث ومتابعة حسابات المستخدمين وجولاتهم والفواتير المالية", + "searchPlaceholder": "البحث عن مستخدم بالاسم، اسم العائلة، الهاتف، البريد أو المعرّف...", + "totalUsers": "إجمالي المستخدمين", + "activeUsers": "المستخدمون النشطون", + "allStatuses": "جميع الحالات", + "active": "نشط", + "inactive": "غير نشط", + "colId": "المعرف", + "colUser": "المستخدم", + "colPhone": "رقم الهاتف", + "colEmail": "البريد الإلكتروني", + "colDateJoined": "تاريخ التسجيل", + "colLastLogin": "آخر تسجيل دخول", + "colStatus": "الحالة", + "colActions": "الإجراءات", + "viewDetails": "عرض الملف", + "noUsersFound": "لم يتم العثور على مستخدمين بهذه المواصفات.", + "noData": "لا توجد بيانات", + "userDetailTitle": "ملف المستخدم", + "backToUsers": "العودة إلى قائمة المستخدمين", + "tabProfile": "بيانات المستخدم", + "tabTours": "جولات المستخدم", + "tabInvoices": "فواتير المستخدم", + "profileInfo": "بيانات الهوية والحساب", + "passengersTitle": "المسافرون والمرافقون المسجلون", + "noPassengers": "لم يتم تسجيل أي مسافرين لهذا المستخدم.", + "fullname": "الاسم الكامل", + "phone": "رقم الهاتف", + "email": "البريد الإلكتروني", + "role": "مستوى الصلاحية", + "admin": "مدیر النظام", + "regularUser": "مستخدم عادي", + "language": "لغة النظام", + "deviceId": "معرّف الجهاز", + "passportNumber": "رقم جواز السفر", + "birthdate": "تاريخ الميلاد", + "ageCategory": "الفئة العمرية", + "toursAll": "جميع الجولات", + "toursActive": "النشطة حالياً", + "toursUpcoming": "القادمة", + "toursPast": "المشتراة سابقاً", + "noToursFound": "لم يتم العثور على جولات في هذه الفئة.", + "tourTitle": "عنوان الجولة", + "tourDates": "تاريخ الرحلة", + "tourPrice": "المبلغ المدفوع", + "tourStatus": "حالة الطلب", + "tourTripStatus": "حالة الرحلة", + "invoicesAll": "جميع الفواتير", + "invoicesSimcard": "شرائح الاتصال", + "invoicesExchange": "الصرافة", + "invoicesShop": "المتجر", + "invoicesTour": "الجولات", + "invoicesOther": "عمليات ومشتريات أخرى", + "noInvoicesFound": "لا توجد فواتير في هذه الفئة لهذا المستخدم.", + "invoiceId": "رقم الفاتورة", + "serviceType": "نوع الخدمة", + "amount": "المبلغ", + "createdDate": "تاريخ الإصدار", + "status": "الحالة", + "receipt": "إيصال الدفع", + "hasReceipt": "يوجد إيصال", + "noReceipt": "بدون إيصال", + "quickStats": "إحصائيات سريعة للمستخدم", + "totalFactorsCount": "إجمالي الفواتير", + "totalToursCount": "الجولات المحجوزة", + "totalApprovedSpent": "إجمالي المدفوعات المعتمدة", + "kpiTotalUsers": "إجمالي المسجلين", + "kpiActiveUsers": "المستخدمون النشطون", + "kpiTourUsers": "مشتري الرحلات", + "kpiFactorUsers": "ذوو الفواتير والمشتريات", + "kpiStaffUsers": "طاقم الإدارة", + "userList": "قائمة المستخدمين", + "filterAll": "جميع المستخدمين", + "filterActive": "النشطون", + "filterInactive": "غير النشطين", + "filterStaff": "طاقم الإدارة", + "filterTourBuyers": "حاجزو الرحلات", + "filterFactorBuyers": "لديهم فواتير", + "deletedAccount": "حساب محذوف", + "unnamedUser": "مستخدم بدون اسم", + "invoicesCount": "فاتورة", + "toursCount": "رحلة", + "page": "صفحة", + "of": "من", + "previous": "السابق", + "next": "التالي" + }, + "settings": { + "pageTitle": "إعدادات النظام", + "pageDescription": "إدارة المشرفين وتعيين وتغيير كلمات المرور وتهيئة وحدات النظام", + "tabAdmins": "إدارة المشرفين والصلاحيات", + "tabNotifications": "نظام الإشعارات", + "kpiTotalAdmins": "إجمالي المشرفين", + "kpiActiveAdmins": "المشرفون النشطون", + "kpiSuperusers": "المشرفون المتميزون (Superuser)", + "kpiInactiveAdmins": "المشرفون غير النشطين", + "searchAdminsPlaceholder": "البحث عن الاسم، البريد، رقم الهاتف...", + "refreshBtn": "تحديث", + "refreshTooltip": "تحديث قائمة المشرفين", + "addAdminBtn": "إضافة مشرف جديد", + "adminsCountLabel": "مشرف", + "colAdmin": "المشرف", + "colContact": "معلومات الاتصال", + "colRole": "الدور", + "colStatus": "الحالة", + "colPassword": "كلمة المرور", + "colDateJoined": "تاريخ الانضمام", + "colActions": "الإجراءات", + "noAdminsFoundTitle": "لم يتم العثور على أي مشرف", + "noAdminsFoundDesc": "لا يوجد مشرفون يطابقون معايير البحث أو الفلترة الحالية.", + "roleStaff": "مشرف النظام", + "toggleStatusTooltip": "انقر لتبديل الحالة بين نشط وغير نشط", + "passwordCustomOrEncrypted": "كلمة مرور مشفرة", + "copyPasswordTooltip": "نسخ كلمة المرور", + "setPasswordAction": "تعيين / تغيير كلمة المرور", + "setPasswordTooltip": "تعيين أو تغيير كلمة المرور لهذا المشرف", + "viewUserProfileTooltip": "عرض الملف الشخصي للمستخدم", + "adminActivatedNotice": "تم تفعيل حساب المشرف '{{name}}'.", + "adminDeactivatedNotice": "تم إلغاء تفعيل حساب المشرف '{{name}}'.", + "statusUpdatedTitle": "تحديث الحالة", + "errorStatusToggleFailed": "فشل في تحديث حالة حساب المشرف.", + "passwordCopiedSuccess": "تم نسخ كلمة المرور إلى الحافظة بنجاح.", + "setPasswordModalTitle": "تعيين وتغيير كلمة مرور المشرف", + "setPasswordModalDesc": "قم بتعيين كلمة مرور جديدة أو إعادة تعيين كلمة المرور الحالية لهذا المشرف.", + "currentAvailablePassword": "كلمة المرور الحالية المسجلة", + "copied": "تم النسخ", + "copy": "نسخ", + "newPasswordLabel": "كلمة المرور الجديدة", + "generatePasswordBtn": "إنشاء كلمة مرور قوية", + "passwordGeneratedNotice": "تم إنشاء كلمة مرور عشوائية قوية وتعبئتها.", + "generatedPasswordTitle": "تم إنشاء كلمة المرور", + "passwordMinLengthHint": "الحد الأدنى لطول كلمة المرور هو 6 أحرف.", + "confirmPasswordLabel": "تأكيد كلمة المرور الجديدة", + "errorPasswordRequired": "يرجى إدخال كلمة المرور.", + "errorPasswordMinLength": "يجب أن تتكون كلمة المرور من 6 أحرف على الأقل.", + "errorPasswordMismatch": "كلمتا المرور غير متطابقتين.", + "passwordUpdateSuccess": "تم تحديث كلمة مرور المشرف '{{name}}' بنجاح.", + "successTitle": "نجاح العملية", + "errorPasswordUpdateFailed": "فشل في تحديث كلمة المرور.", + "cancelBtn": "إلغاء", + "savePasswordBtn": "حفظ وتطبيق كلمة المرور", + "savingPassword": "جاري حفظ كلمة المرور...", + "createAdminModalTitle": "إضافة مشرف جديد إلى النظام", + "createAdminModalDesc": "حدد تفاصيل المشرف الجديد ومستوى صلاحياته لإنشاء الحساب.", + "adminFullnameLabel": "الاسم الكامل", + "adminFullnamePlaceholder": "مثال: علي محمد", + "adminEmailLabel": "البريد الإلكتروني", + "adminPhoneLabel": "رقم الهاتف المحمول", + "adminEmailOrPhoneHint": "يجب تقديم البريد الإلكتروني أو رقم الهاتف المحمول على الأقل.", + "initialPasswordLabel": "كلمة المرور الأولية", + "roleSuperuserTitle": "صلاحيات المشرف المتميز (Superuser)", + "roleSuperuserDesc": "وصول كامل إلى جميع الأقسام والإعدادات وقدرات النظام", + "accountActiveTitle": "الحساب نشط", + "accountActiveDesc": "سيتمكن المشرف من تسجيل الدخول مباشرة بعد الإنشاء.", + "errorNameRequired": "يرجى إدخال الاسم الكامل للمشرف.", + "errorEmailOrPhoneRequired": "البريد الإلكتروني أو رقم الهاتف مطلوب على الأقل.", + "adminCreatedSuccess": "تمت إضافة المشرف '{{name}}' بنجاح.", + "errorAdminCreateFailed": "فشل في إنشاء المشرف الجديد.", + "createAdminBtn": "إنشاء المشرف", + "creatingAdmin": "جاري إنشاء الحساب...", + "notificationsTabTitle": "إدارة الإشعارات وإرسال التنبيهات (Push Notifications)", + "notificationsTabDesc": "هذا القسم مخصص لإدارة قوالب الرسائل، وإرسال الإشعارات الجماعية، واستهداف الجمهور، والربط مع خدمة FCM، وسيتم تفعيله قريباً.", + "notificationsUpcomingFeaturesTitle": "الميزات قيد التنفيذ لهذا القسم:", + "notificationsFeature1": "إرسال إشعارات مخصصة لمستخدمي التطبيق والويب مصنفة حسب اللغة", + "notificationsFeature2": "جدولة تلقائية للتذكير بالرحلات والجولات والمدفوعات المعلقة", + "notificationsFeature3": "تقارير تحليلية لمعدلات المشاهدة والنقر والتسليم الناجح" + }, "languages": { "fa": "فارسی", "ar": "العربية", diff --git a/src/locales/en.json b/src/locales/en.json index de24f86..738ddeb 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -4,6 +4,7 @@ "brandShort": "Aqila", "dashboard": "Dashboard", "dashboardTitle": "Admin Dashboard", + "users": "Users", "tours": "Tours", "allTours": "All Tours", "ongoingTours": "Ongoing Tours", @@ -269,6 +270,175 @@ "liveStatus": "Live Status", "quickFilter": "Status Filter" }, + "users": { + "title": "User Management", + "subtitle": "View, search, and monitor user accounts, tours, and financial invoices", + "searchPlaceholder": "Search users by first/last name, phone, email, or ID...", + "totalUsers": "Total Users", + "activeUsers": "Active Users", + "allStatuses": "All Statuses", + "active": "Active", + "inactive": "Inactive", + "colId": "ID", + "colUser": "User", + "colPhone": "Phone Number", + "colEmail": "Email", + "colDateJoined": "Registration Date", + "colLastLogin": "Last Login", + "colStatus": "Status", + "colActions": "Actions", + "viewDetails": "View Profile", + "noUsersFound": "No users found matching your criteria.", + "noData": "No data available", + "userDetailTitle": "User Profile Dossier", + "backToUsers": "Back to Users List", + "tabProfile": "User Profile", + "tabTours": "User Tours", + "tabInvoices": "User Invoices", + "profileInfo": "Identity & Account Details", + "passengersTitle": "Registered Passengers & Companions", + "noPassengers": "No passengers registered for this user.", + "fullname": "Full Name", + "phone": "Phone Number", + "email": "Email Address", + "role": "Access Level", + "admin": "System Admin", + "regularUser": "Regular User", + "language": "Platform Language", + "deviceId": "Device ID", + "passportNumber": "Passport Number", + "birthdate": "Birth Date", + "ageCategory": "Age Category", + "toursAll": "All Tours", + "toursActive": "Currently Active", + "toursUpcoming": "Upcoming", + "toursPast": "Past & Completed", + "noToursFound": "No tours found in this category.", + "tourTitle": "Tour Title", + "tourDates": "Tour Dates", + "tourPrice": "Paid Amount", + "tourStatus": "Order Status", + "tourTripStatus": "Trip Status", + "invoicesAll": "All Invoices", + "invoicesSimcard": "SIM Card", + "invoicesExchange": "Exchange", + "invoicesShop": "Massoumah Store", + "invoicesTour": "Tour", + "invoicesOther": "Other Operations", + "noInvoicesFound": "No invoices found in this category for this user.", + "invoiceId": "Invoice #", + "serviceType": "Service Type", + "amount": "Amount", + "createdDate": "Issue Date", + "status": "Status", + "receipt": "Payment Receipt", + "hasReceipt": "Has Receipt", + "noReceipt": "No Receipt", + "quickStats": "User Overview Stats", + "totalFactorsCount": "Total Invoices", + "totalToursCount": "Booked Tours", + "totalApprovedSpent": "Total Approved Spent", + "kpiTotalUsers": "Total Registered", + "kpiActiveUsers": "Active Users", + "kpiTourUsers": "Tour Buyers", + "kpiFactorUsers": "Invoice Customers", + "kpiStaffUsers": "System Staff", + "userList": "Users List", + "filterAll": "All Users", + "filterActive": "Active", + "filterInactive": "Inactive", + "filterStaff": "Staff", + "filterTourBuyers": "Tour Pilgrims", + "filterFactorBuyers": "Has Invoices", + "deletedAccount": "Deleted Account", + "unnamedUser": "Unnamed User", + "invoicesCount": "Invoices", + "toursCount": "Tours", + "page": "Page", + "of": "of", + "previous": "Previous", + "next": "Next" + }, + "settings": { + "pageTitle": "System Settings", + "pageDescription": "Management of administrators, setting and updating passwords, and system configuration", + "tabAdmins": "Admin & Access Management", + "tabNotifications": "Notification System", + "kpiTotalAdmins": "Total Administrators", + "kpiActiveAdmins": "Active Administrators", + "kpiSuperusers": "Superusers", + "kpiInactiveAdmins": "Inactive Administrators", + "searchAdminsPlaceholder": "Search name, email, phone number...", + "refreshBtn": "Refresh", + "refreshTooltip": "Refresh administrators list", + "addAdminBtn": "Add New Admin", + "adminsCountLabel": "Admins", + "colAdmin": "Admin", + "colContact": "Contact Info", + "colRole": "Role", + "colStatus": "Status", + "colPassword": "Password", + "colDateJoined": "Date Joined", + "colActions": "Actions", + "noAdminsFoundTitle": "No administrators found", + "noAdminsFoundDesc": "No administrators match the current search or filters.", + "roleStaff": "Staff Admin", + "toggleStatusTooltip": "Click to toggle active/inactive status", + "passwordCustomOrEncrypted": "Encrypted password", + "copyPasswordTooltip": "Copy password", + "setPasswordAction": "Set / Change Password", + "setPasswordTooltip": "Set or change password for this administrator", + "viewUserProfileTooltip": "View user profile", + "adminActivatedNotice": "Admin account '{{name}}' has been activated.", + "adminDeactivatedNotice": "Admin account '{{name}}' has been deactivated.", + "statusUpdatedTitle": "Status Updated", + "errorStatusToggleFailed": "Failed to update administrator status.", + "passwordCopiedSuccess": "Password copied to clipboard successfully.", + "setPasswordModalTitle": "Set or Change Admin Password", + "setPasswordModalDesc": "Assign a new password or reset the current password for this administrator.", + "currentAvailablePassword": "Current recorded password", + "copied": "Copied", + "copy": "Copy", + "newPasswordLabel": "New Password", + "generatePasswordBtn": "Generate Strong Password", + "passwordGeneratedNotice": "A strong random password has been generated and populated.", + "generatedPasswordTitle": "Password Generated", + "passwordMinLengthHint": "Minimum password length is 6 characters.", + "confirmPasswordLabel": "Confirm New Password", + "errorPasswordRequired": "Please enter a password.", + "errorPasswordMinLength": "Password must be at least 6 characters.", + "errorPasswordMismatch": "Passwords do not match.", + "passwordUpdateSuccess": "Password for administrator '{{name}}' was updated successfully.", + "successTitle": "Success", + "errorPasswordUpdateFailed": "Failed to update password.", + "cancelBtn": "Cancel", + "savePasswordBtn": "Save & Apply Password", + "savingPassword": "Saving password...", + "createAdminModalTitle": "Add New System Administrator", + "createAdminModalDesc": "Specify new administrator details and access level to create account.", + "adminFullnameLabel": "Full Name", + "adminFullnamePlaceholder": "e.g. John Doe", + "adminEmailLabel": "Email Address", + "adminPhoneLabel": "Mobile Phone", + "adminEmailOrPhoneHint": "At least one of email or mobile phone must be provided.", + "initialPasswordLabel": "Initial Password", + "roleSuperuserTitle": "Superuser Privileges", + "roleSuperuserDesc": "Full access to all sections, settings, and system capabilities", + "accountActiveTitle": "Account is active", + "accountActiveDesc": "Administrator can log in immediately upon creation.", + "errorNameRequired": "Please enter the administrator's full name.", + "errorEmailOrPhoneRequired": "Either email or phone number is required.", + "adminCreatedSuccess": "Administrator '{{name}}' was created successfully.", + "errorAdminCreateFailed": "Failed to create administrator.", + "createAdminBtn": "Create Administrator", + "creatingAdmin": "Creating account...", + "notificationsTabTitle": "Notification Management & Push Notifications", + "notificationsTabDesc": "This section is dedicated to notification templates, broadcast messages, audience targeting, and FCM integration, and will be active soon.", + "notificationsUpcomingFeaturesTitle": "Features under development for this section:", + "notificationsFeature1": "Send targeted notifications to app and web users segmented by language", + "notificationsFeature2": "Automated scheduling for trip reminders, tours, and pending payments", + "notificationsFeature3": "Analytics reports on impressions, open rates, and successful delivery" + }, "languages": { "fa": "فارسی", "ar": "العربية", diff --git a/src/locales/fa.json b/src/locales/fa.json index 43e5b89..2063500 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -4,6 +4,7 @@ "brandShort": "عقیله", "dashboard": "داشبورد", "dashboardTitle": "داشبورد مدیریت", + "users": "کاربران", "tours": "تورها", "allTours": "فهرست همه تورها", "ongoingTours": "تورهای در حال برگزاری", @@ -139,7 +140,7 @@ "disabled": "غیرفعال", "unknown": "نامشخص", "days": "{count} روز", - "dayOne": "۱ روز" + "dayOne": "1 روز" }, "tours": { "title": "مدیریت تورها", @@ -159,7 +160,7 @@ "filterOrderStatus": "وضعیت سفارش", "createTour": "ایجاد تور جدید", "tourDetails": "جزئیات تور", - "manageTranslations": "مدیریت ترجمه‌ها (۵ زبان)", + "manageTranslations": "مدیریت ترجمه‌ها (5 زبان)", "tabOverview": "مشخصات", "tabItinerary": "برنامه سفر", "tabPassengers": "مسافران", @@ -210,7 +211,7 @@ }, "billing": { "title": "مدیریت صورت‌حساب‌ها و فاکتورها", - "description": "بررسی فیش‌های بانکی، تایید یا رد پرداخت‌ها و پیگیری ریز فاکتورهای ۵ سرویس سامانه عقیله", + "description": "بررسی فیش‌های بانکی، تایید یا رد پرداخت‌ها و پیگیری ریز فاکتورهای 5 سرویس سامانه عقیله", "totalInvoices": "کل صورت‌حساب‌ها", "pendingReview": "در انتظار بررسی فیش", "awaitingPayment": "در انتظار پرداخت مشتری", @@ -269,6 +270,175 @@ "liveStatus": "وضعیت زنده", "quickFilter": "فیلتر وضعیت" }, + "users": { + "title": "مدیریت کاربران", + "subtitle": "مشاهده، جستجو و بررسی وضعیت حساب، تورها و تراکنش‌های مالی کاربران", + "searchPlaceholder": "جستجوی کاربر بر اساس نام، نام خانوادگی، شماره تماس، ایمیل یا شناسه...", + "totalUsers": "کل کاربران", + "activeUsers": "کاربران فعال", + "allStatuses": "همه وضعیت‌ها", + "active": "فعال", + "inactive": "غیرفعال", + "colId": "شناسه", + "colUser": "کاربر", + "colPhone": "شماره موبایل", + "colEmail": "ایمیل", + "colDateJoined": "تاریخ عضویت", + "colLastLogin": "آخرین ورود", + "colStatus": "وضعیت", + "colActions": "عملیات", + "viewDetails": "مشاهده پرونده", + "noUsersFound": "کاربری با این مشخصات یافت نشد.", + "noData": "داده‌ای ثبت نشده", + "userDetailTitle": "پرونده کاربر", + "backToUsers": "بازگشت به فهرست کاربران", + "tabProfile": "مشخصات کاربر", + "tabTours": "تورهای کاربر", + "tabInvoices": "فاکتورهای کاربر", + "profileInfo": "مشخصات هویتی و حساب", + "passengersTitle": "مسافران و همراهان ثبت‌شده", + "noPassengers": "هیچ مسافری برای این کاربر ثبت نشده است.", + "fullname": "نام و نام خانوادگی", + "phone": "تلفن همراه", + "email": "پست الکترونیک", + "role": "سطح دسترسی", + "admin": "مدیر سیستم", + "regularUser": "کاربر عادی", + "language": "زبان سامانه", + "deviceId": "شناسه دستگاه", + "passportNumber": "شماره گذرنامه", + "birthdate": "تاریخ تولد", + "ageCategory": "رده سنی", + "toursAll": "همه تورها", + "toursActive": "در حال حاضر فعال", + "toursUpcoming": "آینده", + "toursPast": "خریداری‌شده قبلی", + "noToursFound": "هیچ توری در این دسته‌بندی یافت نشد.", + "tourTitle": "عنوان تور", + "tourDates": "تاریخ برگزاری", + "tourPrice": "مبلغ پرداختی", + "tourStatus": "وضعیت سفارش", + "tourTripStatus": "وضعیت سفر", + "invoicesAll": "همه فاکتورها", + "invoicesSimcard": "سیم‌کارت", + "invoicesExchange": "صرافی", + "invoicesShop": "فروشگاه", + "invoicesTour": "تور", + "invoicesOther": "سایر عملیات و خریدها", + "noInvoicesFound": "هیچ فاکتوری در این دسته‌بندی برای کاربر ثبت نشده است.", + "invoiceId": "شماره فاکتور", + "serviceType": "نوع سرویس", + "amount": "مبلغ", + "createdDate": "تاریخ صدور", + "status": "وضعیت", + "receipt": "فیش پرداخت", + "hasReceipt": "دارای فیش", + "noReceipt": "بدون فیش", + "quickStats": "آمار اجمالی کاربر", + "totalFactorsCount": "تعداد کل فاکتورها", + "totalToursCount": "تعداد تورهای رزروشده", + "totalApprovedSpent": "مجموع پرداختی‌های تاییدشده", + "kpiTotalUsers": "کل کاربران ثبت‌شده", + "kpiActiveUsers": "کاربران فعال", + "kpiTourUsers": "خریداران تور", + "kpiFactorUsers": "دارای فاکتور و خرید", + "kpiStaffUsers": "مدیران سیستم", + "userList": "فهرست کاربران", + "filterAll": "همه کاربران", + "filterActive": "فعال", + "filterInactive": "غیرفعال", + "filterStaff": "مدیران سیستم", + "filterTourBuyers": "خریداران تور", + "filterFactorBuyers": "دارای فاکتور", + "deletedAccount": "حساب حذف‌شده", + "unnamedUser": "کاربر بدون نام", + "invoicesCount": "فاکتور", + "toursCount": "تور", + "page": "صفحه", + "of": "از", + "previous": "قبلی", + "next": "بعدی" + }, + "settings": { + "pageTitle": "تنظیمات سامانه", + "pageDescription": "مدیریت مدیران، تعیین و تغییر رمزهای عبور و پیکربندی ماژول‌های سامانه", + "tabAdmins": "مدیریت مدیران و دسترسی‌ها", + "tabNotifications": "سامانه اعلان‌ها (Notifications)", + "kpiTotalAdmins": "کل مدیران سیستم", + "kpiActiveAdmins": "مدیران فعال", + "kpiSuperusers": "مدیران ارشد (Superuser)", + "kpiInactiveAdmins": "مدیران غیرفعال", + "searchAdminsPlaceholder": "جستجوی نام، ایمیل، شماره تماس...", + "refreshBtn": "تازه‌سازی", + "refreshTooltip": "تازه‌سازی اطلاعات مدیران", + "addAdminBtn": "افزودن مدیر جدید", + "adminsCountLabel": "مدیر", + "colAdmin": "مدیر", + "colContact": "اطلاعات تماس", + "colRole": "سطح دسترسی", + "colStatus": "وضعیت", + "colPassword": "رمز عبور", + "colDateJoined": "تاریخ عضویت", + "colActions": "عملیات", + "noAdminsFoundTitle": "هیچ مدیری یافت نشد", + "noAdminsFoundDesc": "با فیلترها یا عبارت جستجوی فعلی مدیری ثبت نشده است.", + "roleStaff": "مدیر سیستم", + "toggleStatusTooltip": "کلیک برای تغییر وضعیت فعال/غیرفعال", + "passwordCustomOrEncrypted": "رمز رمزنگاری‌شده", + "copyPasswordTooltip": "کپی رمز عبور", + "setPasswordAction": "تعیین / تغییر رمز", + "setPasswordTooltip": "تعیین یا تغییر رمز عبور برای این مدیر", + "viewUserProfileTooltip": "مشاهده پرونده کامل کاربر", + "adminActivatedNotice": "حساب مدیر «{{name}}» فعال شد.", + "adminDeactivatedNotice": "حساب مدیر «{{name}}» غیرفعال شد.", + "statusUpdatedTitle": "تغییر وضعیت", + "errorStatusToggleFailed": "خطا در تغییر وضعیت حساب مدیر.", + "passwordCopiedSuccess": "رمز عبور با موفقیت در کلیپ‌بورد کپی شد.", + "setPasswordModalTitle": "تعیین و تغییر رمز عبور مدیر", + "setPasswordModalDesc": "برای این مدیر رمز عبور جدید تعیین یا رمز موجود را بازنشانی نمایید.", + "currentAvailablePassword": "رمز فعلی ثبت‌شده", + "copied": "کپی شد", + "copy": "کپی", + "newPasswordLabel": "رمز عبور جدید", + "generatePasswordBtn": "تولید رمز قوی", + "passwordGeneratedNotice": "رمز عبور تصادفی قوی ایجاد و جایگذاری شد.", + "generatedPasswordTitle": "تولید رمز", + "passwordMinLengthHint": "حداقل طول مجاز رمز عبور 6 کاراکتر است.", + "confirmPasswordLabel": "تکرار رمز عبور جدید", + "errorPasswordRequired": "لطفاً رمز عبور را وارد نمایید.", + "errorPasswordMinLength": "طول رمز عبور باید حداقل 6 کاراکتر باشد.", + "errorPasswordMismatch": "رمز عبور و تکرار آن یکسان نیستند.", + "passwordUpdateSuccess": "رمز عبور مدیر «{{name}}» با موفقیت به‌روزرسانی شد.", + "successTitle": "عملیات موفق", + "errorPasswordUpdateFailed": "خطا در به‌روزرسانی رمز عبور.", + "cancelBtn": "انصراف", + "savePasswordBtn": "ذخیره و اعمال رمز", + "savingPassword": "در حال ذخیره‌سازی...", + "createAdminModalTitle": "افزودن مدیر جدید به سیستم", + "createAdminModalDesc": "اطلاعات مدیر جدید و سطح دسترسی او را برای ایجاد حساب مشخص کنید.", + "adminFullnameLabel": "نام و نام خانوادگی", + "adminFullnamePlaceholder": "مثال: علی رضایی", + "adminEmailLabel": "آدرس ایمیل", + "adminPhoneLabel": "شماره تلفن همراه", + "adminEmailOrPhoneHint": "حداقل یکی از موارد ایمیل یا تلفن همراه باید وارد شود.", + "initialPasswordLabel": "رمز عبور اولیه", + "roleSuperuserTitle": "دسترسی مدیر ارشد (Superuser)", + "roleSuperuserDesc": "دسترسی کامل به کلیه بخش‌ها، تنظیمات و قابلیت‌های سیستم", + "accountActiveTitle": "وضعیت حساب فعال باشد", + "accountActiveDesc": "مدیر بلافاصله پس از ایجاد قادر به ورود به پنل خواهد بود.", + "errorNameRequired": "لطفاً نام و نام خانوادگی مدیر را وارد نمایید.", + "errorEmailOrPhoneRequired": "وارد کردن حداقل یکی از موارد ایمیل یا شماره همراه الزامی است.", + "adminCreatedSuccess": "مدیر «{{name}}» با موفقیت به سیستم اضافه شد.", + "errorAdminCreateFailed": "خطا در ایجاد مدیر جدید.", + "createAdminBtn": "ایجاد مدیر جدید", + "creatingAdmin": "در حال ایجاد حساب...", + "notificationsTabTitle": "سامانه مدیریت و ارسال اعلان‌ها (Push Notifications)", + "notificationsTabDesc": "این بخش برای مدیریت قالب‌های پیام، ارسال اعلان‌های همگانی، گروه‌بندی مخاطبان و اتصال به سرویس FCM اختصاص یافته است و به زودی فعال خواهد شد.", + "notificationsUpcomingFeaturesTitle": "قابلیت‌های در دست پیاده‌سازی این بخش:", + "notificationsFeature1": "ارسال اعلان اختصاصی به کاربران اپلیکیشن و وب با تفکیک زبان", + "notificationsFeature2": "زمان‌بندی خودکار یادآورهای سفر، تور و پرداخت‌های مالی", + "notificationsFeature3": "گزارش‌های تحلیلی نرخ مشاهده، کلیک و تحویل موفق پیام‌ها" + }, "languages": { "fa": "فارسی", "ar": "العربية",