Browse Source

feat: add admin panel modules for tours, users, settings, billing, and services

master
parent
commit
1eb794c6eb
  1. 6
      src/app/router.tsx
  2. 2
      src/components/ui/dialog.tsx
  3. 1
      src/config/navigation.ts
  4. 8
      src/features/billing/components/BillingDetailModal.tsx
  5. 4
      src/features/billing/components/ReceiptLightboxDialog.tsx
  6. 690
      src/features/services/ServicesPage.tsx
  7. 104
      src/features/services/components/DuasServiceView.tsx
  8. 50
      src/features/services/components/FaqServiceView.tsx
  9. 2
      src/features/services/components/LocationDetailSheet.tsx
  10. 91
      src/features/services/components/SimcardFormDialog.tsx
  11. 65
      src/features/services/components/SimcardServiceView.tsx
  12. 84
      src/features/services/components/StoreServiceView.tsx
  13. 58
      src/features/services/components/ZayerGuideServiceView.tsx
  14. 87
      src/features/settings/SettingsPage.tsx
  15. 273
      src/features/settings/components/AdminManagementTab.tsx
  16. 103
      src/features/settings/components/AdminStatsCards.tsx
  17. 321
      src/features/settings/components/AdminUserTable.tsx
  18. 283
      src/features/settings/components/CreateAdminModal.tsx
  19. 44
      src/features/settings/components/NotificationsTab.tsx
  20. 281
      src/features/settings/components/SetPasswordModal.tsx
  21. 108
      src/features/settings/services/settings-api.ts
  22. 36
      src/features/settings/types.ts
  23. 6
      src/features/tours/OngoingToursPage.tsx
  24. 16
      src/features/tours/ToursPage.tsx
  25. 26
      src/features/tours/components/CreateTourDialog.tsx
  26. 20
      src/features/tours/components/TourDetailDialog.tsx
  27. 14
      src/features/tours/components/TourDetailSheet.tsx
  28. 2
      src/features/tours/components/TourTranslationsDialog.tsx
  29. 239
      src/features/users/UserDetailPage.tsx
  30. 822
      src/features/users/UsersPage.tsx
  31. 297
      src/features/users/components/UserFactorsTab.tsx
  32. 216
      src/features/users/components/UserProfileTab.tsx
  33. 236
      src/features/users/components/UserToursTab.tsx
  34. 93
      src/features/users/services/users-api.ts
  35. 120
      src/features/users/types.ts
  36. 3
      src/icons.tsx
  37. 17
      src/index.css
  38. 8
      src/lib/date-utils.ts
  39. 22
      src/lib/utils.ts
  40. 172
      src/locales/ar.json
  41. 170
      src/locales/en.json
  42. 176
      src/locales/fa.json

6
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() {
{/* تمام مسیرهای محافظت‌شده پشت گارد امنیتی */}
<Route element={<RequireAuth><Outlet /></RequireAuth>}>
<Route path="/" element={<PageReady><DashboardHome /></PageReady>} />
<Route path="/users" element={<PageReady><UsersPage /></PageReady>} />
<Route path="/users/:id" element={<PageReady><UserDetailPage /></PageReady>} />
<Route path="/tours" element={<PageReady><ToursPage /></PageReady>} />
<Route path="/tours/ongoing" element={<PageReady><OngoingToursPage /></PageReady>} />
<Route path="/tours/orders" element={<Navigate to="/billing?service=tour" replace />} />
@ -30,6 +35,7 @@ export default function AppRouter() {
<Route path="/services/:serviceKey" element={<PageReady><ServicesPage /></PageReady>} />
<Route path="/services/:serviceKey/:subKey" element={<PageReady><ServicesPage /></PageReady>} />
<Route path="/billing" element={<PageReady><BillingPage /></PageReady>} />
<Route path="/settings" element={<PageReady><SettingsPage /></PageReady>} />
{/* هدایت مسیرهای ناشناخته به صفحه اصلی */}
<Route path="*" element={<Navigate to="/" replace />} />

2
src/components/ui/dialog.tsx

@ -78,7 +78,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end gap-2 pt-4 border-t border-border-soft shrink-0', className)}
className={cn('flex flex-col-reverse sm:flex-row items-center justify-end gap-2 pt-4 border-t border-border-soft shrink-0', className)}
{...props}
/>
)

1
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'),

8
src/features/billing/components/BillingDetailModal.tsx

@ -307,13 +307,13 @@ export function BillingDetailModal({
<div className="space-y-1 flex-1">
<h4 className="font-bold text-xs text-slate-900 dark:text-slate-100">دلیل انقضای صورت‌حساب:</h4>
<p className="text-xs text-slate-600 dark:text-slate-300 leading-relaxed font-medium">
{activeFactor.rejected_description || 'انقضای مهلت پرداخت (سپری شدن مهلت ۷۲ ساعته)'}
{activeFactor.rejected_description || 'انقضای مهلت پرداخت (سپری شدن مهلت 72 ساعته)'}
</p>
</div>
</div>
)}
{/* اطلاع‌رسانی تمدید خودکار مهلت ۳ روزه در صورت فعال‌سازی مجدد فاکتور منقضی شده یا رد شده */}
{/* اطلاع‌رسانی تمدید خودکار مهلت 3 روزه در صورت فعال‌سازی مجدد فاکتور منقضی شده یا رد شده */}
{(activeFactor.status === 'expired' || activeFactor.status === 'rejected') && (selectedStatus === 'awaiting_payment' || selectedStatus === 'pending') && (
<div className="bg-slate-800 border border-amber-500/40 rounded-2xl p-4 flex items-start gap-3.5 shadow-md">
<div className="size-8 rounded-xl bg-amber-500/20 text-amber-400 flex items-center justify-center shrink-0 mt-0.5">
@ -325,11 +325,11 @@ export function BillingDetailModal({
تمدید مجدد مهلت پرداخت صورت‌حساب:
</h4>
<span className="text-[11px] font-bold text-amber-300 bg-amber-500/20 border border-amber-500/30 px-2 py-0.5 rounded-md">
+۳ روز مهلت جدید
+3 روز مهلت جدید
</span>
</div>
<p className="text-slate-200 leading-relaxed font-normal">
با ثبت وضعیت <strong className="font-bold text-amber-300">«{selectedStatus === 'awaiting_payment' ? 'در انتظار پرداخت' : 'در انتظار بررسی فیش'}»</strong>، این صورت‌حساب مجدداً فعال شده و یک مهلت پرداخت ۳ روزهٔ جدید (۷۲ ساعت از تاریخ امروز) برای مسافر در نظر گرفته می‌شود.
با ثبت وضعیت <strong className="font-bold text-amber-300">«{selectedStatus === 'awaiting_payment' ? 'در انتظار پرداخت' : 'در انتظار بررسی فیش'}»</strong>، این صورت‌حساب مجدداً فعال شده و یک مهلت پرداخت 3 روزهٔ جدید (72 ساعت از تاریخ امروز) برای مسافر در نظر گرفته می‌شود.
</p>
</div>
</div>

4
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 درجه"
>
<Ic name="refresh" className="size-3.5" />
<span>چرخش</span>
@ -134,7 +134,7 @@ export function ReceiptLightboxDialog({
onClick={handleReset}
title="بازنشانی اندازه"
>
<span>اندازه اولیه (۱۰۰٪)</span>
<span>اندازه اولیه (100%)</span>
</Button>
)}

690
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() {
<div className="space-y-6 pt-2 pb-14">
{/* سربرگ بخش خدمات (برای شهرهای زیارتی به صورت یکپارچه داخل کامپوننت با دکمه لود مجدد مدیریت می‌شود) */}
{serviceKey !== 'cities' && (
<div className="flex flex-col gap-4 rounded-2xl border border-border-soft bg-card/40 p-6 shadow-sm sm:flex-row sm:items-center sm:justify-between backdrop-blur-xs">
<div className="flex flex-col gap-4 rounded-2xl border border-border-soft bg-card/40 p-5 shadow-xs sm:flex-row sm:items-center sm:justify-between backdrop-blur-xs">
<div className="space-y-1">
<div className="flex items-center gap-2.5">
<div className="flex size-9 items-center justify-center rounded-xl bg-primary/10 text-primary">
@ -228,7 +228,7 @@ export default function ServicesPage() {
</h1>
<Badge
variant="outline"
className="border-primary/30 bg-primary/10 text-primary font-bold"
className="border-primary/30 bg-primary/10 text-primary font-bold font-mono text-xs"
>
{currentService.titleEn}
</Badge>
@ -237,6 +237,16 @@ export default function ServicesPage() {
{currentService.description}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge
variant="outline"
className="border-border-soft bg-card/70 text-grey-2 text-xs py-1.5 px-3 rounded-xl font-medium gap-1.5 shadow-2xs"
>
<span className="size-2 rounded-full bg-emerald-500 animate-pulse" />
<span>{currentService.badge}</span>
</Badge>
</div>
</div>
)}
@ -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 (
<div className="space-y-6">
{/* جدول نرخ‌های لحظه‌ای ارز */}
{/* ۱. کارت‌های آمار و شاخص‌های کلیدی صرافی */}
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4">
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs hover:border-primary/30 transition backdrop-blur-xs">
<div className="flex items-center justify-between">
<span className="text-xs text-grey-3 font-medium">سفارشات ثبت‌شده</span>
<Badge className="bg-primary/10 text-primary border-primary/20 text-[10px] font-bold">
امروز: {toFa(5)}
</Badge>
</div>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">
{toFa(28)}
</div>
<div className="mt-1 text-[11px] text-emerald-600 dark:text-emerald-400 font-medium">
+12% افزایش نسبت به هفته قبل
</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs hover:border-primary/30 transition backdrop-blur-xs">
<div className="flex items-center justify-between">
<span className="text-xs text-grey-3 font-medium">نرخ مبادله دینار عراق</span>
<span className="text-xs">🇮🇶</span>
</div>
<div className="mt-2 text-2xl font-black text-emerald-600 dark:text-emerald-400 font-mono tabular-nums">
{toFa('2,100')} <span className="text-xs text-grey-3 font-normal">دینار</span>
</div>
<div className="mt-1 text-[11px] text-grey-3 font-medium">
به‌ازای هر 100 هزار تومان
</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs hover:border-primary/30 transition backdrop-blur-xs">
<div className="flex items-center justify-between">
<span className="text-xs text-grey-3 font-medium">در انتظار تحویل مقصد</span>
<Badge className="bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20 text-[10px] font-bold">
نیازمند اقدام
</Badge>
</div>
<div className="mt-2 text-2xl font-black text-amber-600 dark:text-amber-400 font-mono tabular-nums">
{toFa(4)}
</div>
<div className="mt-1 text-[11px] text-grey-3">
فرودگاه نجف و هتل‌های مدینه
</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs hover:border-primary/30 transition backdrop-blur-xs">
<div className="flex items-center justify-between">
<span className="text-xs text-grey-3 font-medium">حجم تبادلات ارزی</span>
<Ic name="wallet" className="size-4 text-primary" />
</div>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">
{toFa(450)} <span className="text-xs text-grey-3 font-normal">م.تومان</span>
</div>
<div className="mt-1 text-[11px] text-grey-3">
گردش مالی ماه جاری
</div>
</div>
</div>
{/* ۲. کارت‌های لحظه‌ای نرخ برابری ارزها */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-xs font-bold text-grey-3 uppercase tracking-wider">تابلوی زنده نرخ برابری ارزهای زیارتی</h3>
<span className="text-[11px] text-emerald-600 dark:text-emerald-400 font-medium">همگام با بازار نجف و صرافی مرکزی</span>
</div>
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4">
{rates.map((r) => (
<div key={r.pair} className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<div key={r.pair} className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs hover:border-primary/30 transition">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="text-base">{r.flag}</span>
<span className="text-xs font-bold text-foreground font-mono">{r.pair}</span>
<span className="text-[11px] text-emerald-600 dark:text-emerald-400 font-bold font-mono">{r.change}</span>
</div>
<span className={`text-[11px] font-bold font-mono ${
r.change.startsWith('+') ? 'text-emerald-600 dark:text-emerald-400' : r.change.startsWith('-') ? 'text-rose-500' : 'text-grey-3'
}`}>
{r.change}
</span>
</div>
<div className="mt-2 text-xl font-black text-foreground font-mono tabular-nums">{r.rate}</div>
<div className="mt-1 text-[11px] text-grey-3">{r.base}</div>
<div className="mt-1 text-[11px] text-grey-3 truncate">{r.base}</div>
</div>
))}
</div>
</div>
{/* سفارشات اخیر ارزی */}
<div className="space-y-3">
<h3 className="text-sm font-black text-foreground">سفارشات اخیر تبدیل و تحویل ارز</h3>
{/* ۳. نوار جستجو و فیلترهای سفارشات */}
<div className="flex flex-col gap-3 rounded-2xl border border-border-soft bg-card/40 p-4 sm:flex-row sm:items-center sm:justify-between shadow-xs">
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={() => setStatusFilter('ALL')}
className={`px-3 py-1.5 rounded-xl text-xs font-bold transition ${
statusFilter === 'ALL'
? 'bg-primary/10 text-primary border border-primary/20 shadow-xs'
: 'text-grey-3 hover:text-foreground'
}`}
>
همه سفارشات ({toFa(recentOrders.length)})
</button>
<button
onClick={() => setStatusFilter('CONFIRMED')}
className={`px-3 py-1.5 rounded-xl text-xs font-bold transition ${
statusFilter === 'CONFIRMED'
? 'bg-primary/10 text-primary border border-primary/20 shadow-xs'
: 'text-grey-3 hover:text-foreground'
}`}
>
آماده تحویل
</button>
<button
onClick={() => setStatusFilter('DONE')}
className={`px-3 py-1.5 rounded-xl text-xs font-bold transition ${
statusFilter === 'DONE'
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20 shadow-xs'
: 'text-grey-3 hover:text-foreground'
}`}
>
انجام شده
</button>
<button
onClick={() => setStatusFilter('PENDING')}
className={`px-3 py-1.5 rounded-xl text-xs font-bold transition ${
statusFilter === 'PENDING'
? 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20 shadow-xs'
: 'text-grey-3 hover:text-foreground'
}`}
>
در حال بررسی
</button>
</div>
<div className="relative min-w-[220px]">
<Ic name="search" className="absolute start-3 top-1/2 -translate-y-1/2 size-3.5 text-grey-3" />
<Input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="جستجوی زائر، کد سفارش یا محل تحویل..."
className="h-9 ps-9 text-xs bg-surface-base rounded-xl border-border-soft"
/>
{searchTerm && (
<button
onClick={() => setSearchTerm('')}
className="absolute end-2.5 top-1/2 -translate-y-1/2 text-grey-3 hover:text-foreground p-0.5"
>
<Ic name="close" className="size-3" />
</button>
)}
</div>
</div>
{/* ۴. جدول سفارشات ارزی با طراحی استاندارد پنل */}
<div className="overflow-hidden rounded-2xl border border-border-soft bg-card/40 shadow-xs">
<div className="flex items-center justify-between border-b border-border-soft bg-card/50 px-5 py-3.5">
<div className="flex items-center gap-2.5">
<div className="size-2 rounded-full bg-primary animate-pulse" />
<span className="text-xs font-black text-foreground">فهرست سفارشات تبدیل و تحویل ارز زیارتی</span>
<Badge variant="secondary" className="text-[11px] font-mono bg-primary/10 text-primary border-0">
{toFa(filteredOrders.length)} سفارش
</Badge>
</div>
<span className="text-[11px] text-grey-3 font-mono">آخرین همگام‌سازی: چند لحظه پیش</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-start text-xs">
<thead className="border-b border-border-soft bg-card/60 text-grey-3">
<tr>
<th className="p-3.5 text-start font-bold">کد سفارش</th>
<th className="p-3.5 text-start font-bold w-20">کد پیگیری</th>
<th className="p-3.5 text-start font-bold">زائر متقاضی</th>
<th className="p-3.5 text-start font-bold">مبلغ پرداختی</th>
<th className="p-3.5 text-start font-bold">مبلغ دریافتی مقصد</th>
<th className="p-3.5 text-start font-bold">نحوه تحویل</th>
<th className="p-3.5 text-start font-bold">وضعیت سفارش</th>
<th className="p-3.5 text-start font-bold">مبلغ مبدا (تومان)</th>
<th className="p-3.5 text-start font-bold">مبلغ مقصد (ارز تحویلی)</th>
<th className="p-3.5 text-start font-bold">محل و نحوه تحویل</th>
<th className="p-3.5 text-start font-bold w-24">تاریخ</th>
<th className="p-3.5 text-start font-bold w-36">وضعیت</th>
</tr>
</thead>
<tbody className="divide-y divide-border-soft">
{recentOrders.map((ord) => (
<tr key={ord.id} className="hover:bg-card/70 transition">
{filteredOrders.length === 0 ? (
<tr>
<td colSpan={7} className="p-8 text-center text-xs text-grey-3">
هیچ سفارشی با این مشخصات یافت نشد.
</td>
</tr>
) : (
filteredOrders.map((ord) => (
<tr key={ord.id} className="hover:bg-card/70 transition-colors">
<td className="p-3.5 font-mono font-bold text-grey-3">#{ord.id}</td>
<td className="p-3.5 font-bold text-foreground">{ord.user}</td>
<td className="p-3.5 text-grey-2 font-mono tabular-nums">{ord.from}</td>
<td className="p-3.5 font-bold text-foreground font-mono tabular-nums">{ord.to}</td>
<td className="p-3.5 text-grey-3">{ord.deliverType}</td>
<td className="p-3.5 font-bold text-foreground">
<div className="flex items-center gap-2">
<div className="size-6 rounded-full bg-primary/10 text-primary flex items-center justify-center text-[10px] font-bold">
{ord.user.slice(0, 1)}
</div>
<span>{ord.user}</span>
</div>
</td>
<td className="p-3.5 text-grey-2 font-mono tabular-nums font-bold">{ord.from}</td>
<td className="p-3.5 font-black text-foreground font-mono tabular-nums">{ord.to}</td>
<td className="p-3.5 text-grey-3">
<div className="flex items-center gap-1.5">
<Ic name="mapPin" className="size-3 text-grey-3 shrink-0" />
<span>{ord.deliverType}</span>
</div>
</td>
<td className="p-3.5 text-grey-3 font-mono text-[11px]">{ord.date}</td>
<td className="p-3.5">
<Badge className="bg-primary/15 text-primary border border-primary/20 text-[10px] rounded-full">
<Badge
className={`text-[10px] rounded-full border ${
ord.statusCode === 'DONE'
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20'
: ord.statusCode === 'CONFIRMED'
? 'bg-primary/10 text-primary border-primary/20'
: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20'
}`}
>
{ord.status}
</Badge>
</td>
</tr>
))}
))
)}
</tbody>
</table>
</div>
@ -383,38 +584,42 @@ function TripEtiquetteServiceView({ service }: { service: ServiceMeta }) {
return (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4">
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs hover:border-primary/30 transition backdrop-blur-xs">
<span className="text-xs text-grey-3 font-medium">مجموع زیارات و ادعیه</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">{toFa(94)}</div>
<div className="mt-1 text-[11px] text-grey-3">فهرست کامل مناسک</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<span className="text-xs text-grey-3 font-medium">فایل‌های صوتی و صوتی‌متنی</span>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs hover:border-primary/30 transition backdrop-blur-xs">
<span className="text-xs text-grey-3 font-medium">فایل‌های صوتی و ترتیل</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">{toFa(86)}</div>
<div className="mt-1 text-[11px] text-emerald-600 dark:text-emerald-400 font-medium">کیفیت استودیویی</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs hover:border-primary/30 transition backdrop-blur-xs">
<span className="text-xs text-grey-3 font-medium">زبان‌های ترجمه</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">۵ زبان</div>
<div className="mt-2 text-2xl font-black text-primary font-mono tabular-nums">5 زبان</div>
<div className="mt-1 text-[11px] text-grey-3">فارسی، عربی، انگلیسی، روسی، اندونزیایی</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs hover:border-primary/30 transition backdrop-blur-xs">
<span className="text-xs text-grey-3 font-medium">شهرهای تحت پوشش</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">{toFa(6)}</div>
<div className="mt-1 text-[11px] text-grey-3">عراق و عربستان سعودی</div>
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{rituals.map((r) => (
<div key={r.id} className="rounded-2xl border border-border-soft bg-card/40 p-5 space-y-3 shadow-xs">
<div key={r.id} className="rounded-2xl border border-border-soft bg-card/40 p-5 space-y-3 shadow-xs hover:border-primary/30 transition">
<div className="flex items-center justify-between">
<Badge variant="outline" className="text-[10px] rounded-lg">{r.city}</Badge>
<div className="flex items-center gap-1 text-[11px] text-grey-3">
<Ic name="music" className="size-3 text-primary" />
<span>همراه با صوت</span>
<Badge variant="outline" className="text-[10px] rounded-lg border-border-soft">{r.city}</Badge>
<div className="flex items-center gap-1.5 text-[11px] text-primary font-medium">
<Ic name="volume2" className="size-3.5" />
<span>همراه با صوت و ترجمه</span>
</div>
</div>
<h3 className="text-base font-black text-foreground">{r.title}</h3>
<div className="flex items-center justify-between text-xs text-grey-3 pt-2 border-t border-border-soft">
<div className="flex items-center justify-between text-xs text-grey-3 pt-2.5 border-t border-border-soft">
<span>شامل {toFa(r.prayersCount)} فراز و دعای وارده</span>
<span className="text-primary font-bold">مشاهده متن و صوت &gt;</span>
<span className="text-primary font-bold hover:underline cursor-pointer">مشاهده متن و صوت &gt;</span>
</div>
</div>
))}
@ -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<string>('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 (
<div className="space-y-6">
{/* ۱. کارت‌های شاخص عملکرد مرکز پشتیبانی و مشاوره */}
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4">
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs hover:border-primary/30 transition backdrop-blur-xs">
<div className="flex items-center justify-between">
<span className="text-xs text-grey-3 font-medium">مشاوران شیفت جاری</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">{toFa(8)}</div>
<span className="flex items-center gap-1.5 text-[10px] text-emerald-600 dark:text-emerald-400 font-bold">
<span className="size-2 rounded-full bg-emerald-500 animate-pulse" />
آنلاین
</span>
</div>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">
{toFa(8)} <span className="text-xs text-grey-3 font-normal">نفر</span>
</div>
<div className="mt-1 text-[11px] text-grey-3 font-medium">
از 12 کارشناس پشتیبانی 24 ساعته
</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs hover:border-primary/30 transition backdrop-blur-xs">
<div className="flex items-center justify-between">
<span className="text-xs text-grey-3 font-medium">مشاوره‌های امروز</span>
<Badge className="bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20 text-[10px] font-bold">
موفق
</Badge>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<span className="text-xs text-grey-3 font-medium">مشاوره‌های موفق امروز</span>
<div className="mt-2 text-2xl font-black text-emerald-600 dark:text-emerald-400 font-mono tabular-nums">{toFa(63)}</div>
<div className="mt-2 text-2xl font-black text-emerald-600 dark:text-emerald-400 font-mono tabular-nums">
{toFa(64)}
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<span className="text-xs text-grey-3 font-medium">میانگین زمان انتظار زائر</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">۴۵ ثانیه</div>
<div className="mt-1 text-[11px] text-grey-3 font-medium">
پاسخگویی به مسائل بهداشتی و شرعی
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs hover:border-primary/30 transition backdrop-blur-xs">
<div className="flex items-center justify-between">
<span className="text-xs text-grey-3 font-medium">میانگین زمان انتظار</span>
<Ic name="clock" className="size-3.5 text-primary" />
</div>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">
45 <span className="text-xs text-grey-3 font-normal">ثانیه</span>
</div>
<div className="mt-1 text-[11px] text-emerald-600 dark:text-emerald-400 font-medium">
استاندارد عالی (زیر 1 دقیقه)
</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs hover:border-primary/30 transition backdrop-blur-xs">
<div className="flex items-center justify-between">
<span className="text-xs text-grey-3 font-medium">رضایت‌مندی زائرین</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">۹۸.۵٪</div>
<Ic name="star" className="size-3.5 text-amber-500" />
</div>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">
98.7%
</div>
<div className="mt-1 text-[11px] text-grey-3 font-medium">
بر اساس 1,420 بازخورد ثبت‌شده
</div>
</div>
</div>
{/* ۲. فیلترها و نوار جستجوی کارشناسان */}
<div className="flex flex-col gap-3 rounded-2xl border border-border-soft bg-card/40 p-4 sm:flex-row sm:items-center sm:justify-between shadow-xs">
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={() => setSpecialtyFilter('ALL')}
className={`px-3 py-1.5 rounded-xl text-xs font-bold transition ${
specialtyFilter === 'ALL'
? 'bg-primary/10 text-primary border border-primary/20 shadow-xs'
: 'text-grey-3 hover:text-foreground'
}`}
>
همه تخصص‌ها
</button>
<button
onClick={() => setSpecialtyFilter('medical')}
className={`px-3 py-1.5 rounded-xl text-xs font-bold transition ${
specialtyFilter === 'medical'
? 'bg-primary/10 text-primary border border-primary/20 shadow-xs'
: 'text-grey-3 hover:text-foreground'
}`}
>
پزشکی و بهداشت
</button>
<button
onClick={() => setSpecialtyFilter('rituals')}
className={`px-3 py-1.5 rounded-xl text-xs font-bold transition ${
specialtyFilter === 'rituals'
? 'bg-primary/10 text-primary border border-primary/20 shadow-xs'
: 'text-grey-3 hover:text-foreground'
}`}
>
احکام و مناسک
</button>
<button
onClick={() => setSpecialtyFilter('logistics')}
className={`px-3 py-1.5 rounded-xl text-xs font-bold transition ${
specialtyFilter === 'logistics'
? 'bg-primary/10 text-primary border border-primary/20 shadow-xs'
: 'text-grey-3 hover:text-foreground'
}`}
>
لجستیک و گمشدگان
</button>
</div>
<div className="relative min-w-[220px]">
<Ic name="search" className="absolute start-3 top-1/2 -translate-y-1/2 size-3.5 text-grey-3" />
<Input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="جستجوی نام یا حوزه تخصص..."
className="h-9 ps-9 text-xs bg-surface-base rounded-xl border-border-soft"
/>
{searchTerm && (
<button
onClick={() => setSearchTerm('')}
className="absolute end-2.5 top-1/2 -translate-y-1/2 text-grey-3 hover:text-foreground p-0.5"
>
<Ic name="close" className="size-3" />
</button>
)}
</div>
</div>
{/* ۳. کارت‌های کارشناسان با استایل مدرن */}
<div className="space-y-4">
<h3 className="text-sm font-black text-foreground">کادر مشاوران تخصصی عقیله</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{consultants.map((c) => (
<div key={c.id} className="rounded-2xl border border-border-soft bg-card/40 p-5 space-y-3 shadow-xs">
<div className="flex items-center gap-3">
<div className="size-10 rounded-full bg-primary/10 text-primary flex items-center justify-center font-bold">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="text-sm font-black text-foreground">کادر کارشناسان پشتیبانی و مشاوره عقیله</h3>
<Badge variant="secondary" className="text-[11px] font-mono bg-primary/10 text-primary border-0">
{toFa(filteredConsultants.length)} کارشناس
</Badge>
</div>
<span className="text-xs text-grey-3">پشتیبانی زنده صوتی و متنی زائرین</span>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{filteredConsultants.map((c) => (
<div
key={c.id}
className="rounded-2xl border border-border-soft bg-card/40 p-5 space-y-4 shadow-xs hover:border-primary/40 transition flex flex-col justify-between"
>
<div className="space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="size-11 rounded-2xl bg-primary/10 text-primary flex items-center justify-center font-bold text-base shrink-0">
<Ic name="headset" className="size-5" />
</div>
<Badge
className={`text-[10px] rounded-full border ${
c.isOnline
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20'
: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20'
}`}
>
{c.status}
</Badge>
</div>
<div>
<h4 className="text-sm font-black text-foreground">{c.name}</h4>
<p className="text-[11px] text-grey-3">{c.specialty}</p>
<p className="text-xs text-grey-3 mt-1 leading-relaxed line-clamp-2">{c.specialty}</p>
</div>
</div>
<div className="flex items-center justify-between text-xs pt-2 border-t border-border-soft">
<Badge className="bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20 text-[10px] rounded-full">
{c.status}
</Badge>
<span className="text-grey-3 font-mono tabular-nums">{toFa(c.activeChats)} گفتگوی فعال</span>
<div className="space-y-3 pt-3 border-t border-border-soft text-xs">
<div className="flex items-center justify-between text-grey-3">
<span>گفتگوهای فعال:</span>
<span className="font-mono font-bold text-foreground tabular-nums">{toFa(c.activeChats)} جلسه</span>
</div>
<div className="flex items-center justify-between text-grey-3">
<span>رضایت مخاطبان:</span>
<span className="font-mono font-bold text-emerald-600 dark:text-emerald-400 tabular-nums">{c.satisfaction}</span>
</div>
<div className="flex items-center justify-between text-grey-3">
<span>سابقه تخصصی:</span>
<span className="font-mono text-grey-2">{c.experience}</span>
</div>
</div>
</div>
))}
@ -490,56 +851,191 @@ function ConsultantServiceView({ service }: { service: ServiceMeta }) {
// ۷. تخمین هزینه سفر (Cost Estimate)
// ─────────────────────────────────────────────────────────────────────────────
function CostEstimateServiceView({ service }: { service: ServiceMeta }) {
const [selectedDestination, setSelectedDestination] = useState<string>('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 (
<div className="space-y-6">
{/* ۱. کارت‌های شاخص‌های آماری تخمین هزینه */}
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4">
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<span className="text-xs text-grey-3 font-medium">محاسبات انجام شده این ماه</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">{toFa('12.8K')}</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs hover:border-primary/30 transition backdrop-blur-xs">
<div className="flex items-center justify-between">
<span className="text-xs text-grey-3 font-medium">استعلام‌های این ماه</span>
<Badge className="bg-primary/10 text-primary border-primary/20 text-[10px] font-bold">
زائرین
</Badge>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<span className="text-xs text-grey-3 font-medium">دقت میانگین برآورد هوشمند</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">۹۵٪</div>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">
{toFa('12.8K')}
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<span className="text-xs text-grey-3 font-medium">پکیج‌های پیش‌فرض اقتصادی و ویژه</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">{toFa(8)}</div>
<div className="mt-1 text-[11px] text-emerald-600 dark:text-emerald-400 font-medium">
+18% رشد استعلام‌های اربعین و عمره
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<span className="text-xs text-grey-3 font-medium">بروزرسانی نرخ‌ها</span>
<div className="mt-2 text-2xl font-black text-emerald-600 font-mono">امروز</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs hover:border-primary/30 transition backdrop-blur-xs">
<div className="flex items-center justify-between">
<span className="text-xs text-grey-3 font-medium">دقت برآورد هوشمند</span>
<Ic name="check" className="size-3.5 text-emerald-600" />
</div>
<div className="mt-2 text-2xl font-black text-emerald-600 dark:text-emerald-400 font-mono tabular-nums">
96.2%
</div>
<div className="mt-1 text-[11px] text-grey-3 font-medium">
مطابقت با هزینه‌های واقعی سفر
</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs hover:border-primary/30 transition backdrop-blur-xs">
<div className="flex items-center justify-between">
<span className="text-xs text-grey-3 font-medium">سرفصل‌های هزینه‌ای</span>
<Badge variant="outline" className="text-[10px] border-border-soft">
پوشش کامل
</Badge>
</div>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">
{toFa(estimateCategories.length)} <span className="text-xs text-grey-3 font-normal">بخش اصلی</span>
</div>
<div className="mt-1 text-[11px] text-grey-3 font-medium">
هتل، ترانسفر، خوراک، اینترنت، بیمه
</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs hover:border-primary/30 transition backdrop-blur-xs">
<div className="flex items-center justify-between">
<span className="text-xs text-grey-3 font-medium">وضعیت نرخ‌ها</span>
<span className="size-2 rounded-full bg-emerald-500 animate-pulse" />
</div>
<div className="mt-2 text-2xl font-black text-primary font-mono tabular-nums">
امروز
</div>
<div className="mt-1 text-[11px] text-grey-3 font-medium">
همگام‌سازی لحظه‌ای با هتل‌ها و ترابری
</div>
</div>
</div>
{/* ۲. فیلترها و نوار جستجو */}
<div className="flex flex-col gap-3 rounded-2xl border border-border-soft bg-card/40 p-4 sm:flex-row sm:items-center sm:justify-between shadow-xs">
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={() => setSelectedDestination('ALL')}
className={`px-3 py-1.5 rounded-xl text-xs font-bold transition ${
selectedDestination === 'ALL'
? 'bg-primary/10 text-primary border border-primary/20 shadow-xs'
: 'text-grey-3 hover:text-foreground'
}`}
>
همه سرفصل‌ها ({toFa(estimateCategories.length)})
</button>
<button
onClick={() => setSelectedDestination('stay')}
className={`px-3 py-1.5 rounded-xl text-xs font-bold transition ${
selectedDestination === 'stay'
? 'bg-primary/10 text-primary border border-primary/20 shadow-xs'
: 'text-grey-3 hover:text-foreground'
}`}
>
هتل و اقامتگاه
</button>
<button
onClick={() => setSelectedDestination('transfer')}
className={`px-3 py-1.5 rounded-xl text-xs font-bold transition ${
selectedDestination === 'transfer'
? 'bg-primary/10 text-primary border border-primary/20 shadow-xs'
: 'text-grey-3 hover:text-foreground'
}`}
>
ترانسفر و تردد
</button>
<button
onClick={() => setSelectedDestination('food')}
className={`px-3 py-1.5 rounded-xl text-xs font-bold transition ${
selectedDestination === 'food'
? 'bg-primary/10 text-primary border border-primary/20 shadow-xs'
: 'text-grey-3 hover:text-foreground'
}`}
>
خوراک و پذیرایی
</button>
</div>
<div className="relative min-w-[220px]">
<Ic name="search" className="absolute start-3 top-1/2 -translate-y-1/2 size-3.5 text-grey-3" />
<Input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="جستجوی سرفصل یا نکات هزینه..."
className="h-9 ps-9 text-xs bg-surface-base rounded-xl border-border-soft"
/>
{searchTerm && (
<button
onClick={() => setSearchTerm('')}
className="absolute end-2.5 top-1/2 -translate-y-1/2 text-grey-3 hover:text-foreground p-0.5"
>
<Ic name="close" className="size-3" />
</button>
)}
</div>
</div>
{/* ۳. جدول تخمین و بودجه‌بندی سفر با هدر کارتی مدرن */}
<div className="overflow-hidden rounded-2xl border border-border-soft bg-card/40 shadow-xs">
<div className="flex items-center justify-between border-b border-border-soft bg-card/50 px-5 py-3.5">
<div className="flex items-center gap-2.5">
<Ic name="calculator" className="size-4 text-primary" />
<span className="text-xs font-black text-foreground">جدول برآورد تفکیکی مخارج سفر (اقتصادی و VIP)</span>
<Badge variant="secondary" className="text-[11px] font-mono bg-primary/10 text-primary border-0">
{toFa(filteredCategories.length)} آیتم
</Badge>
</div>
<span className="text-[11px] text-grey-3">ارز مبنا: دلار و دینار</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-start text-xs">
<thead className="border-b border-border-soft bg-card/60 text-grey-3">
<tr>
<th className="p-3.5 text-start font-bold">بخش هزینه</th>
<th className="p-3.5 text-start font-bold">برآورد بسته اقتصادی (Standard)</th>
<th className="p-3.5 text-start font-bold">برآورد بسته ویژه (VIP)</th>
<th className="p-3.5 text-start font-bold">توضیحات و نکات</th>
<th className="p-3.5 text-start font-bold">بسته اقتصادی (Standard)</th>
<th className="p-3.5 text-start font-bold">بسته ویژه (VIP)</th>
<th className="p-3.5 text-start font-bold">توضیحات و نکات اجرایی</th>
</tr>
</thead>
<tbody className="divide-y divide-border-soft">
{estimateCategories.map((c) => (
<tr key={c.id} className="hover:bg-card/70 transition">
{filteredCategories.map((c) => (
<tr key={c.id} className="hover:bg-card/70 transition-colors">
<td className="p-3.5 font-bold text-foreground">{c.title}</td>
<td className="p-3.5 font-mono text-grey-2 tabular-nums">{c.standard}</td>
<td className="p-3.5 font-mono font-bold text-foreground tabular-nums">{c.vip}</td>
<td className="p-3.5 text-grey-3">{c.notes}</td>
<td className="p-3.5 font-mono font-black text-primary tabular-nums">{c.vip}</td>
<td className="p-3.5 text-grey-3 leading-relaxed">{c.notes}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}

104
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<string>()
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 (
<div className="space-y-6" dir="rtl">
{/* ۰. کارت‌های آماری کلان ادعیه و زیارات */}
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4">
{/* کل ادعیه */}
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs flex items-center justify-between">
<div>
<p className="text-[11px] text-grey-3 font-medium">کل ادعیه و زیارات</p>
{isLoading ? (
<ShimmerBox width={48} height={24} rounded="md" className="mt-1" />
) : (
<p className="text-xl font-black mt-1 font-mono text-foreground tabular-nums">
{toFa(duasStats.total)}
</p>
)}
</div>
<div className="size-9 rounded-xl bg-muted/60 flex items-center justify-center text-muted-foreground">
<Ic name="book" className="size-4" />
</div>
</div>
{/* شهرهای زیارتی */}
<div className="rounded-2xl border border-blue-500/20 bg-blue-500/5 p-4 shadow-xs flex items-center justify-between">
<div>
<p className="text-[11px] text-blue-600 dark:text-blue-400 font-medium">شهرهای تحت پوشش</p>
{isLoading ? (
<ShimmerBox width={48} height={24} rounded="md" className="mt-1" />
) : (
<p className="text-xl font-black mt-1 font-mono text-blue-600 dark:text-blue-400 tabular-nums">
{toFa(duasStats.citiesCount)}
</p>
)}
</div>
<div className="size-9 rounded-xl bg-blue-500/15 text-blue-600 dark:text-blue-400 flex items-center justify-center">
<Ic name="map" className="size-4" />
</div>
</div>
{/* ادعیه با صوت و فراز */}
<div className="rounded-2xl border border-amber-500/20 bg-amber-500/5 p-4 shadow-xs flex items-center justify-between">
<div>
<p className="text-[11px] text-amber-600 dark:text-amber-400 font-medium">دارای متن و صوت</p>
{isLoading ? (
<ShimmerBox width={48} height={24} rounded="md" className="mt-1" />
) : (
<p className="text-xl font-black mt-1 font-mono text-amber-600 dark:text-amber-400 tabular-nums">
{toFa(duasStats.withAudio)}
</p>
)}
</div>
<div className="size-9 rounded-xl bg-amber-500/15 text-amber-600 dark:text-amber-400 flex items-center justify-center">
<Ic name="music" className="size-4" />
</div>
</div>
{/* ادعیه فعال در اپ */}
<div className="rounded-2xl border border-emerald-500/20 bg-emerald-500/5 p-4 shadow-xs flex items-center justify-between">
<div>
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 font-medium">فعال در اپلیکیشن</p>
{isLoading ? (
<ShimmerBox width={48} height={24} rounded="md" className="mt-1" />
) : (
<p className="text-xl font-black mt-1 font-mono text-emerald-600 dark:text-emerald-400 tabular-nums">
{toFa(duasStats.active)}
</p>
)}
</div>
<div className="size-9 rounded-xl bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 flex items-center justify-center">
<Ic name="checkCircle" className="size-4" />
</div>
</div>
</div>
{/* نوار فیلتر و جستجوی پیشرفته */}
<div className="rounded-2xl border border-border-soft bg-card/30 p-3.5 flex flex-col lg:flex-row items-stretch lg:items-center gap-3 shadow-xs">
{/* اینپوت جستجو */}
@ -842,7 +931,12 @@ export function DuasServiceView() {
)}
{/* جدول اصلی لیست ادعیه */}
<div className="rounded-2xl border border-border-soft bg-card/40 overflow-hidden shadow-xs">
<div className="rounded-2xl border border-border-soft bg-card/40 overflow-hidden shadow-xs relative">
{(isLoading || isRefreshing) && (
<div className="absolute top-0 inset-x-0 h-0.5 bg-primary/20 overflow-hidden z-10">
<div className="h-full bg-primary animate-indeterminate origin-right" />
</div>
)}
<div className="overflow-x-auto">
<Table>
<TableHeader className="bg-card/70">
@ -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"
>
<option value={10}>۱۰</option>
<option value={25}>۲۵</option>
<option value={50}>۵۰</option>
<option value={10}>10</option>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={1000}>همه</option>
</select>
</div>
@ -1283,7 +1377,7 @@ export function DuasServiceView() {
<Ic name="pencil" className="size-3.5 text-primary" />
<span>نام زیارت / دعا (چندزبانه)</span>
</span>
<span className="text-[11px] text-grey-3">۵ زبان سامانه</span>
<span className="text-[11px] text-grey-3">5 زبان سامانه</span>
</div>
{/* عربی پیش‌فرض و الزامی */}

50
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() {
))
) : (
<>
<Card className="border-border-soft bg-card/40 shadow-xs">
<Card className="border-border-soft bg-card/40 shadow-xs hover:border-primary/30 transition backdrop-blur-xs">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] text-muted-foreground font-medium">پرسش‌های این دسته‌بندی</p>
<p className="text-xl font-black mt-1 font-mono text-foreground">
<p className="text-xl font-black mt-1 font-mono text-foreground tabular-nums">
{toFa(items.filter((i) => i.category === selectedCategory.id).length)}
</p>
</div>
@ -741,11 +741,11 @@ export function FaqServiceView() {
</CardContent>
</Card>
<Card className="border-border-soft bg-card/40 shadow-xs">
<Card className="border-border-soft bg-card/40 shadow-xs hover:border-primary/30 transition backdrop-blur-xs">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] text-muted-foreground font-medium">فعال در اپلیکیشن</p>
<p className="text-xl font-black mt-1 font-mono text-emerald-600 dark:text-emerald-400">
<p className="text-xl font-black mt-1 font-mono text-emerald-600 dark:text-emerald-400 tabular-nums">
{toFa(items.filter((i) => i.category === selectedCategory.id && i.is_active).length)}
</p>
</div>
@ -755,11 +755,11 @@ export function FaqServiceView() {
</CardContent>
</Card>
<Card className="border-border-soft bg-card/40 shadow-xs">
<Card className="border-border-soft bg-card/40 shadow-xs hover:border-primary/30 transition backdrop-blur-xs">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] text-muted-foreground font-medium">ترتیب نمایش دسته‌بندی</p>
<p className="text-xl font-black mt-1 font-mono text-foreground">{toFa(selectedCategory.order)}</p>
<p className="text-xl font-black mt-1 font-mono text-foreground tabular-nums">{toFa(selectedCategory.order)}</p>
</div>
<div className="size-9 rounded-xl bg-muted text-muted-foreground flex items-center justify-center">
<Ic name="arrowUpDown" className="size-4" />
@ -767,11 +767,11 @@ export function FaqServiceView() {
</CardContent>
</Card>
<Card className="border-border-soft bg-card/40 shadow-xs">
<Card className="border-border-soft bg-card/40 shadow-xs hover:border-primary/30 transition backdrop-blur-xs">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] text-muted-foreground font-medium">پوشش ۵ زبانه سامانه</p>
<p className="text-xl font-black mt-1 font-mono text-foreground">۱۰۰٪</p>
<p className="text-[11px] text-muted-foreground font-medium">پوشش 5 زبانه سامانه</p>
<p className="text-xl font-black mt-1 font-mono text-foreground tabular-nums">100%</p>
</div>
<div className="size-9 rounded-xl bg-primary/10 text-primary flex items-center justify-center">
<Ic name="globe" className="size-4" />
@ -912,7 +912,7 @@ export function FaqServiceView() {
{/* وضعیت پوشش ۵ زبانه */}
<div className="flex items-center justify-between text-[10px] text-muted-foreground pt-1 border-t border-border/30">
<span className="text-[10px] font-medium">ترجمه‌های ۵ زبانه:</span>
<span className="text-[10px] font-medium">ترجمه‌های 5 زبانه:</span>
<div className="flex items-center gap-1">
{SUPPORTED_LANGUAGES.map((lang) => {
const hasTrans =
@ -1005,11 +1005,11 @@ export function FaqServiceView() {
))
) : (
<>
<Card className="border-border-soft bg-card/40 shadow-xs">
<Card className="border-border-soft bg-card/40 shadow-xs hover:border-primary/30 transition backdrop-blur-xs">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] text-muted-foreground font-medium">کل دسته‌بندی‌ها</p>
<p className="text-xl font-black mt-1 font-mono text-foreground">{toFa(categories.length)}</p>
<p className="text-xl font-black mt-1 font-mono text-foreground tabular-nums">{toFa(categories.length)}</p>
</div>
<div className="size-9 rounded-xl bg-primary/10 text-primary flex items-center justify-center">
<Ic name="folder" className="size-4" />
@ -1017,11 +1017,11 @@ export function FaqServiceView() {
</CardContent>
</Card>
<Card className="border-border-soft bg-card/40 shadow-xs">
<Card className="border-border-soft bg-card/40 shadow-xs hover:border-primary/30 transition backdrop-blur-xs">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] text-muted-foreground font-medium">دسته‌بندی‌های فعال</p>
<p className="text-xl font-black mt-1 font-mono text-emerald-600 dark:text-emerald-400">
<p className="text-xl font-black mt-1 font-mono text-emerald-600 dark:text-emerald-400 tabular-nums">
{toFa(categories.filter((c) => c.is_active).length)}
</p>
</div>
@ -1031,11 +1031,11 @@ export function FaqServiceView() {
</CardContent>
</Card>
<Card className="border-border-soft bg-card/40 shadow-xs">
<Card className="border-border-soft bg-card/40 shadow-xs hover:border-primary/30 transition backdrop-blur-xs">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] text-muted-foreground font-medium">کل پرسش و پاسخ‌ها</p>
<p className="text-xl font-black mt-1 font-mono text-foreground">{toFa(items.length)}</p>
<p className="text-xl font-black mt-1 font-mono text-foreground tabular-nums">{toFa(items.length)}</p>
</div>
<div className="size-9 rounded-xl bg-muted text-muted-foreground flex items-center justify-center">
<Ic name="helpCircle" className="size-4" />
@ -1043,11 +1043,11 @@ export function FaqServiceView() {
</CardContent>
</Card>
<Card className="border-border-soft bg-card/40 shadow-xs">
<Card className="border-border-soft bg-card/40 shadow-xs hover:border-primary/30 transition backdrop-blur-xs">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] text-muted-foreground font-medium">سوالات فعال در اپ</p>
<p className="text-xl font-black mt-1 font-mono text-foreground">
<p className="text-xl font-black mt-1 font-mono text-foreground tabular-nums">
{toFa(items.filter((i) => i.is_active).length)}
</p>
</div>
@ -1325,7 +1325,7 @@ export function FaqServiceView() {
<div className="size-6 rounded-lg bg-primary/10 text-primary flex items-center justify-center">
<Ic name="globe" className="size-3.5" />
</div>
<span className="text-xs font-bold text-foreground">عناوین چندزبانه دسته‌بندی (پوشش ۵ زبان)</span>
<span className="text-xs font-bold text-foreground">عناوین چندزبانه دسته‌بندی (پوشش 5 زبان)</span>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-[10px] border-border/60 px-2 py-0.5 font-medium text-muted-foreground">
@ -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 زبان دیگر"
>
<Ic name="repeat" className="size-3" />
<span>کپی در تمام زبان‌ها</span>
@ -1466,7 +1466,7 @@ export function FaqServiceView() {
{editingItem ? 'ویرایش پرسش و پاسخ' : 'ایجاد پرسش و پاسخ جدید'}
</DialogTitle>
<DialogDescription className="text-[11px] text-muted-foreground mt-0.5">
مدیریت متن پرسش و پاسخ به همراه پوشش ۵ زبان همزمان
مدیریت متن پرسش و پاسخ به همراه پوشش 5 زبان همزمان
</DialogDescription>
</div>
</div>
@ -1537,7 +1537,7 @@ export function FaqServiceView() {
<div className="size-6 rounded-lg bg-primary/10 text-primary flex items-center justify-center">
<Ic name="globe" className="size-3.5" />
</div>
<span className="text-xs font-bold text-foreground">متن سوال و پاسخ (پوشش ۵ زبان)</span>
<span className="text-xs font-bold text-foreground">متن سوال و پاسخ (پوشش 5 زبان)</span>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-[10px] border-border/60 px-2 py-0.5 font-medium text-muted-foreground">
@ -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 زبان دیگر"
>
<Ic name="repeat" className="size-3" />
<span>کپی در تمام زبان‌ها</span>

2
src/features/services/components/LocationDetailSheet.tsx

@ -250,7 +250,7 @@ export function LocationDetailSheet({
<span className="text-grey-3">ساعات کاری:</span>
<span className="font-bold text-foreground">
{is24Hours ? (
<span className="text-emerald-600 dark:text-emerald-400">۲۴ ساعته (شبانه‌روزی)</span>
<span className="text-emerald-600 dark:text-emerald-400">24 ساعته (شبانه‌روزی)</span>
) : (
<span className="font-mono" dir="ltr">
{location.working_hours_from ? location.working_hours_from.substring(0, 5) : '08:00'} -{' '}

91
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<SupportedLanguage, string> = {
const createEmptyTitles = (): Record<SupportedLanguage, string> => ({
ar: '',
fa: '',
en: '',
ru: '',
id: '',
}
})
const EMPTY_DETAILS: Record<SupportedLanguage, string[]> = {
const createEmptyDetails = (): Record<SupportedLanguage, string[]> => ({
ar: [],
fa: [],
en: [],
ru: [],
id: [],
}
})
export function SimcardFormDialog({
open,
@ -68,10 +68,10 @@ export function SimcardFormDialog({
const [status, setStatus] = useState<number>(1)
// عناوین چندزبانه
const [titles, setTitles] = useState<Record<SupportedLanguage, string>>({ ...EMPTY_TITLES })
const [titles, setTitles] = useState<Record<SupportedLanguage, string>>(createEmptyTitles)
// ویژگی‌های چندزبانه
const [details, setDetails] = useState<Record<SupportedLanguage, string[]>>({ ...EMPTY_DETAILS })
const [details, setDetails] = useState<Record<SupportedLanguage, string[]>>(createEmptyDetails)
// افزودن ویژگی جدید در زبان فعال
const [newDetailText, setNewDetailText] = useState('')
@ -86,6 +86,8 @@ export function SimcardFormDialog({
const [errorMsg, setErrorMsg] = useState<string | null>(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<SupportedLanguage, string> = { ...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<SupportedLanguage, string[]> = { ...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} اضافه نشده است. می‌توانید با فیلد بالا ویژگی جدید ثبت کنید.
</div>
) : (
<div className="space-y-2 max-h-48 overflow-y-auto pe-1">
<div className="space-y-2">
{activeDetails.map((itemText, idx) => (
<div
key={idx}
@ -585,41 +605,44 @@ export function SimcardFormDialog({
</div>
{/* فوتر فیکس‌شده در پایین */}
<DialogFooter className="p-4 sm:px-6 bg-card border-t border-border-soft shrink-0 flex flex-row items-center justify-between gap-3">
<div className="flex items-center gap-2">
<DialogFooter className="p-4 sm:px-6 bg-card border-t border-border-soft shrink-0 flex flex-row items-center justify-between w-full gap-3">
{/* دکمه حذف در سمت راست (Start در RTL) */}
<div>
{isEditing && onDelete && (
<Button
type="submit"
type="button"
variant="ghost"
disabled={isSubmitting || isDeleting}
className="rounded-xl text-xs font-bold h-10 px-5 bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs"
onClick={() => setShowDeleteConfirm(true)}
className="rounded-xl text-xs h-10 px-3.5 text-rose-500 hover:text-rose-600 hover:bg-rose-500/10 gap-1.5"
title="حذف بسته سیم‌کارت"
>
{isSubmitting ? 'در حال ذخیره...' : 'ذخیره'}
<Ic name="trash" className="size-4" />
<span>حذف بسته</span>
</Button>
)}
</div>
{/* دکمه‌های انصراف و ذخیره در سمت چپ (End در RTL) */}
<div className="flex items-center gap-2">
<DialogClose asChild>
<Button
type="button"
variant="ghost"
disabled={isSubmitting || isDeleting}
className="rounded-xl text-xs h-10 px-4"
className="rounded-xl text-xs h-10 px-4 text-grey-3 hover:text-foreground"
>
انصراف
</Button>
</DialogClose>
</div>
{/* دکمه حذف بسته در گوشه فرم برای حالت ویرایش */}
{isEditing && onDelete && (
<Button
type="button"
variant="ghost"
type="submit"
disabled={isSubmitting || isDeleting}
onClick={() => setShowDeleteConfirm(true)}
className="rounded-xl text-xs h-10 px-3.5 text-rose-500 hover:text-rose-600 hover:bg-rose-500/10 gap-1.5"
title="حذف بسته سیم‌کارت"
className="rounded-xl text-xs font-bold h-10 px-6 bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs"
>
<Ic name="trash" className="size-4" />
<span>حذف بسته</span>
{isSubmitting ? 'در حال ذخیره...' : 'ذخیره'}
</Button>
)}
</div>
</DialogFooter>
</form>
</DialogContent>

65
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 }) {
<div className="space-y-6">
{/* کارت‌های آمار واقعی منطبق با دیتابیس */}
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4">
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs hover:border-primary/30 transition backdrop-blur-xs">
<div className="flex items-center justify-between">
<span className="text-xs text-grey-3 font-medium">بسته‌های فعال سیم‌کارت</span>
<span className="text-xs text-emerald-500 font-bold">در اپلیکیشن</span>
<Badge className="bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20 text-[10px] font-bold">
در اپلیکیشن
</Badge>
</div>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">
{isLoading ? <ShimmerBox className="h-8 w-16" rounded="lg" /> : toFa(simcards.length)}
</div>
<div className="mt-1 text-[11px] text-grey-3 font-medium">
تنوع اپراتورها و دیتای سفر
</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs hover:border-primary/30 transition backdrop-blur-xs">
<div className="flex items-center justify-between">
<span className="text-xs text-grey-3 font-medium">کشورهای تحت پوشش</span>
<span className="text-xs">🌍</span>
</div>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">
{toFa(3)}{' '}
<span className="text-xs font-normal text-grey-3">
(🇮🇷 ایران، 🇮🇶 عراق، 🇸🇦 عربستان)
</span>
{toFa(3)} <span className="text-xs font-normal text-grey-3">کشور</span>
</div>
<div className="mt-1 text-[11px] text-grey-3 font-medium">
🇮🇷 ایران، 🇮🇶 عراق، 🇸🇦 عربستان
</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs hover:border-primary/30 transition backdrop-blur-xs">
<div className="flex items-center justify-between">
<span className="text-xs text-grey-3 font-medium">میانگین قیمت بسته‌ها</span>
<Ic name="tag" className="size-3.5 text-primary" />
</div>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">
{isLoading ? (
<ShimmerBox className="h-8 w-20" rounded="lg" />
@ -179,9 +192,12 @@ export function SimcardServiceView({ service }: { service: ServiceMeta }) {
`${toFa(averagePrice)}$`
)}
</div>
<div className="mt-1 text-[11px] text-grey-3 font-medium">
قیمت‌گذاری رقابتی زائر
</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs flex flex-col justify-between">
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs hover:border-primary/30 transition backdrop-blur-xs flex flex-col justify-between">
<div>
<div className="flex items-center justify-between">
<span className="text-xs text-grey-3 font-medium">سفارش‌های ثبت‌شده</span>
@ -200,6 +216,9 @@ export function SimcardServiceView({ service }: { service: ServiceMeta }) {
toFa(ordersCount)
)}
</div>
<div className="mt-1 text-[11px] text-emerald-600 dark:text-emerald-400 font-medium">
همگام با صورت‌حساب‌ها
</div>
</div>
</div>
</div>
@ -276,6 +295,27 @@ export function SimcardServiceView({ service }: { service: ServiceMeta }) {
{/* جدول بسته‌های سیم‌کارت مطابق با دیتابیس */}
<div className="overflow-hidden rounded-2xl border border-border-soft bg-card/40 shadow-xs">
<div className="flex items-center justify-between border-b border-border-soft bg-card/50 px-5 py-3.5">
<div className="flex items-center gap-2.5">
<Ic name="simcard" className="size-4 text-primary" />
<span className="text-xs font-black text-foreground">فهرست بسته‌های اینترنت و سیم‌کارت</span>
<Badge variant="secondary" className="text-[11px] font-mono bg-primary/10 text-primary border-0">
{toFa(filtered.length)} بسته
</Badge>
</div>
{isLoading && (
<span className="text-[11px] text-primary flex items-center gap-1 font-medium">
<span className="size-2 rounded-full bg-primary animate-ping" />
در حال دریافت از سرور...
</span>
)}
</div>
{isLoading && (
<div className="h-0.5 w-full bg-primary/20 overflow-hidden">
<div className="h-full bg-primary animate-pulse w-full" />
</div>
)}
<div className="overflow-x-auto">
<table className="w-full text-start text-xs">
<thead className="border-b border-border-soft bg-card/60 text-grey-3">
@ -431,7 +471,12 @@ export function SimcardServiceView({ service }: { service: ServiceMeta }) {
{/* فرم ایجاد و ویرایش سیم‌کارت */}
<SimcardFormDialog
open={isFormOpen}
onOpenChange={setIsFormOpen}
onOpenChange={(open) => {
setIsFormOpen(open)
if (!open) {
setEditingItem(null)
}
}}
item={editingItem}
onSave={handleSave}
onDelete={handleDelete}

84
src/features/services/components/StoreServiceView.tsx

@ -500,60 +500,104 @@ export function StoreServiceView({
{/* کارت‌های شاخص‌های کلیدی (KPIs) بر اساس نمای فعال */}
{activeTab === 'categories' ? (
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4">
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<span className="text-xs text-muted-foreground font-medium">کل دسته‌بندی‌های فروشگاه</span>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs hover:border-primary/30 transition backdrop-blur-xs">
<div className="flex items-center justify-between">
<span className="text-xs text-grey-3 font-medium">کل دسته‌بندی‌های فروشگاه</span>
<Ic name="folder" className="size-3.5 text-primary" />
</div>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">
{isLoadingCategories ? <ShimmerBox className="h-8 w-14" /> : toFa(totalCategoriesCount)}
</div>
<div className="mt-1 text-[11px] text-grey-3">مدیریت ردیف‌های کالایی</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs hover:border-primary/30 transition backdrop-blur-xs">
<div className="flex items-center justify-between">
<span className="text-xs text-grey-3 font-medium">دسته‌بندی‌های فعال</span>
<Badge className="bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20 text-[10px] font-bold">
نمایش در اپ
</Badge>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<span className="text-xs text-muted-foreground font-medium">دسته‌بندی‌های فعال (قابل نمایش)</span>
<div className="mt-2 text-2xl font-black text-emerald-600 dark:text-emerald-400 font-mono tabular-nums">
{isLoadingCategories ? <ShimmerBox className="h-8 w-14" /> : toFa(activeCategoriesCount)}
</div>
<div className="mt-1 text-[11px] text-grey-3">در دسترس زائرین</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs hover:border-primary/30 transition backdrop-blur-xs">
<div className="flex items-center justify-between">
<span className="text-xs text-grey-3 font-medium">دسته‌بندی‌های پنهان</span>
<Badge className="bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20 text-[10px] font-bold">
عدم نمایش
</Badge>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<span className="text-xs text-muted-foreground font-medium">دسته‌بندی‌های غیرفعال (پنهان)</span>
<div className="mt-2 text-2xl font-black text-amber-600 dark:text-amber-400 font-mono tabular-nums">
{isLoadingCategories ? <ShimmerBox className="h-8 w-14" /> : toFa(inactiveCategoriesCount)}
</div>
<div className="mt-1 text-[11px] text-grey-3">موقت یا خارج از فصل</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs hover:border-primary/30 transition backdrop-blur-xs">
<div className="flex items-center justify-between">
<span className="text-xs text-grey-3 font-medium">کالاهای تخصیص‌یافته</span>
<Ic name="store" className="size-3.5 text-primary" />
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<span className="text-xs text-muted-foreground font-medium">کالاهای متصل به دسته‌ها</span>
<div className="mt-2 text-2xl font-black text-primary font-mono tabular-nums">
{isLoadingCategories ? <ShimmerBox className="h-8 w-14" /> : toFa(totalConnectedProducts)}
</div>
<div className="mt-1 text-[11px] text-grey-3">اقلام فعال در دسته‌ها</div>
</div>
</div>
) : (
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4">
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<span className="text-xs text-muted-foreground font-medium">کل کالاهای موجود در انبار</span>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs hover:border-primary/30 transition backdrop-blur-xs">
<div className="flex items-center justify-between">
<span className="text-xs text-grey-3 font-medium">کالاهای ثبت‌شده انبار</span>
<Badge className="bg-primary/10 text-primary border-primary/20 text-[10px] font-bold">
انبار معصومه
</Badge>
</div>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">
{isLoadingProducts ? <ShimmerBox className="h-8 w-14" /> : toFa(products.length || totalConnectedProducts)}
</div>
<div className="mt-1 text-[11px] text-grey-3">سوغات و ملزومات زیارت</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs hover:border-primary/30 transition backdrop-blur-xs">
<div className="flex items-center justify-between">
<span className="text-xs text-grey-3 font-medium">دسته‌بندی‌های موجود</span>
<Ic name="folder" className="size-3.5 text-primary" />
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<span className="text-xs text-muted-foreground font-medium">دسته‌بندی‌های در دسترس</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">
{isLoadingCategories ? <ShimmerBox className="h-8 w-14" /> : toFa(activeCategoriesCount)}
</div>
<div className="mt-1 text-[11px] text-grey-3">گروه‌های فعال فروشگاهی</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<span className="text-xs text-muted-foreground font-medium">مجموع موجودی فیزیکی</span>
<div className="mt-2 text-2xl font-black text-primary font-mono tabular-nums">
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs hover:border-primary/30 transition backdrop-blur-xs">
<div className="flex items-center justify-between">
<span className="text-xs text-grey-3 font-medium">مجموع موجودی فیزیکی</span>
<Ic name="tag" className="size-3.5 text-emerald-600" />
</div>
<div className="mt-2 text-2xl font-black text-emerald-600 dark:text-emerald-400 font-mono tabular-nums">
{isLoadingProducts ? (
<ShimmerBox className="h-8 w-14" />
) : (
toFa(products.reduce((acc, p) => acc + (Number(p.stock) || 0), 0))
)}
</div>
<div className="mt-1 text-[11px] text-grey-3">تعداد کل اقلام در انبار</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<span className="text-xs text-muted-foreground font-medium">امکان ارسال به هتل‌های عتبات</span>
<div className="mt-2 text-2xl font-black text-emerald-600 dark:text-emerald-400 font-mono">
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs hover:border-primary/30 transition backdrop-blur-xs">
<div className="flex items-center justify-between">
<span className="text-xs text-grey-3 font-medium">ارسال مستقیم در مقصد</span>
<span className="size-2 rounded-full bg-emerald-500 animate-pulse" />
</div>
<div className="mt-2 text-2xl font-black text-primary font-mono tabular-nums">
فعال
</div>
<div className="mt-1 text-[11px] text-emerald-600 dark:text-emerald-400 font-medium">تحویل در هتل‌های نجف، کربلا، مکه</div>
</div>
</div>
)}
@ -1075,7 +1119,7 @@ export function StoreServiceView({
<div className="size-6 rounded-lg bg-primary/10 text-primary flex items-center justify-center">
<Ic name="globe" className="size-3.5" />
</div>
<span className="text-xs font-bold text-foreground">عناوین چندزبانه دسته‌بندی (پوشش ۵ زبان)</span>
<span className="text-xs font-bold text-foreground">عناوین چندزبانه دسته‌بندی (پوشش 5 زبان)</span>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-[10px] border-border/60 px-2 py-0.5 font-medium text-muted-foreground">
@ -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 زبان دیگر"
>
<Ic name="repeat" className="size-3" />
<span>کپی در تمام زبان‌ها</span>

58
src/features/services/components/ZayerGuideServiceView.tsx

@ -342,45 +342,51 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
<div className="space-y-6">
{/* ۱. سربرگ آماری کلان دیتابیس */}
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4">
<Card className="border-border-soft bg-card/40 shadow-xs">
<CardContent className="p-4">
<span className="text-xs text-grey-3 font-medium">کل اماکن ثبت‌شده در دیتابیس</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono">
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs hover:border-primary/30 transition backdrop-blur-xs">
<div className="flex items-center justify-between">
<span className="text-xs text-grey-3 font-medium">اماکن ثبت‌شده در سامانه</span>
<Badge className="bg-primary/10 text-primary border-primary/20 text-[10px] font-bold">
دیتابیس آنلاین
</Badge>
</div>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">
{toFa(stats.total)}
</div>
<div className="mt-1 text-[11px] text-grey-3">داده‌های رسمی و همگام با اپ</div>
</CardContent>
</Card>
</div>
<Card className="border-border-soft bg-card/40 shadow-xs">
<CardContent className="p-4">
<span className="text-xs text-amber-600 dark:text-amber-400 font-medium">شهرهای زیارتی فعال</span>
<div className="mt-2 text-2xl font-black text-amber-600 dark:text-amber-400 font-mono">
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs hover:border-primary/30 transition backdrop-blur-xs">
<div className="flex items-center justify-between">
<span className="text-xs text-grey-3 font-medium">شهرهای زیارتی فعال</span>
<span className="text-xs">📍</span>
</div>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">
{toFa(stats.citiesCount || allCitiesList.length)}
</div>
<div className="mt-1 text-[11px] text-grey-3">عراق، عربستان، ایران، سوریه</div>
</CardContent>
</Card>
</div>
<Card className="border-border-soft bg-card/40 shadow-xs">
<CardContent className="p-4">
<span className="text-xs text-primary font-medium">دسته‌بندی‌های خدماتی</span>
<div className="mt-2 text-2xl font-black text-primary font-mono">
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs hover:border-primary/30 transition backdrop-blur-xs">
<div className="flex items-center justify-between">
<span className="text-xs text-grey-3 font-medium">دسته‌بندی‌های خدماتی</span>
<Ic name="tag" className="size-3.5 text-primary" />
</div>
<div className="mt-2 text-2xl font-black text-primary font-mono tabular-nums">
{toFa(categories.length || stats.categoriesCount || 8)}
</div>
<div className="mt-1 text-[11px] text-grey-3">مساجد، درمانی، تاریخی و...</div>
</CardContent>
</Card>
<div className="mt-1 text-[11px] text-grey-3">مساجد، درمانی، تاریخی و خرید</div>
</div>
<Card className="border-border-soft bg-card/40 shadow-xs">
<CardContent className="p-4">
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs hover:border-primary/30 transition backdrop-blur-xs">
<div className="flex items-center justify-between">
<span className="text-xs text-grey-3 font-medium">کشورهای تحت پوشش</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono">
{toFa(countries.length || 4)}
<span className="text-xs">🌍</span>
</div>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">
{toFa(countries.length || 4)} <span className="text-xs font-normal text-grey-3">کشور</span>
</div>
<div className="mt-1 text-[11px] text-grey-3">🇮🇶 عراق، 🇸🇦 عربستان، 🇮🇷 ایران، 🇸🇾 سوریه</div>
</div>
<div className="mt-1 text-[11px] text-grey-3">عراق، عربستان، ایران، سوریه</div>
</CardContent>
</Card>
</div>
{/* ۲. نوار جستجو، فیلتر شهر/کشور و دسته‌بندی */}

87
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<string>('admins')
return (
<div className="space-y-6 pt-2 pb-14">
{/* ۱. سربرگ اصلی صفحه تنظیمات (مطابق الگوی طراحی جامع عقیله) */}
<div className="flex flex-col gap-4 rounded-2xl border border-border-soft bg-card/40 p-6 shadow-xs sm:flex-row sm:items-center sm:justify-between backdrop-blur-xs">
<div className="space-y-1">
<div className="flex items-center gap-2.5">
<div className="flex size-9 items-center justify-center rounded-xl bg-primary/10 text-primary">
<Ic name="settings" className="size-5" />
</div>
<h1 className="text-xl font-black text-foreground">
{t('settings.pageTitle')}
</h1>
<Badge
variant="outline"
className="border-primary/30 bg-primary/10 text-primary font-bold font-mono text-xs min-w-[65px] justify-center"
>
{t('settings.badgeRoleControl')}
</Badge>
</div>
<p className="text-xs text-grey-3">
{t('settings.pageSubtitle')}
</p>
</div>
</div>
{/* ۲. نوار تب‌های تفکیک بخش‌های تنظیمات */}
<div className="flex items-center gap-2 overflow-x-auto pb-2 sm:pb-0 scrollbar-none">
{SETTINGS_TABS.map((tab) => {
const isActive = activeTab === tab.id
return (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-3.5 py-2 rounded-xl text-xs font-bold transition-all whitespace-nowrap border shrink-0 ${
isActive
? 'bg-primary text-white border-primary shadow-xs'
: 'bg-card/60 text-grey-2 border-border-soft hover:bg-card hover:text-foreground'
}`}
>
<Ic name={tab.icon} className={`size-4 ${isActive ? 'text-white' : 'text-primary'}`} />
<span>{t(tab.labelKey as any)}</span>
{tab.isCore && (
<span
className={`text-[10px] px-1.5 py-0.2 rounded-md font-bold ${
isActive
? 'bg-white/20 text-white'
: 'bg-primary/15 text-primary'
}`}
>
{t('billing.coreTourBadge')}
</span>
)}
</button>
)
})}
</div>
{/* ۳. محتوای تب فعال */}
{activeTab === 'admins' ? <AdminManagementTab /> : <NotificationsTab />}
</div>
)
}

273
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<AdminUserItem[]>([])
const [totalCount, setTotalCount] = React.useState(0)
const [stats, setStats] = React.useState<AdminStats | null>(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<string>('all')
const [offset, setOffset] = React.useState(0)
const limit = 15
// Modals state
const [selectedAdminForPassword, setSelectedAdminForPassword] = React.useState<AdminUserItem | null>(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 (
<div className="space-y-5">
{/* کارت‌های شاخص آمار و تعداد ادمین‌ها */}
<AdminStatsCards stats={stats} loading={loadingStats} />
{/* نوار جستجو، فیلترها و دکمه افزودن مدیر جدید */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3 bg-card p-3 rounded-2xl border border-border-soft">
<div className="flex flex-1 items-center gap-2">
{/* فیلد جستجوی زنده */}
<div className="relative flex-1 max-w-md">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('settings.searchAdminsPlaceholder')}
className="ps-9 h-9 text-xs"
/>
<Ic
name="search"
className="absolute start-3 top-1/2 -translate-y-1/2 size-4 text-grey-3 pointer-events-none"
/>
{search && (
<button
type="button"
onClick={() => setSearch('')}
className="absolute end-2.5 top-1/2 -translate-y-1/2 text-grey-3 hover:text-foreground cursor-pointer"
>
<Ic name="x" className="size-3.5" />
</button>
)}
</div>
{/* فیلتر وضعیت */}
<div className="flex items-center gap-1 bg-muted/60 p-0.5 rounded-xl border border-border-soft text-xs">
<button
type="button"
onClick={() => {
setSelectedStatus('all')
setOffset(0)
}}
className={`px-3 py-1.5 rounded-lg font-bold transition-colors cursor-pointer ${
selectedStatus === 'all'
? 'bg-card text-foreground shadow-xs'
: 'text-grey-3 hover:text-foreground'
}`}
>
{t('users.filterAll')}
</button>
<button
type="button"
onClick={() => {
setSelectedStatus('active')
setOffset(0)
}}
className={`px-3 py-1.5 rounded-lg font-bold transition-colors cursor-pointer ${
selectedStatus === 'active'
? 'bg-card text-emerald-600 dark:text-emerald-400 shadow-xs'
: 'text-grey-3 hover:text-foreground'
}`}
>
{t('users.filterActive')}
</button>
<button
type="button"
onClick={() => {
setSelectedStatus('inactive')
setOffset(0)
}}
className={`px-3 py-1.5 rounded-lg font-bold transition-colors cursor-pointer ${
selectedStatus === 'inactive'
? 'bg-card text-rose-500 shadow-xs'
: 'text-grey-3 hover:text-foreground'
}`}
>
{t('users.filterInactive')}
</button>
</div>
</div>
{/* دکمه‌های اکشن: افزودن مدیر + تازه‌سازی */}
<div className="flex items-center gap-2 shrink-0">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
loadAdmins()
loadStats()
}}
disabled={loading}
className="h-9 px-3 gap-1.5 border-border-soft text-grey-2 hover:text-foreground"
title={t('settings.refreshTooltip')}
>
<Ic name="refresh" className={`size-3.5 ${loading ? 'animate-spin' : ''}`} />
<span className="hidden md:inline">{t('settings.refreshBtn')}</span>
</Button>
<Button
type="button"
size="sm"
onClick={() => setCreateAdminOpen(true)}
className="h-9 px-3.5 gap-1.5 bg-primary text-white hover:bg-primary/90 text-xs font-bold shadow-xs cursor-pointer"
>
<Ic name="plus" className="size-4" />
<span>{t('settings.addAdminBtn')}</span>
</Button>
</div>
</div>
{/* جدول نمایش ادمین‌ها */}
<AdminUserTable
admins={admins}
loading={loading}
onSetPassword={handleOpenSetPassword}
onAdminUpdated={handleAdminUpdated}
/>
{/* پیجینیشن صفحه‌بندی */}
{totalCount > limit && (
<div className="flex items-center justify-between pt-2 text-xs text-muted-foreground">
<p>
{t('users.page')} {Math.floor(offset / limit) + 1} {t('users.of')}{' '}
{Math.ceil(totalCount / limit)} ({totalCount} {t('settings.adminsCountLabel')})
</p>
<div className="flex items-center gap-1.5">
<Button
type="button"
variant="outline"
size="sm"
disabled={offset === 0 || loading}
onClick={() => setOffset((prev) => Math.max(0, prev - limit))}
className="h-8 text-xs border-border-soft"
>
{t('users.previous')}
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={offset + limit >= totalCount || loading}
onClick={() => setOffset((prev) => prev + limit)}
className="h-8 text-xs border-border-soft"
>
{t('users.next')}
</Button>
</div>
</div>
)}
{/* مودال تعیین و تغییر رمز عبور */}
<SetPasswordModal
admin={selectedAdminForPassword}
open={setPasswordOpen}
onOpenChange={setSetPasswordOpen}
onSuccess={handlePasswordUpdated}
/>
{/* مودال ایجاد ادمین جدید */}
<CreateAdminModal
open={createAdminOpen}
onOpenChange={setCreateAdminOpen}
onSuccess={handleAdminCreated}
/>
</div>
)
}

103
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 (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
{/* ۱. کل مدیران سیستم */}
<Card className="border-border-soft bg-card/40 shadow-xs">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] sm:text-xs text-grey-3 font-medium">
{t('settings.kpiTotalAdmins')}
</p>
{loading && !stats ? (
<ShimmerBox width={48} height={24} rounded="md" className="mt-1.5" />
) : (
<p className="text-xl sm:text-2xl font-black mt-1 font-mono tabular-nums text-foreground">
{stats?.staff_users ?? 0}
</p>
)}
</div>
<div className="size-10 rounded-xl bg-muted/60 flex items-center justify-center text-muted-foreground">
<Ic name="users" className="size-5" />
</div>
</CardContent>
</Card>
{/* ۲. مدیران فعال */}
<Card className="border-emerald-500/20 bg-emerald-500/5 shadow-xs">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] sm:text-xs text-emerald-600 dark:text-emerald-400 font-medium">
{t('settings.kpiActiveAdmins')}
</p>
{loading && !stats ? (
<ShimmerBox width={48} height={24} rounded="md" className="mt-1.5" />
) : (
<p className="text-xl sm:text-2xl font-black mt-1 font-mono tabular-nums text-emerald-600 dark:text-emerald-400">
{stats?.active_staff_users ?? 0}
</p>
)}
</div>
<div className="size-10 rounded-xl bg-emerald-500/10 flex items-center justify-center text-emerald-600 dark:text-emerald-400">
<Ic name="checkCircle" className="size-5" />
</div>
</CardContent>
</Card>
{/* ۳. مدیران ارشد (سوپراوزر) */}
<Card className="border-primary/20 bg-primary/5 shadow-xs">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] sm:text-xs text-primary font-medium">
{t('settings.kpiSuperusers')}
</p>
{loading && !stats ? (
<ShimmerBox width={48} height={24} rounded="md" className="mt-1.5" />
) : (
<p className="text-xl sm:text-2xl font-black mt-1 font-mono tabular-nums text-primary">
{stats?.superusers_count ?? 0}
</p>
)}
</div>
<div className="size-10 rounded-xl bg-primary/10 flex items-center justify-center text-primary">
<Ic name="shield" className="size-5" />
</div>
</CardContent>
</Card>
{/* ۴. مدیران غیرفعال */}
<Card className="border-border-soft bg-card/40 shadow-xs">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] sm:text-xs text-grey-3 font-medium">
{t('settings.kpiInactiveAdmins')}
</p>
{loading && !stats ? (
<ShimmerBox width={48} height={24} rounded="md" className="mt-1.5" />
) : (
<p className="text-xl sm:text-2xl font-black mt-1 font-mono tabular-nums text-muted-foreground">
{stats?.inactive_staff_users ?? 0}
</p>
)}
</div>
<div className="size-10 rounded-xl bg-muted/60 flex items-center justify-center text-muted-foreground">
<Ic name="xCircle" className="size-5" />
</div>
</CardContent>
</Card>
</div>
)
}

321
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<number | null>(null)
const [copiedId, setCopiedId] = React.useState<number | null>(null)
const [revealedIds, setRevealedIds] = React.useState<Record<number, boolean>>({})
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 (
<div className="rounded-2xl border border-border-soft bg-card overflow-hidden shadow-xs">
<div className="overflow-x-auto">
<table className="w-full text-start text-xs border-collapse">
<thead>
<tr className="border-b border-border-soft bg-muted/30 text-grey-2">
<th className="py-3.5 px-4 text-start font-bold">{t('settings.colAdmin')}</th>
<th className="py-3.5 px-4 text-start font-bold">{t('settings.colContact')}</th>
<th className="py-3.5 px-4 text-center font-bold">{t('settings.colRole')}</th>
<th className="py-3.5 px-4 text-center font-bold">{t('settings.colStatus')}</th>
<th className="py-3.5 px-4 text-center font-bold">{t('settings.colPassword')}</th>
<th className="py-3.5 px-4 text-start font-bold">{t('settings.colDateJoined')}</th>
<th className="py-3.5 px-4 text-end font-bold">{t('settings.colActions')}</th>
</tr>
</thead>
<tbody className="divide-y divide-border-soft/60">
{loading ? (
Array.from({ length: 4 }).map((_, idx) => (
<tr key={idx} className="animate-pulse">
<td className="p-4">
<div className="flex items-center gap-3">
<ShimmerBox width={36} height={36} rounded="full" />
<div className="space-y-1.5">
<ShimmerBox width={100} height={14} rounded="md" />
<ShimmerBox width={60} height={10} rounded="md" />
</div>
</div>
</td>
<td className="p-4">
<ShimmerBox width={120} height={14} rounded="md" />
</td>
<td className="p-4 text-center">
<ShimmerBox width={70} height={20} rounded="full" className="mx-auto" />
</td>
<td className="p-4 text-center">
<ShimmerBox width={60} height={20} rounded="full" className="mx-auto" />
</td>
<td className="p-4 text-center">
<ShimmerBox width={90} height={20} rounded="md" className="mx-auto" />
</td>
<td className="p-4">
<ShimmerBox width={80} height={14} rounded="md" />
</td>
<td className="p-4 text-end">
<ShimmerBox width={90} height={28} rounded="lg" className="ms-auto" />
</td>
</tr>
))
) : admins.length === 0 ? (
<tr>
<td colSpan={7} className="py-12 text-center text-muted-foreground">
<div className="flex flex-col items-center justify-center gap-2">
<div className="size-12 rounded-2xl bg-muted/60 flex items-center justify-center text-muted-foreground">
<Ic name="users" className="size-6" />
</div>
<p className="text-sm font-bold text-foreground">
{t('settings.noAdminsFoundTitle')}
</p>
<p className="text-xs text-muted-foreground">
{t('settings.noAdminsFoundDesc')}
</p>
</div>
</td>
</tr>
) : (
admins.map((admin) => {
const isRevealed = !!revealedIds[admin.id]
const isToggling = togglingId === admin.id
return (
<tr
key={admin.id}
className="hover:bg-muted/30 transition-colors group"
>
{/* ۱. ادمین */}
<td className="py-3.5 px-4">
<div className="flex items-center gap-3">
<div className="size-9 rounded-xl bg-primary/10 text-primary flex items-center justify-center font-bold text-xs shrink-0 border border-primary/20">
{admin.avatar ? (
<img
src={admin.avatar}
alt={admin.fullname}
className="size-full rounded-xl object-cover"
/>
) : (
(admin.fullname || admin.email || 'A').charAt(0).toUpperCase()
)}
</div>
<div>
<div className="flex items-center gap-1.5">
<span className="font-bold text-foreground">
{admin.fullname || t('users.unnamedUser')}
</span>
<span className="text-[10px] text-muted-foreground font-mono">
#{admin.id}
</span>
</div>
{admin.last_login && (
<p className="text-[10px] text-muted-foreground mt-0.5">
{t('users.lastLogin')}: {formatDate(admin.last_login)}
</p>
)}
</div>
</div>
</td>
{/* ۲. تماس و ارتباط */}
<td className="py-3.5 px-4 font-mono text-[11px] text-muted-foreground">
{admin.email && (
<div className="text-foreground font-medium" dir="ltr">
{admin.email}
</div>
)}
{admin.phone_number && (
<div className="text-grey-3 mt-0.5" dir="ltr">
{admin.phone_number}
</div>
)}
{!admin.email && !admin.phone_number && '—'}
</td>
{/* ۳. نقش */}
<td className="py-3.5 px-4 text-center">
<span
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-[10px] font-bold ${
admin.is_superuser
? 'bg-primary/15 text-primary border border-primary/25'
: 'bg-blue-500/15 text-blue-600 dark:text-blue-400 border border-blue-500/25'
}`}
>
<Ic
name={admin.is_superuser ? 'shield' : 'user'}
className="size-3"
/>
<span>
{admin.is_superuser
? t('users.admin')
: t('settings.roleStaff')}
</span>
</span>
</td>
{/* ۴. وضعیت فعال/غیرفعال */}
<td className="py-3.5 px-4 text-center">
<button
type="button"
onClick={() => handleToggleStatus(admin)}
disabled={isToggling}
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-bold transition-all cursor-pointer ${
admin.is_active
? 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/25 border border-emerald-500/20'
: 'bg-muted text-muted-foreground hover:bg-muted/80 border border-border-soft'
}`}
title={t('settings.toggleStatusTooltip')}
>
{isToggling ? (
<div className="size-2.5 border-2 border-current border-t-transparent rounded-full animate-spin" />
) : (
<div
className={`size-1.5 rounded-full ${
admin.is_active ? 'bg-emerald-500 animate-pulse' : 'bg-grey-3'
}`}
/>
)}
<span>
{admin.is_active ? t('users.filterActive') : t('users.filterInactive')}
</span>
</button>
</td>
{/* ۵. رمز عبور / رمز در دسترس */}
<td className="py-3.5 px-4 text-center">
{admin.available_password ? (
<div className="inline-flex items-center gap-1.5 bg-muted/60 border border-border-soft px-2.5 py-1 rounded-lg">
<Ic name="lock" className="size-3 text-grey-3 shrink-0" />
<span className="font-mono text-[11px] font-bold text-foreground" dir="ltr">
{isRevealed ? admin.available_password : '••••••••'}
</span>
<button
type="button"
onClick={() => toggleRevealPassword(admin.id)}
className="text-grey-3 hover:text-foreground transition-colors cursor-pointer p-0.5"
title={isRevealed ? t('auth.hidePassword') : t('auth.showPassword')}
>
<Ic name={isRevealed ? 'eyeOff' : 'eye'} className="size-3.5" />
</button>
<button
type="button"
onClick={() => handleCopyPassword(admin)}
className="text-grey-3 hover:text-primary transition-colors cursor-pointer p-0.5"
title={t('settings.copyPasswordTooltip')}
>
<Ic
name={copiedId === admin.id ? 'check' : 'copy'}
className="size-3.5"
/>
</button>
</div>
) : (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<Ic name="lock" className="size-3" />
<span>{t('settings.passwordCustomOrEncrypted')}</span>
</span>
)}
</td>
{/* ۶. تاریخ ثبت */}
<td className="py-3.5 px-4 text-start font-mono text-[11px] text-muted-foreground whitespace-nowrap">
{formatDate(admin.date_joined)}
</td>
{/* ۷. دکمه‌های عملیات */}
<td className="py-3.5 px-4 text-end">
<div className="flex items-center justify-end gap-1.5">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => onSetPassword(admin)}
className="h-8 px-2.5 text-xs font-bold gap-1.5 border-border-soft hover:bg-primary/10 hover:text-primary hover:border-primary/30"
title={t('settings.setPasswordTooltip')}
>
<Ic name="key" className="size-3.5 text-primary" />
<span className="hidden sm:inline">
{t('settings.setPasswordAction')}
</span>
</Button>
<Link
to={`/users/${admin.id}`}
className="h-8 size-8 flex items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors cursor-pointer"
title={t('settings.viewUserProfileTooltip')}
>
<Ic name="external" className="size-3.5" />
</Link>
</div>
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
</div>
)
}

283
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[520px] p-0 overflow-hidden">
<DialogHeader className="p-5 pb-4 border-b border-border-soft bg-card/60">
<div className="flex items-center gap-3">
<div className="size-10 rounded-xl bg-primary/10 text-primary flex items-center justify-center shrink-0">
<Ic name="users" className="size-5" />
</div>
<div>
<DialogTitle className="text-base font-bold text-foreground">
{t('settings.createAdminModalTitle')}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground mt-0.5">
{t('settings.createAdminModalDesc')}
</DialogDescription>
</div>
</div>
</DialogHeader>
<form onSubmit={handleSubmit} className="p-5 space-y-4">
{/* نام و نام خانوادگی */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-foreground">
{t('settings.adminFullnameLabel')} <span className="text-rose-500">*</span>
</label>
<Input
value={fullname}
onChange={(e) => setFullname(e.target.value)}
placeholder={t('settings.adminFullnamePlaceholder')}
className="text-xs"
/>
</div>
{/* دو ستونه: ایمیل و شماره موبایل */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="text-xs font-medium text-foreground">
{t('settings.adminEmailLabel')}
</label>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="[email protected]"
className="text-xs font-mono"
dir="ltr"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-foreground">
{t('settings.adminPhoneLabel')}
</label>
<Input
type="tel"
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value)}
placeholder="+98912..."
className="text-xs font-mono"
dir="ltr"
/>
</div>
</div>
<p className="text-[10px] text-muted-foreground">
{t('settings.adminEmailOrPhoneHint')}
</p>
{/* رمز عبور اولیه */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="text-xs font-medium text-foreground">
{t('settings.initialPasswordLabel')} <span className="text-rose-500">*</span>
</label>
<button
type="button"
onClick={generateStrongPassword}
className="text-[11px] text-primary hover:underline font-bold flex items-center gap-1 cursor-pointer"
>
<Ic name="sparkles" className="size-3" />
<span>{t('settings.generatePasswordBtn')}</span>
</button>
</div>
<div className="relative">
<Input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••••••"
className="pe-10 font-mono text-sm tracking-wider"
dir="ltr"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute end-2.5 top-1/2 -translate-y-1/2 text-grey-3 hover:text-foreground transition-colors cursor-pointer"
>
<Ic name={showPassword ? 'eyeOff' : 'eye'} className="size-4" />
</button>
</div>
<p className="text-[10px] text-muted-foreground">
{t('settings.passwordMinLengthHint')}
</p>
</div>
{/* نقش و سطح دسترسی + وضعیت حساب */}
<div className="p-3.5 rounded-xl bg-muted/40 border border-border-soft space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-bold text-foreground">
{t('settings.roleSuperuserTitle')}
</p>
<p className="text-[10px] text-muted-foreground mt-0.5">
{t('settings.roleSuperuserDesc')}
</p>
</div>
<Switch
checked={isSuperuser}
onCheckedChange={setIsSuperuser}
/>
</div>
<div className="h-px bg-border-soft" />
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-bold text-foreground">
{t('settings.accountActiveTitle')}
</p>
<p className="text-[10px] text-muted-foreground mt-0.5">
{t('settings.accountActiveDesc')}
</p>
</div>
<Switch
checked={isActive}
onCheckedChange={setIsActive}
/>
</div>
</div>
<DialogFooter className="pt-3 gap-2 sm:gap-0">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={submitting}
className="text-xs"
>
{t('settings.cancelBtn')}
</Button>
<Button
type="submit"
disabled={submitting || !fullname || (!email && !phoneNumber) || !password}
className="text-xs bg-primary text-white hover:bg-primary/90"
>
{submitting ? (
<div className="flex items-center gap-2">
<div className="size-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
<span>{t('settings.creatingAdmin')}</span>
</div>
) : (
<div className="flex items-center gap-1.5">
<Ic name="plus" className="size-3.5" />
<span>{t('settings.createAdminBtn')}</span>
</div>
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

44
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 (
<Card className="border-border-soft bg-card/40 shadow-xs">
<CardContent className="p-8 sm:p-12 text-center max-w-xl mx-auto space-y-4">
<div className="size-16 rounded-3xl bg-primary/10 text-primary flex items-center justify-center mx-auto shadow-inner">
<Ic name="bell" className="size-8" />
</div>
<div className="space-y-2">
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-bold bg-amber-500/15 text-amber-600 dark:text-amber-400 border border-amber-500/25">
<span className="size-2 rounded-full bg-amber-500 animate-pulse" />
<span>{t('navigation.soon')}</span>
</div>
<h3 className="text-lg font-black text-foreground">
{t('settings.notificationsTabTitle')}
</h3>
<p className="text-xs sm:text-sm text-muted-foreground leading-relaxed">
{t('settings.notificationsTabDesc')}
</p>
</div>
<div className="p-4 rounded-2xl bg-muted/40 border border-border-soft text-start space-y-2 text-xs text-muted-foreground">
<div className="flex items-center gap-2 font-bold text-foreground">
<Ic name="sparkles" className="size-4 text-primary" />
<span>{t('settings.notificationsUpcomingFeaturesTitle')}</span>
</div>
<ul className="list-disc list-inside space-y-1 ps-1 text-[11px] sm:text-xs">
<li>{t('settings.notificationsFeature1')}</li>
<li>{t('settings.notificationsFeature2')}</li>
<li>{t('settings.notificationsFeature3')}</li>
</ul>
</div>
</CardContent>
</Card>
)
}

281
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[480px] p-0 overflow-hidden">
<DialogHeader className="p-5 pb-4 border-b border-border-soft bg-card/60">
<div className="flex items-center gap-3">
<div className="size-10 rounded-xl bg-primary/10 text-primary flex items-center justify-center shrink-0">
<Ic name="key" className="size-5" />
</div>
<div>
<DialogTitle className="text-base font-bold text-foreground">
{t('settings.setPasswordModalTitle')}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground mt-0.5">
{t('settings.setPasswordModalDesc')}
</DialogDescription>
</div>
</div>
</DialogHeader>
<form onSubmit={handleSubmit} className="p-5 space-y-4">
{/* مشخصات ادمین هدف */}
<div className="p-3.5 rounded-xl bg-muted/40 border border-border-soft flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="size-9 rounded-lg bg-primary/15 text-primary flex items-center justify-center font-bold text-sm">
{(admin.fullname || admin.email || 'A').charAt(0).toUpperCase()}
</div>
<div>
<p className="text-xs font-bold text-foreground">
{admin.fullname || t('users.unnamedUser')}
</p>
<p className="text-[11px] text-muted-foreground font-mono mt-0.5" dir="ltr">
{admin.email || admin.phone_number || `#${admin.id}`}
</p>
</div>
</div>
<span
className={`text-[10px] px-2 py-0.5 rounded-md font-bold ${
admin.is_superuser
? 'bg-primary/15 text-primary border border-primary/20'
: 'bg-blue-500/15 text-blue-600 dark:text-blue-400 border border-blue-500/20'
}`}
>
{admin.is_superuser ? t('users.admin') : t('settings.roleStaff')}
</span>
</div>
{/* اگر رمز فعلی ذخیره‌شده دارد، نمایش و دکمه کپی */}
{admin.available_password && (
<div className="p-3 rounded-xl bg-amber-500/10 border border-amber-500/20 flex items-center justify-between">
<div className="flex items-center gap-2">
<Ic name="lock" className="size-4 text-amber-600 dark:text-amber-400 shrink-0" />
<span className="text-xs text-amber-700 dark:text-amber-300 font-medium">
{t('settings.currentAvailablePassword')}:
</span>
<span className="font-mono text-xs font-bold text-foreground bg-card/70 px-2 py-0.5 rounded-md border border-border-soft" dir="ltr">
{admin.available_password}
</span>
</div>
<button
type="button"
onClick={handleCopyCurrent}
className="text-[11px] font-bold text-amber-700 dark:text-amber-300 hover:underline flex items-center gap-1 cursor-pointer"
>
<Ic name="copy" className="size-3.5" />
<span>{copiedCurrent ? t('settings.copied') : t('settings.copy')}</span>
</button>
</div>
)}
{/* فیلد رمز عبور جدید */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="text-xs font-medium text-foreground">
{t('settings.newPasswordLabel')}
</label>
<button
type="button"
onClick={generateStrongPassword}
className="text-[11px] text-primary hover:underline font-bold flex items-center gap-1 cursor-pointer"
>
<Ic name="sparkles" className="size-3" />
<span>{t('settings.generatePasswordBtn')}</span>
</button>
</div>
<div className="relative">
<Input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••••••"
className="pe-10 font-mono text-sm tracking-wider"
dir="ltr"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute end-2.5 top-1/2 -translate-y-1/2 text-grey-3 hover:text-foreground transition-colors cursor-pointer"
>
<Ic name={showPassword ? 'eyeOff' : 'eye'} className="size-4" />
</button>
</div>
<p className="text-[10px] text-muted-foreground">
{t('settings.passwordMinLengthHint')}
</p>
</div>
{/* فیلد تکرار رمز عبور */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-foreground">
{t('settings.confirmPasswordLabel')}
</label>
<div className="relative">
<Input
type={showConfirmPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••••••"
className="pe-10 font-mono text-sm tracking-wider"
dir="ltr"
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute end-2.5 top-1/2 -translate-y-1/2 text-grey-3 hover:text-foreground transition-colors cursor-pointer"
>
<Ic name={showConfirmPassword ? 'eyeOff' : 'eye'} className="size-4" />
</button>
</div>
{confirmPassword && password !== confirmPassword && (
<p className="text-[11px] text-rose-500 font-medium">
{t('settings.errorPasswordMismatch')}
</p>
)}
</div>
<DialogFooter className="pt-3 gap-2 sm:gap-0">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={submitting}
className="text-xs"
>
{t('settings.cancelBtn')}
</Button>
<Button
type="submit"
disabled={submitting || !password || password !== confirmPassword}
className="text-xs bg-primary text-white hover:bg-primary/90"
>
{submitting ? (
<div className="flex items-center gap-2">
<div className="size-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
<span>{t('settings.savingPassword')}</span>
</div>
) : (
<div className="flex items-center gap-1.5">
<Ic name="check" className="size-3.5" />
<span>{t('settings.savePasswordBtn')}</span>
</div>
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

108
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<DrfPaginatedResponse<AdminUserItem>> {
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<DrfPaginatedResponse<AdminUserItem>>(path)
}
/**
* دریافت آمارهای کلان مربوط به مدیران سیستم
*/
export async function fetchAdminStats(): Promise<AdminStats> {
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<AdminUserItem> {
return apiFetch<AdminUserItem>('account/admin/users/create/', {
method: 'POST',
body: payload,
})
}
/**
* ویرایش اطلاعات یا تغییر وضعیت فعال/غیرفعال ادمین
*/
export async function updateAdminUser(
userId: number,
payload: UpdateAdminPayload
): Promise<AdminUserItem> {
return apiFetch<AdminUserItem>(`account/admin/users/${userId}/`, {
method: 'PATCH',
body: payload,
})
}

36
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
}

6
src/features/tours/OngoingToursPage.tsx

@ -390,7 +390,7 @@ export default function OngoingToursPage() {
<Card className="border-border-soft bg-card/40 shadow-xs">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] text-muted-foreground font-medium">نزدیک به شروع (۲۴-۴۸ ساعت)</p>
<p className="text-[11px] text-muted-foreground font-medium">نزدیک به شروع (24-48 ساعت)</p>
{isLoading ? (
<Skeleton className="mt-2 h-7 w-12 rounded-lg" />
) : (
@ -458,7 +458,7 @@ export default function OngoingToursPage() {
: 'text-grey-3'
}`}
>
نزدیک به شروع ۲۴-۴۸ ساعت ({toFa(stats.startingSoon)})
نزدیک به شروع 24-48 ساعت ({toFa(stats.startingSoon)})
</Button>
</div>
@ -502,7 +502,7 @@ export default function OngoingToursPage() {
</div>
<h3 className="mt-4 text-sm font-bold text-foreground">توری در حال برگزاری یا نزدیک به شروع یافت نشد</h3>
<p className="mt-1 text-xs text-grey-3 max-w-md mx-auto">
در بازه زمانی جاری (هم‌اکنون یا ۲۴ تا ۴۸ ساعت آینده)، هیچ توری مطابق با فیلترهای انتخابی شما در حال
در بازه زمانی جاری (هم‌اکنون یا 24 تا 48 ساعت آینده)، هیچ توری مطابق با فیلترهای انتخابی شما در حال
برگزاری نیست.
</p>
<Button

16
src/features/tours/ToursPage.tsx

@ -215,7 +215,7 @@ export default function ToursPage() {
const s = new Date(start).getTime()
const e = new Date(end).getTime()
const diff = Math.ceil((e - s) / (1000 * 3600 * 24))
return diff > 0 ? `${diff} روز` : '۱ روز'
return diff > 0 ? `${diff} روز` : '1 روز'
}
return (
@ -537,15 +537,15 @@ export default function ToursPage() {
{/* جدول ۳ ردیف قیمت تور بر اساس رده سنی */}
<div className="space-y-1.5 pt-2 border-t border-border-soft text-xs">
<div className="flex items-center justify-between text-grey-2">
<span className="text-[11px] text-grey-3">بزرگسال <span className="text-[10px] opacity-80">(۷+ سال)</span>:</span>
<span className="text-[11px] text-grey-3">بزرگسال <span className="text-[10px] opacity-80">(7+ سال)</span>:</span>
<span className="font-bold text-foreground font-mono">${parseFloat(tour.price || '0').toLocaleString()}</span>
</div>
<div className="flex items-center justify-between text-grey-2">
<span className="text-[11px] text-grey-3">کودک <span className="text-[10px] opacity-80">(۲ تا ۷ سال)</span>:</span>
<span className="text-[11px] text-grey-3">کودک <span className="text-[10px] opacity-80">(2 تا 7 سال)</span>:</span>
<span className="font-bold text-foreground font-mono">${parseFloat(tour.price_child || '0').toLocaleString()}</span>
</div>
<div className="flex items-center justify-between text-grey-2">
<span className="text-[11px] text-grey-3">نوزاد <span className="text-[10px] opacity-80">(۰ تا ۲ سال)</span>:</span>
<span className="text-[11px] text-grey-3">نوزاد <span className="text-[10px] opacity-80">(0 تا 2 سال)</span>:</span>
<span className="font-bold text-foreground font-mono">${parseFloat(tour.price_infant || '0').toLocaleString()}</span>
</div>
</div>
@ -674,10 +674,10 @@ export default function ToursPage() {
setCurrentPage(1)
}}
options={[
{ value: '10', label: '۱۰' },
{ value: '20', label: '۲۰' },
{ value: '50', label: '۵۰' },
{ value: '100', label: '۱۰۰' },
{ value: '10', label: '10' },
{ value: '20', label: '20' },
{ value: '50', label: '50' },
{ value: '100', label: '100' },
]}
size="sm"
className="w-20"

26
src/features/tours/components/CreateTourDialog.tsx

@ -100,7 +100,7 @@ export function CreateTourDialog({
return
}
if (file.size > 15 * 1024 * 1024) {
setImageUploadError('حجم تصویر نباید بیشتر از ۱۵ مگابایت باشد.')
setImageUploadError('حجم تصویر نباید بیشتر از 15 مگابایت باشد.')
return
}
@ -372,7 +372,7 @@ export function CreateTourDialog({
افزودن تور زیارتی و گردشگری جدید
</DialogTitle>
<DialogDescription className="text-xs text-grey-3 mt-0.5">
ثبت مشخصات پایه، هزینه‌ها، ظرفیت، تصویر شاخص و ورودی‌های ترجمه ۵ زبانه
ثبت مشخصات پایه، هزینه‌ها، ظرفیت، تصویر شاخص و ورودی‌های ترجمه 5 زبانه
</DialogDescription>
</div>
</div>
@ -389,7 +389,7 @@ export function CreateTourDialog({
: 'text-grey-3 hover:text-foreground'
}`}
>
۱. مشخصات و قیمت
1. مشخصات و قیمت
</button>
<button
type="button"
@ -400,7 +400,7 @@ export function CreateTourDialog({
: 'text-grey-3 hover:text-foreground'
}`}
>
۲. ویژگی‌ها و نکات
2. ویژگی‌ها و نکات
</button>
<button
type="button"
@ -412,7 +412,7 @@ export function CreateTourDialog({
}`}
>
<Ic name="globe" className="size-3.5" />
<span>۳. ترجمه‌های ۵ زبانه</span>
<span>3. ترجمه‌های 5 زبانه</span>
</button>
</div>
@ -568,7 +568,7 @@ export function CreateTourDialog({
برای انتخاب تصویر شاخص کلیک کنید یا فایل را اینجا رها کنید (Drag & Drop)
</p>
<p className="text-[11px] text-grey-3 mt-1">
فرمت‌های پشتیبانی‌شده: JPG، PNG، WEBP — حداکثر حجم: ۱۵ مگابایت
فرمت‌های پشتیبانی‌شده: JPG، PNG، WEBP — حداکثر حجم: 15 مگابایت
</p>
{imageUploadError && (
<p className="text-xs text-rose-400 font-medium mt-2">
@ -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({
</div>
<div>
<label className="block text-[11px] font-bold text-grey-2 mb-1">
کودک (۲ تا ۷ سال):
کودک (2 تا 7 سال):
</label>
<Input
type="number"
@ -705,7 +705,7 @@ export function CreateTourDialog({
</div>
<div>
<label className="block text-[11px] font-bold text-grey-2 mb-1">
نوزاد (زیر ۲ سال):
نوزاد (زیر 2 سال):
</label>
<Input
type="number"
@ -765,7 +765,7 @@ export function CreateTourDialog({
<Input
value={newFeature}
onChange={(e) => 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({
<DialogFooter className="px-6 py-4 bg-secondary/10 border-t border-border/50 flex items-center justify-between -mx-6 -mb-5 mt-6">
<div className="flex items-center gap-2">
<span className="text-xs text-grey-3">
{activeTab === 'basic' && 'مرحله ۱ از ۳: اطلاعات پایه و قیمت'}
{activeTab === 'features_tips' && 'مرحله ۲ از ۳: ویژگی‌ها و نکات'}
{activeTab === 'translations' && 'مرحله ۳ از ۳: ترجمه‌های چندزبانه'}
{activeTab === 'basic' && 'مرحله 1 از 3: اطلاعات پایه و قیمت'}
{activeTab === 'features_tips' && 'مرحله 2 از 3: ویژگی‌ها و نکات'}
{activeTab === 'translations' && 'مرحله 3 از 3: ترجمه‌های چندزبانه'}
</span>
</div>

20
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({
<div className="flex items-center justify-between pb-2 border-b border-border-soft">
<div className="flex items-center gap-2 text-xs font-extrabold text-foreground">
<Ic name="dollar" className="size-4 text-primary" />
<span>جدول ۳ ردیف نرخ‌های مصوب و تخفیف تور</span>
<span>جدول 3 ردیف نرخ‌های مصوب و تخفیف تور</span>
</div>
{discNum > 0 && (
<Badge
@ -1344,7 +1344,7 @@ export function TourDetailDialog({
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<div className="rounded-xl border border-border-soft bg-surface-base p-3 space-y-1.5">
<label className="text-[11px] font-bold text-grey-3 block">بزرگسال (۷+ سال / Adult):</label>
<label className="text-[11px] font-bold text-grey-3 block">بزرگسال (7+ سال / Adult):</label>
<div className="relative">
<Input
type="number"
@ -1363,11 +1363,11 @@ export function TourDetailDialog({
نهایی: ${discountedAdult.toLocaleString()}
</div>
)}
<span className="text-[10px] text-grey-3 block">خدمات کامل (۷ سال به بالا)</span>
<span className="text-[10px] text-grey-3 block">خدمات کامل (7 سال به بالا)</span>
</div>
<div className="rounded-xl border border-border-soft bg-surface-base p-3 space-y-1.5">
<label className="text-[11px] font-bold text-grey-3 block">کودک (۲ تا ۷ سال / Child):</label>
<label className="text-[11px] font-bold text-grey-3 block">کودک (2 تا 7 سال / Child):</label>
<div className="relative">
<Input
type="number"
@ -1381,11 +1381,11 @@ export function TourDetailDialog({
/>
<span className="absolute left-2.5 top-2 text-xs text-grey-3 font-mono">$</span>
</div>
<span className="text-[10px] text-grey-3 block">صندلی و خدمات کودک (۲ تا ۷ سال کامل)</span>
<span className="text-[10px] text-grey-3 block">صندلی و خدمات کودک (2 تا 7 سال کامل)</span>
</div>
<div className="rounded-xl border border-border-soft bg-surface-base p-3 space-y-1.5">
<label className="text-[11px] font-bold text-grey-3 block">نوزاد (۰ تا ۲ سال / Infant):</label>
<label className="text-[11px] font-bold text-grey-3 block">نوزاد (0 تا 2 سال / Infant):</label>
<div className="relative">
<Input
type="number"
@ -1399,7 +1399,7 @@ export function TourDetailDialog({
/>
<span className="absolute left-2.5 top-2 text-xs text-grey-3 font-mono">$</span>
</div>
<span className="text-[10px] text-grey-3 block">بیمه و ترانسفر نوزاد (۰ تا ۲ سال کامل)</span>
<span className="text-[10px] text-grey-3 block">بیمه و ترانسفر نوزاد (0 تا 2 سال کامل)</span>
</div>
<div className="rounded-xl border border-border-soft bg-surface-base p-3 space-y-1.5">
@ -1458,7 +1458,7 @@ export function TourDetailDialog({
</div>
{hasNoImages && (
<Badge variant="outline" className="border-rose-500/30 bg-rose-500/10 text-rose-500 text-[11px]">
حداقل ۱ تصویر الزامی است
حداقل 1 تصویر الزامی است
</Badge>
)}
</div>
@ -1620,7 +1620,7 @@ export function TourDetailDialog({
<div className="space-y-2.5">
<div className="space-y-1">
<label className="text-[11px] font-bold text-grey-2">
عنوان مرحله (مثال: روز ۱: ورود به نجف اشرف و استقرار):
عنوان مرحله (مثال: روز 1: ورود به نجف اشرف و استقرار):
</label>
<Input
value={newItinTitle}

14
src/features/tours/components/TourDetailSheet.tsx

@ -151,7 +151,7 @@ export function TourDetailSheet({ tourId, isOpen, onClose }: TourDetailSheetProp
className="gap-1.5 text-xs rounded-xl border-primary/40 text-primary hover:bg-primary/10 h-7 px-2.5"
>
<Ic name="globe" className="size-3.5" />
<span>مدیریت ترجمه‌ها (۵ زبان)</span>
<span>مدیریت ترجمه‌ها (5 زبان)</span>
</Button>
<div className="flex items-center gap-1.5">
<TourStatusBadge status={tour.status} type="sale" />
@ -277,7 +277,7 @@ export function TourDetailSheet({ tourId, isOpen, onClose }: TourDetailSheetProp
<h4 className="text-xs font-extrabold text-foreground">جدول نرخ‌های مصوب</h4>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="rounded-xl border border-border-soft bg-surface-base p-3">
<span className="text-[11px] text-grey-3">بزرگسال (۷+ سال / Adult 7+)</span>
<span className="text-[11px] text-grey-3">بزرگسال (7+ سال / Adult 7+)</span>
<div className="mt-1 text-sm font-black text-foreground">
${parseFloat(tour.price).toLocaleString()}
</div>
@ -291,23 +291,23 @@ export function TourDetailSheet({ tourId, isOpen, onClose }: TourDetailSheetProp
</span>
</div>
)}
<span className="text-[10px] text-grey-3 block mt-1">خدمات کامل بزرگسال (۷ سال به بالا)</span>
<span className="text-[10px] text-grey-3 block mt-1">خدمات کامل بزرگسال (7 سال به بالا)</span>
</div>
<div className="rounded-xl border border-border-soft bg-surface-base p-3">
<span className="text-[11px] text-grey-3">کودک (۲ تا ۷ سال / Child 2-7)</span>
<span className="text-[11px] text-grey-3">کودک (2 تا 7 سال / Child 2-7)</span>
<div className="mt-1 text-sm font-black text-foreground">
${parseFloat(tour.price_child || '0').toLocaleString()}
</div>
<span className="text-[10px] text-grey-3 block mt-1">صندلی و خدمات کودک (۲ تا ۷ سال کامل)</span>
<span className="text-[10px] text-grey-3 block mt-1">صندلی و خدمات کودک (2 تا 7 سال کامل)</span>
</div>
<div className="rounded-xl border border-border-soft bg-surface-base p-3">
<span className="text-[11px] text-grey-3">نوزاد (۰ تا ۲ سال / Infant 0-2)</span>
<span className="text-[11px] text-grey-3">نوزاد (0 تا 2 سال / Infant 0-2)</span>
<div className="mt-1 text-sm font-black text-foreground">
${parseFloat(tour.price_infant || '0').toLocaleString()}
</div>
<span className="text-[10px] text-grey-3 block mt-1">بیمه و ترانسفر نوزاد (۰ تا ۲ سال کامل)</span>
<span className="text-[10px] text-grey-3 block mt-1">بیمه و ترانسفر نوزاد (0 تا 2 سال کامل)</span>
</div>
</div>
</div>

2
src/features/tours/components/TourTranslationsDialog.tsx

@ -270,7 +270,7 @@ export function TourTranslationsDialog({
</div>
<div>
<DialogTitle className="text-base font-extrabold text-foreground">
مدیریت ترجمه‌های ۵ زبانه تور
مدیریت ترجمه‌های 5 زبانه تور
</DialogTitle>
<DialogDescription className="text-xs text-grey-3 mt-0.5">
{tourTitle || `تور شناسه #${tourId}`} &bull; ذخیره‌سازی پویا در سرور و پایگاه‌داده

239
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<UserDetail | null>(null)
const [loading, setLoading] = React.useState(true)
const [activeTab, setActiveTab] = React.useState<TabType>('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 (
<div className="space-y-6">
<div className="h-10 w-48 rounded-xl bg-muted/60 animate-pulse" />
<div className="h-32 rounded-2xl bg-card border border-border/60 animate-pulse" />
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => (
<div key={i} className="h-20 rounded-2xl bg-muted/40 animate-pulse" />
))}
</div>
<div className="h-64 rounded-2xl bg-card border border-border/60 animate-pulse" />
</div>
)
}
if (!user) {
return (
<div className="p-12 text-center bg-card border border-border/60 rounded-2xl space-y-4">
<div className="mx-auto w-12 h-12 rounded-full bg-rose-500/10 text-rose-500 flex items-center justify-center">
<Ic name="alertCircle" className="w-6 h-6" />
</div>
<h2 className="text-base font-bold text-foreground">
{t('users.noUsersFound')}
</h2>
<button
type="button"
onClick={() => navigate('/users')}
className="px-4 py-2 rounded-xl bg-primary text-white text-xs font-semibold"
>
{t('users.backToUsers')}
</button>
</div>
)
}
return (
<div className="space-y-6">
{/* دکمه بازگشت و عنوان پرونده */}
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => navigate('/users')}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-card border border-border/60 text-xs font-semibold hover:bg-muted/40 transition-all text-muted-foreground hover:text-foreground"
>
<Ic name="arrowRight" className="w-4 h-4 rtl:rotate-0 rotate-180" />
<span>{t('users.backToUsers')}</span>
</button>
</div>
{/* بنر اطلاعات هویت کاربر */}
<div className="bg-card border border-border/60 rounded-2xl p-5 sm:p-6 shadow-xs flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-4">
{user.avatar ? (
<img
src={user.avatar}
alt={user.fullname || 'Avatar'}
className="w-16 h-16 rounded-full object-cover border-2 border-border/80 shadow-xs shrink-0"
/>
) : (
<div className="w-16 h-16 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xl font-black shrink-0">
{user.fullname ? user.fullname.slice(0, 2).toUpperCase() : 'U'}
</div>
)}
<div>
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-lg sm:text-xl font-black text-foreground">
{user.fullname || t('users.noData')}
</h1>
<span className="font-mono text-xs px-2 py-0.5 rounded-md bg-muted text-muted-foreground">
#{user.id}
</span>
<span
className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium ${
user.is_active
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'bg-rose-500/10 text-rose-600 dark:text-rose-400'
}`}
>
<span
className={`w-1.5 h-1.5 rounded-full ${
user.is_active ? 'bg-emerald-500' : 'bg-rose-500'
}`}
/>
{user.is_active ? t('users.active') : t('users.inactive')}
</span>
</div>
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground mt-1.5">
{user.phone_number && (
<div className="flex items-center gap-1 font-mono dir-ltr">
<Ic name="phone" className="w-3.5 h-3.5 text-muted-foreground/70" />
<span>{user.phone_number.split(':deleted')[0].trim()}</span>
</div>
)}
{user.email && (
<div className="flex items-center gap-1 font-mono">
<Ic name="mail" className="w-3.5 h-3.5 text-muted-foreground/70" />
<span>{user.email.split(':deleted')[0].trim()}</span>
{user.email.includes(':deleted') && (
<span className="px-1.5 py-0.2 rounded-md bg-rose-500/10 text-rose-600 dark:text-rose-400 text-[10px] font-bold">
{t('users.deletedAccount')}
</span>
)}
</div>
)}
</div>
</div>
</div>
{/* کارت‌های آماری سریع */}
<div className="grid grid-cols-3 gap-2 sm:gap-3 shrink-0 pt-3 sm:pt-0 border-t sm:border-t-0 border-border/40">
<div className="p-3 rounded-xl bg-muted/30 border border-border/30 text-center">
<span className="block text-[11px] text-muted-foreground mb-0.5">
{t('users.totalFactorsCount')}
</span>
<span className="text-base sm:text-lg font-mono font-bold text-foreground">
{user.stats?.total_factors ?? 0}
</span>
</div>
<div className="p-3 rounded-xl bg-muted/30 border border-border/30 text-center">
<span className="block text-[11px] text-muted-foreground mb-0.5">
{t('users.totalToursCount')}
</span>
<span className="text-base sm:text-lg font-mono font-bold text-foreground">
{user.stats?.total_tours ?? 0}
</span>
</div>
<div className="p-3 rounded-xl bg-muted/30 border border-border/30 text-center">
<span className="block text-[11px] text-muted-foreground mb-0.5">
{t('users.totalApprovedSpent')}
</span>
<span className="text-base sm:text-lg font-mono font-bold text-emerald-600 dark:text-emerald-400">
${(user.stats?.approved_spent ?? 0).toLocaleString()}
</span>
</div>
</div>
</div>
{/* سیستم تب‌بندی جامع پرونده کاربر (User Dossier Tabs) */}
<div className="border-b border-border/60">
<nav className="flex items-center gap-2 sm:gap-4 -mb-px overflow-x-auto">
{/* تب اول: مشخصات کاربر */}
<button
type="button"
onClick={() => setActiveTab('profile')}
className={`inline-flex items-center gap-2 py-3 px-3 sm:px-4 border-b-2 text-xs sm:text-sm font-bold transition-all whitespace-nowrap ${
activeTab === 'profile'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
}`}
>
<Ic name="user" className="w-4 h-4" />
<span>{t('users.tabProfile')}</span>
</button>
{/* تب دوم: تورهای کاربر */}
<button
type="button"
onClick={() => setActiveTab('tours')}
className={`inline-flex items-center gap-2 py-3 px-3 sm:px-4 border-b-2 text-xs sm:text-sm font-bold transition-all whitespace-nowrap ${
activeTab === 'tours'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
}`}
>
<Ic name="map" className="w-4 h-4" />
<span>{t('users.tabTours')}</span>
<span className="px-1.5 py-0.2 rounded-full text-[10px] font-mono bg-muted text-muted-foreground">
{user.stats?.total_tours ?? 0}
</span>
</button>
{/* تب سوم: فاکتورهای کاربر */}
<button
type="button"
onClick={() => setActiveTab('invoices')}
className={`inline-flex items-center gap-2 py-3 px-3 sm:px-4 border-b-2 text-xs sm:text-sm font-bold transition-all whitespace-nowrap ${
activeTab === 'invoices'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
}`}
>
<Ic name="receipt" className="w-4 h-4" />
<span>{t('users.tabInvoices')}</span>
<span className="px-1.5 py-0.2 rounded-full text-[10px] font-mono bg-muted text-muted-foreground">
{user.stats?.total_factors ?? 0}
</span>
</button>
</nav>
</div>
{/* محتوای تب فعال */}
<div className="pt-2">
{activeTab === 'profile' && <UserProfileTab user={user} />}
{activeTab === 'tours' && <UserToursTab userId={user.id} />}
{activeTab === 'invoices' && <UserFactorsTab userId={user.id} />}
</div>
</div>
)
}

822
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<UserListItem[]>([])
const [totalCount, setTotalCount] = React.useState(0)
const [loading, setLoading] = React.useState(true)
const [stats, setStats] = React.useState<UserGlobalStats | null>(null)
// Filters state
const [search, setSearch] = React.useState('')
const [debouncedSearch, setDebouncedSearch] = React.useState('')
const [selectedCategory, setSelectedCategory] = React.useState<string>('all')
const [selectedStatus, setSelectedStatus] = React.useState<string>('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 (
<div className="space-y-6 pt-2 pb-14">
{/* ۱. سربرگ اصلی صفحه کاربران (مطابق با ساختار سربرگ در BillingPage) */}
<div className="flex flex-col gap-4 rounded-2xl border border-border-soft bg-card/40 p-6 shadow-xs sm:flex-row sm:items-center sm:justify-between backdrop-blur-xs">
<div className="space-y-1">
<div className="flex items-center gap-2.5">
<div className="flex size-9 items-center justify-center rounded-xl bg-primary/10 text-primary">
<Ic name="users" className="size-5" />
</div>
<h1 className="text-xl font-black text-foreground">
{t('users.title')}
</h1>
<Badge
variant="outline"
className="border-primary/30 bg-primary/10 text-primary font-bold font-mono text-xs min-w-[65px] justify-center"
>
{loading && totalCount === 0 ? (
<ShimmerBox width={36} height={14} rounded="md" className="inline-block" />
) : (
`${totalCount} ${t('users.colUser')}`
)}
</Badge>
</div>
<p className="text-xs text-grey-3">
{t('users.subtitle')}
</p>
</div>
<div className="flex items-center gap-2.5">
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={loading}
className="gap-2 rounded-xl border-border-soft hover:bg-surface-2 text-xs"
>
<Ic name="refresh" className={`size-3.5 ${loading ? 'animate-spin' : ''}`} />
<span>{t('common.refresh')}</span>
</Button>
</div>
</div>
{/* ۲. نوار تب‌های تفکیک دسته‌بندی کاربران (مطابق Service Category Tabs در BillingPage) */}
<div className="flex items-center gap-2 overflow-x-auto pb-2 sm:pb-0 scrollbar-none">
{CATEGORY_TABS.map((tab) => {
const isActive = selectedCategory === tab.id
return (
<button
key={tab.id}
type="button"
onClick={() => handleSelectCategory(tab.id)}
className={`flex items-center gap-2 px-3.5 py-2 rounded-xl text-xs font-bold transition-all whitespace-nowrap border shrink-0 ${
isActive
? 'bg-primary text-white border-primary shadow-xs'
: 'bg-card/60 text-grey-2 border-border-soft hover:bg-card hover:text-foreground'
}`}
>
<Ic name={tab.icon} className={`size-4 ${isActive ? 'text-white' : 'text-primary'}`} />
<span>{t(tab.labelKey as any)}</span>
{tab.isCore && (
<span
className={`text-[10px] px-1.5 py-0.2 rounded-md font-bold ${
isActive
? 'bg-white/20 text-white'
: 'bg-primary/15 text-primary'
}`}
>
{t('billing.coreTourBadge')}
</span>
)}
</button>
)
})}
</div>
{/* ۳. کارت‌های KPI شاخص‌های کلیدی آماری کاربران (۵ کارت مشابه پالت رنگی BillingPage) */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3.5">
{/* ۱. کل کاربران (خنثی) */}
<Card className="border-border-soft bg-card/40 shadow-xs">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] text-grey-3 font-medium">{t('users.kpiTotalUsers')}</p>
{loading && !stats ? (
<ShimmerBox width={48} height={24} rounded="md" className="mt-1" />
) : (
<p className="text-xl font-black mt-1 font-mono tabular-nums text-foreground">
{stats?.total_users ?? totalCount}
</p>
)}
</div>
<div className="size-9 rounded-xl bg-muted/60 flex items-center justify-center text-muted-foreground">
<Ic name="users" className="size-4" />
</div>
</CardContent>
</Card>
{/* ۲. خریداران تور (آبی) */}
<Card className="border-blue-500/20 bg-blue-500/5 shadow-xs">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] text-blue-600 dark:text-blue-400 font-medium">
{t('users.kpiTourUsers')}
</p>
{loading && !stats ? (
<ShimmerBox width={48} height={24} rounded="md" className="mt-1" />
) : (
<p className="text-xl font-black mt-1 font-mono tabular-nums text-blue-600 dark:text-blue-400">
{stats?.users_with_tours ?? 0}
</p>
)}
</div>
<div className="size-9 rounded-xl bg-blue-500/15 text-blue-600 dark:text-blue-400 flex items-center justify-center">
<Ic name="map" className="size-4" />
</div>
</CardContent>
</Card>
{/* ۳. دارای فاکتور و خرید (کهربایی) */}
<Card className="border-amber-500/20 bg-amber-500/5 shadow-xs">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] text-amber-600 dark:text-amber-400 font-medium">
{t('users.kpiFactorUsers')}
</p>
{loading && !stats ? (
<ShimmerBox width={48} height={24} rounded="md" className="mt-1" />
) : (
<p className="text-xl font-black mt-1 font-mono tabular-nums text-amber-600 dark:text-amber-400">
{stats?.users_with_factors ?? 0}
</p>
)}
</div>
<div className="size-9 rounded-xl bg-amber-500/15 text-amber-600 dark:text-amber-400 flex items-center justify-center">
<Ic name="receipt" className="size-4" />
</div>
</CardContent>
</Card>
{/* ۴. کاربران فعال (سبز) */}
<Card className="border-emerald-500/20 bg-emerald-500/5 shadow-xs">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 font-medium">
{t('users.kpiActiveUsers')}
</p>
{loading && !stats ? (
<ShimmerBox width={48} height={24} rounded="md" className="mt-1" />
) : (
<p className="text-xl font-black mt-1 font-mono tabular-nums text-emerald-600 dark:text-emerald-400">
{stats?.active_users ?? 0}
</p>
)}
</div>
<div className="size-9 rounded-xl bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 flex items-center justify-center">
<Ic name="checkCircle" className="size-4" />
</div>
</CardContent>
</Card>
{/* ۵. مدیران سیستم (بنفش/رز) */}
<Card className="border-purple-500/20 bg-purple-500/5 shadow-xs col-span-2 sm:col-span-1">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] text-purple-600 dark:text-purple-400 font-medium">
{t('users.kpiStaffUsers')}
</p>
{loading && !stats ? (
<ShimmerBox width={48} height={24} rounded="md" className="mt-1" />
) : (
<p className="text-xl font-black mt-1 font-mono tabular-nums text-purple-600 dark:text-purple-400">
{stats?.staff_users ?? 0}
</p>
)}
</div>
<div className="size-9 rounded-xl bg-purple-500/15 text-purple-600 dark:text-purple-400 flex items-center justify-center">
<Ic name="shield" className="size-4" />
</div>
</CardContent>
</Card>
</div>
{/* ۴. نوار جستجو و فیلترهای وضعیت لحظه‌ای (مطابق با Card جستجو در BillingPage) */}
<Card className="border-border-soft bg-card/40 shadow-xs">
<CardContent className="p-4">
<div className="flex flex-col md:flex-row items-center justify-between gap-3">
{/* جستجوی متنی زنده */}
<div className="relative flex-1 w-full">
<Ic
name="search"
className="absolute start-3 top-1/2 -translate-y-1/2 size-4 text-grey-3 pointer-events-none"
/>
<Input
placeholder={t('users.searchPlaceholder')}
value={search}
onChange={(e) => 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 && (
<button
type="button"
onClick={() => setSearch('')}
className="absolute end-3 top-1/2 -translate-y-1/2 text-grey-3 hover:text-foreground"
>
<Ic name="x" className="size-3" />
</button>
)}
</div>
{/* چیپ‌های فیلتر سریع وضعیت */}
<div className="flex items-center gap-1.5 w-full md:w-auto overflow-x-auto pb-1 md:pb-0 scrollbar-none">
{[
{ 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 (
<button
key={st.id}
type="button"
onClick={() => handleSelectStatus(st.id)}
className={`px-3 py-1.5 rounded-xl text-xs font-bold transition-all whitespace-nowrap ${
isSelected
? 'bg-primary text-white shadow-xs'
: 'bg-surface-2 text-grey-3 hover:text-foreground hover:bg-surface-3'
}`}
>
{st.label}
</button>
)
})}
</div>
{/* دکمه پاک کردن فیلترها */}
{(search || selectedStatus !== 'all' || selectedCategory !== 'all') && (
<Button
variant="ghost"
size="sm"
onClick={handleResetFilters}
className="h-9 text-xs text-grey-3 hover:text-foreground shrink-0 rounded-xl"
>
<Ic name="x" className="size-3.5 me-1" />
<span>{t('billing.clearFilters')}</span>
</Button>
)}
</div>
</CardContent>
</Card>
{/* ۵. جدول جامع داده‌های کاربران (دقیقاً مطابق استایل Card و جدول در BillingPage) */}
<Card className="border-border-soft bg-card/40 shadow-xs overflow-hidden">
<CardHeader className="py-3 px-4 border-b border-border-soft bg-card/60 flex flex-row items-center justify-between">
<CardTitle className="text-xs font-bold flex items-center gap-2 text-foreground">
<Ic name="list" className="size-4 text-primary" />
<span>{t('users.userList')}</span>
{selectedCategory !== 'all' && (
<Badge variant="outline" className="text-[10px] border-primary/30 text-primary">
{t(CATEGORY_TABS.find((tab) => tab.id === selectedCategory)?.labelKey as any) || selectedCategory}
</Badge>
)}
</CardTitle>
<span className="text-xs text-grey-3 font-mono">
{t('common.pageOf', { current: currentPage, total: totalPages })}
</span>
</CardHeader>
<CardContent className="p-0">
{/* خط انیمیشن لودینگ در بالای جدول هنگام بارگذاری مجدد */}
{loading && (
<div className="h-0.5 bg-primary/20 overflow-hidden">
<div className="h-full bg-primary animate-indeterminate origin-right" />
</div>
)}
{/* حالت عدم وجود داده */}
{!loading && users.length === 0 ? (
<div className="p-12 text-center text-grey-3 space-y-3">
<div className="size-14 rounded-2xl bg-muted/40 flex items-center justify-center mx-auto text-grey-3">
<Ic name="users" className="size-7" />
</div>
<p className="text-sm font-bold text-foreground">{t('users.noUsersFound')}</p>
<p className="text-xs text-grey-3 max-w-sm mx-auto">
{search || selectedStatus !== 'all' || selectedCategory !== 'all'
? t('billing.noFactorsFilter')
: t('billing.noFactorsEmpty')}
</p>
{(search || selectedStatus !== 'all' || selectedCategory !== 'all') && (
<Button variant="outline" size="sm" onClick={handleResetFilters} className="text-xs rounded-xl">
{t('billing.clearFilters')}
</Button>
)}
</div>
) : (
<div className="overflow-x-auto relative hidden md:block">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent text-xs border-b border-border-soft bg-secondary/15 text-grey-2">
<TableHead className="w-16 text-center">{t('users.colId')}</TableHead>
<TableHead className="min-w-[220px]">{t('users.colUser')}</TableHead>
<TableHead className="w-40">{t('users.colPhone')}</TableHead>
<TableHead className="min-w-[200px]">{t('users.colEmail')}</TableHead>
<TableHead className="w-32">{t('users.colDateJoined')}</TableHead>
<TableHead className="w-32">{t('users.colLastLogin')}</TableHead>
<TableHead className="w-28 text-center">{t('billing.liveStatus')}</TableHead>
<TableHead className="text-center w-36">{t('common.actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading && users.length === 0 ? (
Array.from({ length: 6 }).map((_, i) => (
<TableRow key={i} className="animate-pulse">
<TableCell className="p-3.5 text-center"><div className="h-4 w-10 bg-muted/60 rounded-md mx-auto" /></TableCell>
<TableCell className="p-3.5">
<div className="flex items-center gap-2.5">
<div className="size-9 rounded-full bg-muted/60 shrink-0" />
<div className="space-y-1">
<div className="h-4 w-28 bg-muted/60 rounded-md" />
<div className="h-3 w-16 bg-muted/40 rounded-md" />
</div>
</div>
</TableCell>
<TableCell className="p-3.5"><div className="h-4 w-24 bg-muted/60 rounded-md" /></TableCell>
<TableCell className="p-3.5"><div className="h-4 w-32 bg-muted/60 rounded-md" /></TableCell>
<TableCell className="p-3.5"><div className="h-4 w-20 bg-muted/60 rounded-md" /></TableCell>
<TableCell className="p-3.5"><div className="h-4 w-20 bg-muted/60 rounded-md" /></TableCell>
<TableCell className="p-3.5 text-center"><div className="h-6 w-16 bg-muted/60 rounded-full mx-auto" /></TableCell>
<TableCell className="p-3.5 text-center"><div className="h-8 w-24 bg-muted/60 rounded-xl mx-auto" /></TableCell>
</TableRow>
))
) : (
users.map((user) => {
const emailData = cleanEmail(user.email)
const phoneText = cleanPhoneNumber(user.phone_number)
return (
<TableRow
key={user.id}
onClick={() => navigate(`/users/${user.id}`)}
className="hover:bg-muted/30 transition-colors cursor-pointer group"
>
{/* شناسه */}
<TableCell className="p-3.5 text-center font-mono font-bold text-muted-foreground text-xs">
#{user.id}
</TableCell>
{/* اطلاعات کاربر */}
<TableCell className="p-3.5">
<div className="flex items-center gap-3">
{user.avatar ? (
<img
src={user.avatar}
alt={user.fullname || 'User'}
className="size-9 rounded-full object-cover border border-border/60 shrink-0"
/>
) : (
<div className="size-9 rounded-full bg-primary/10 text-primary border border-primary/20 flex items-center justify-center text-xs font-black shrink-0">
{user.fullname && user.fullname.trim()
? user.fullname.slice(0, 2).toUpperCase()
: 'U'}
</div>
)}
<div className="space-y-1">
<div className="font-bold text-foreground group-hover:text-primary transition-colors text-xs sm:text-sm">
{user.fullname && user.fullname.trim()
? user.fullname
: t('users.unnamedUser')}
</div>
<div className="flex items-center gap-1.5 flex-wrap">
{user.is_staff && (
<span className="inline-flex px-2 py-0.5 rounded-md bg-primary/10 text-primary text-[11px] font-bold">
{t('users.admin')}
</span>
)}
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-muted/60 text-muted-foreground text-[11px] font-medium">
<Ic name="map" className="size-3 text-muted-foreground/80 shrink-0" />
<span>
<strong className="font-mono tabular-nums text-foreground/90 font-bold">{user.tour_orders_count}</strong>{' '}
{t('users.toursCount')}
</span>
</span>
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-muted/60 text-muted-foreground text-[11px] font-medium">
<Ic name="receipt" className="size-3 text-muted-foreground/80 shrink-0" />
<span>
<strong className="font-mono tabular-nums text-foreground/90 font-bold">{user.factors_count}</strong>{' '}
{t('users.invoicesCount')}
</span>
</span>
</div>
</div>
</div>
</TableCell>
{/* شماره موبایل */}
<TableCell className="p-3.5">
<span dir="ltr" className="font-mono text-xs inline-block text-start text-foreground/90 font-medium tabular-nums">
{phoneText}
</span>
</TableCell>
{/* ایمیل */}
<TableCell className="p-3.5">
<div className="flex items-center gap-1.5 max-w-[220px]">
<span
dir="ltr"
className="font-mono text-xs text-muted-foreground truncate block"
title={emailData.text}
>
{emailData.text}
</span>
{emailData.isDeleted && (
<span className="px-1.5 py-0.5 rounded-md bg-rose-500/10 text-rose-600 dark:text-rose-400 text-[10px] font-bold shrink-0">
{t('users.deletedAccount')}
</span>
)}
</div>
</TableCell>
{/* تاریخ عضویت */}
<TableCell className="p-3.5 text-xs text-muted-foreground whitespace-nowrap">
{formatDateByLanguage(user.date_joined, language as any)}
</TableCell>
{/* آخرین ورود */}
<TableCell className="p-3.5 text-xs text-muted-foreground whitespace-nowrap">
{formatDateByLanguage(user.last_login, language as any)}
</TableCell>
{/* وضعیت زنده (کپسولی مطابق BillingStatusBadge) */}
<TableCell className="p-3.5 text-center">
{user.is_active ? (
<span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-500/10 border border-emerald-500/20 px-2.5 py-1 text-xs font-bold text-emerald-600 dark:text-emerald-400 whitespace-nowrap">
<span className="size-1.5 rounded-full bg-emerald-500" />
<span>{t('users.active')}</span>
</span>
) : (
<span className="inline-flex items-center gap-1.5 rounded-full bg-rose-500/10 border border-rose-500/20 px-2.5 py-1 text-xs font-bold text-rose-600 dark:text-rose-400 whitespace-nowrap">
<span className="size-1.5 rounded-full bg-rose-500" />
<span>{t('users.inactive')}</span>
</span>
)}
</TableCell>
{/* دکمه عملیات (با آیکون چشم مشابه BillingPage) */}
<TableCell className="p-3.5 text-center" onClick={(e) => e.stopPropagation()}>
<Button
variant="default"
size="sm"
className="h-8 px-3 text-xs gap-1.5 rounded-lg font-bold shadow-2xs hover:brightness-105 active:scale-[0.98] transition-all shrink-0"
onClick={() => navigate(`/users/${user.id}`)}
>
<Ic name="eye" className="size-3.5" />
<span>{t('users.viewDetails')}</span>
</Button>
</TableCell>
</TableRow>
)
})
)}
</TableBody>
</Table>
</div>
)}
{/* نمای کارت‌های موبایل (زیر md) */}
<div className="flex flex-col gap-3 p-4 md:hidden">
{loading && users.length === 0 ? (
Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-32 rounded-2xl bg-muted/40 border border-border/40 animate-pulse" />
))
) : (
users.map((user) => {
const emailData = cleanEmail(user.email)
const phoneText = cleanPhoneNumber(user.phone_number)
return (
<div
key={user.id}
onClick={() => 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"
>
<div className="flex items-start justify-between gap-2 border-b border-border-soft pb-3">
<div className="flex items-center gap-2.5">
<div className="size-9 rounded-full bg-primary/10 text-primary border border-primary/20 flex items-center justify-center text-xs font-black shrink-0">
{user.fullname && user.fullname.trim() ? user.fullname.slice(0, 2).toUpperCase() : 'U'}
</div>
<div>
<div className="font-bold text-xs sm:text-sm text-foreground">
{user.fullname && user.fullname.trim() ? user.fullname : t('users.unnamedUser')}
</div>
<span className="font-mono text-xs text-muted-foreground">#{user.id}</span>
</div>
</div>
<div>
{user.is_active ? (
<span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-500/10 border border-emerald-500/20 px-2.5 py-0.5 text-xs font-bold text-emerald-600 dark:text-emerald-400">
<span className="size-1.5 rounded-full bg-emerald-500" />
<span>{t('users.active')}</span>
</span>
) : (
<span className="inline-flex items-center gap-1.5 rounded-full bg-rose-500/10 border border-rose-500/20 px-2.5 py-0.5 text-xs font-bold text-rose-600 dark:text-rose-400">
<span className="size-1.5 rounded-full bg-rose-500" />
<span>{t('users.inactive')}</span>
</span>
)}
</div>
</div>
<div className="space-y-1.5 text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground">{t('users.colPhone')}:</span>
<span dir="ltr" className="font-mono font-medium text-foreground/90 tabular-nums">{phoneText}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">{t('users.colEmail')}:</span>
<span dir="ltr" className="font-mono truncate max-w-[180px] text-muted-foreground">{emailData.text}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">{t('users.colDateJoined')}:</span>
<span className="text-muted-foreground">{formatDateByLanguage(user.date_joined, language as any)}</span>
</div>
</div>
<div className="pt-2 border-t border-border-soft flex items-center justify-between">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>
<strong className="font-mono tabular-nums text-foreground/90 font-bold">{user.tour_orders_count}</strong>{' '}
{t('users.toursCount')}
</span>
<span>•</span>
<span>
<strong className="font-mono tabular-nums text-foreground/90 font-bold">{user.factors_count}</strong>{' '}
{t('users.invoicesCount')}
</span>
</div>
<Button
variant="default"
size="sm"
className="h-7 px-3 text-xs gap-1 rounded-lg font-bold"
onClick={(e) => {
e.stopPropagation()
navigate(`/users/${user.id}`)
}}
>
<Ic name="eye" className="size-3" />
<span>{t('users.viewDetails')}</span>
</Button>
</div>
</div>
)
})
)}
</div>
{/* ۶. صفحه‌بندی هوشمند شماره‌دار با قابلیت کلیک (پاورقی کارت جدول مطابق با BillingPage) */}
<div className="p-3.5 border-t border-border-soft flex flex-col sm:flex-row items-center justify-between gap-3 text-xs bg-card/60">
<span className="text-grey-3 font-medium">
{t('common.showing')} {(currentPage - 1) * PAGE_SIZE + 1} {t('common.to')}{' '}
{Math.min(currentPage * PAGE_SIZE, totalCount)} {t('common.of')} {totalCount} {t('users.colUser')}
</span>
<div className="flex items-center gap-1.5 flex-wrap justify-center">
{/* دکمه قبلی */}
<button
type="button"
disabled={offset === 0 || loading}
onClick={handlePrevPage}
className="h-8 px-3 rounded-xl bg-card border border-border-soft text-xs font-bold hover:bg-muted/40 disabled:opacity-40 disabled:cursor-not-allowed transition-all shadow-2xs flex items-center gap-1 text-foreground"
>
<Ic name="arrowRight" className="size-3 rtl:rotate-0 rotate-180" />
<span>{t('users.previous')}</span>
</button>
{/* شماره صفحات هوشمند و قابل کلیک */}
{paginationItems.map((item, idx) => {
if (item === 'ellipsis') {
return (
<span
key={`ellipsis-${idx}`}
className="size-8 flex items-center justify-center font-mono text-muted-foreground font-bold"
>
…
</span>
)
}
const pageNum = item
const isActive = pageNum === currentPage
return (
<button
key={`page-${pageNum}`}
type="button"
onClick={() => setOffset((pageNum - 1) * PAGE_SIZE)}
disabled={loading}
className={`min-w-8 h-8 px-2.5 rounded-xl font-mono text-xs font-bold transition-all flex items-center justify-center ${
isActive
? 'bg-primary text-white shadow-xs border border-primary'
: 'bg-card border border-border-soft text-muted-foreground hover:text-foreground hover:bg-muted/40 shadow-2xs'
}`}
>
{pageNum}
</button>
)
})}
{/* دکمه بعدی */}
<button
type="button"
disabled={offset + PAGE_SIZE >= totalCount || loading}
onClick={handleNextPage}
className="h-8 px-3 rounded-xl bg-card border border-border-soft text-xs font-bold hover:bg-muted/40 disabled:opacity-40 disabled:cursor-not-allowed transition-all shadow-2xs flex items-center gap-1 text-foreground"
>
<span>{t('users.next')}</span>
<Ic name="arrowRight" className="size-3 rtl:rotate-180 rotate-0" />
</button>
</div>
</div>
</CardContent>
</Card>
</div>
)
}

297
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<ServiceFilter>('all')
const [loading, setLoading] = React.useState(true)
const [factors, setFactors] = React.useState<UserFactorItem[]>([])
const [receiptModalUrl, setReceiptModalUrl] = React.useState<string | null>(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 (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
{t('billing.statusApproved')}
</span>
)
case 'pending':
return (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-500/10 text-amber-600 dark:text-amber-400">
<span className="w-1.5 h-1.5 rounded-full bg-amber-500" />
{t('billing.statusPending')}
</span>
)
case 'awaiting_payment':
return (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400">
<span className="w-1.5 h-1.5 rounded-full bg-blue-500" />
{t('billing.statusAwaiting')}
</span>
)
case 'rejected':
return (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-rose-500/10 text-rose-600 dark:text-rose-400">
<span className="w-1.5 h-1.5 rounded-full bg-rose-500" />
{t('billing.statusRejected')}
</span>
)
case 'expired':
return (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-slate-500/10 text-slate-600 dark:text-slate-400">
<span className="w-1.5 h-1.5 rounded-full bg-slate-500" />
{t('billing.statusExpired')}
</span>
)
default:
return (
<span className="inline-flex px-2 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground">
{status}
</span>
)
}
}
return (
<div className="space-y-4 sm:space-y-6">
{/* ساب‌تب‌های کپسولی برای دسته‌بندی فاکتورها بر اساس نوع سرویس */}
<div className="flex flex-wrap items-center gap-2 p-1.5 rounded-2xl bg-muted/40 border border-border/40 w-fit">
<button
type="button"
onClick={() => setService('all')}
className={`px-3 py-1.5 rounded-xl text-xs sm:text-sm font-semibold transition-all ${
service === 'all'
? 'bg-card text-foreground shadow-xs border border-border/60'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{t('users.invoicesAll')}
</button>
<button
type="button"
onClick={() => setService('simcard')}
className={`px-3 py-1.5 rounded-xl text-xs sm:text-sm font-semibold transition-all ${
service === 'simcard'
? 'bg-card text-foreground shadow-xs border border-border/60'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{t('users.invoicesSimcard')}
</button>
<button
type="button"
onClick={() => setService('tasrif')}
className={`px-3 py-1.5 rounded-xl text-xs sm:text-sm font-semibold transition-all ${
service === 'tasrif'
? 'bg-card text-foreground shadow-xs border border-border/60'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{t('users.invoicesExchange')}
</button>
<button
type="button"
onClick={() => setService('shop')}
className={`px-3 py-1.5 rounded-xl text-xs sm:text-sm font-semibold transition-all ${
service === 'shop'
? 'bg-card text-foreground shadow-xs border border-border/60'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{t('users.invoicesShop')}
</button>
<button
type="button"
onClick={() => setService('tour')}
className={`px-3 py-1.5 rounded-xl text-xs sm:text-sm font-semibold transition-all ${
service === 'tour'
? 'bg-card text-foreground shadow-xs border border-border/60'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{t('users.invoicesTour')}
</button>
<button
type="button"
onClick={() => setService('custom_trip')}
className={`px-3 py-1.5 rounded-xl text-xs sm:text-sm font-semibold transition-all ${
service === 'custom_trip'
? 'bg-card text-foreground shadow-xs border border-border/60'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{t('users.invoicesOther')}
</button>
</div>
{/* لیست فاکتورها */}
{loading ? (
<div className="space-y-3">
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className="h-16 rounded-2xl bg-muted/40 border border-border/40 animate-pulse"
/>
))}
</div>
) : factors.length === 0 ? (
<div className="p-8 text-center bg-card border border-border/60 rounded-2xl">
<div className="mx-auto w-10 h-10 rounded-full bg-muted/50 flex items-center justify-center text-muted-foreground mb-3">
<Ic name="receipt" className="w-5 h-5" />
</div>
<p className="text-sm font-medium text-foreground mb-1">
{t('users.noInvoicesFound')}
</p>
<p className="text-xs text-muted-foreground">
{t('billing.noFactorsFilter')}
</p>
</div>
) : (
<div className="bg-card border border-border/60 rounded-2xl overflow-hidden shadow-xs">
<div className="overflow-x-auto">
<table className="w-full text-start text-xs sm:text-sm">
<thead>
<tr className="border-b border-border/60 text-muted-foreground bg-muted/20">
<th className="py-3 px-4 text-start font-medium">{t('users.invoiceId')}</th>
<th className="py-3 px-4 text-start font-medium">{t('users.serviceType')}</th>
<th className="py-3 px-4 text-start font-medium">{t('users.amount')}</th>
<th className="py-3 px-4 text-start font-medium">{t('users.createdDate')}</th>
<th className="py-3 px-4 text-start font-medium">{t('users.status')}</th>
<th className="py-3 px-4 text-center font-medium">{t('users.receipt')}</th>
</tr>
</thead>
<tbody className="divide-y divide-border/30">
{factors.map((f) => (
<tr key={f.id} className="hover:bg-muted/30 transition-colors">
<td className="py-3 px-4 font-mono font-semibold text-foreground">
#{f.id}
</td>
<td className="py-3 px-4">
<span className="inline-flex px-2 py-0.5 rounded-full text-xs font-medium bg-muted text-foreground">
{getServiceLabel(f.service)}
</span>
</td>
<td className="py-3 px-4 font-mono font-bold text-foreground">
${Number(f.amount || 0).toLocaleString()}
</td>
<td className="py-3 px-4 font-mono text-muted-foreground">
{formatDate(f.created_at)}
</td>
<td className="py-3 px-4">{getStatusBadge(f.status)}</td>
<td className="py-3 px-4 text-center">
{f.transaction_receipt ? (
<button
type="button"
onClick={() => setReceiptModalUrl(f.transaction_receipt)}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-xl text-xs font-semibold bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
>
<Ic name="image" className="w-3.5 h-3.5" />
<span>{t('billing.viewReceipt')}</span>
</button>
) : (
<span className="text-xs text-muted-foreground">—</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* مدال مشاهده فیش پرداخت */}
{receiptModalUrl && (
<div
className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4 backdrop-blur-xs"
onClick={() => setReceiptModalUrl(null)}
>
<div
className="bg-card border border-border/80 rounded-2xl max-w-lg w-full p-4 overflow-hidden relative shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between pb-3 mb-3 border-b border-border/50">
<h3 className="text-sm font-bold text-foreground">
{t('billing.receiptModalTitle')}
</h3>
<button
type="button"
onClick={() => setReceiptModalUrl(null)}
className="p-1 rounded-lg hover:bg-muted text-muted-foreground hover:text-foreground"
>
<Ic name="x" className="w-4 h-4" />
</button>
</div>
<div className="max-h-[70vh] overflow-auto rounded-xl bg-black/5 flex items-center justify-center p-2">
<img
src={receiptModalUrl}
alt="Receipt"
className="max-h-full max-w-full object-contain rounded-lg"
/>
</div>
</div>
</div>
)}
</div>
)
}

216
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 (
<div className="space-y-6">
{/* ۱. کارت اطلاعات پایه حساب کاربری */}
<div className="bg-card border border-border/60 rounded-2xl p-5 sm:p-6 shadow-xs">
<div className="flex items-center gap-2 mb-5 pb-3 border-b border-border/40">
<span className="p-2 rounded-xl bg-primary/10 text-primary">
<Ic name="user" className="w-5 h-5" />
</span>
<div>
<h2 className="text-base font-bold text-foreground">
{t('users.profileInfo')}
</h2>
<p className="text-xs text-muted-foreground">
{t('users.subtitle')}
</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
{/* نام و نام خانوادگی */}
<div className="p-3.5 rounded-xl bg-muted/40 border border-border/30">
<span className="block text-xs font-medium text-muted-foreground mb-1">
{t('users.fullname')}
</span>
<span className="text-sm font-semibold text-foreground">
{user.fullname || t('users.noData')}
</span>
</div>
{/* شماره تلفن همراه */}
<div className="p-3.5 rounded-xl bg-muted/40 border border-border/30">
<span className="block text-xs font-medium text-muted-foreground mb-1">
{t('users.phone')}
</span>
<span className="text-sm font-mono font-medium text-foreground dir-ltr text-end block">
{user.phone_number ? user.phone_number.split(':deleted')[0].trim() : t('users.noData')}
</span>
</div>
{/* ایمیل حساب */}
<div className="p-3.5 rounded-xl bg-muted/40 border border-border/30">
<div className="flex items-center justify-between mb-1">
<span className="block text-xs font-medium text-muted-foreground">
{t('users.email')}
</span>
{user.email && user.email.includes(':deleted') && (
<span className="px-1.5 py-0.2 rounded-md bg-rose-500/10 text-rose-600 dark:text-rose-400 text-[10px] font-bold">
{t('users.deletedAccount')}
</span>
)}
</div>
<span className="text-sm font-mono font-medium text-foreground dir-ltr text-end truncate block">
{user.email ? user.email.split(':deleted')[0].trim() : t('users.noData')}
</span>
</div>
{/* سطح دسترسی */}
<div className="p-3.5 rounded-xl bg-muted/40 border border-border/30">
<span className="block text-xs font-medium text-muted-foreground mb-1">
{t('users.role')}
</span>
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary">
<Ic name="shield" className="w-3.5 h-3.5" />
{user.is_staff || user.is_superuser ? t('users.admin') : t('users.regularUser')}
</span>
</div>
{/* وضعیت اکانت */}
<div className="p-3.5 rounded-xl bg-muted/40 border border-border/30">
<span className="block text-xs font-medium text-muted-foreground mb-1">
{t('users.colStatus')}
</span>
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium ${
user.is_active
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'bg-rose-500/10 text-rose-600 dark:text-rose-400'
}`}
>
<span
className={`w-1.5 h-1.5 rounded-full ${
user.is_active ? 'bg-emerald-500' : 'bg-rose-500'
}`}
/>
{user.is_active ? t('users.active') : t('users.inactive')}
</span>
</div>
{/* زبان انتخابی کاربر */}
<div className="p-3.5 rounded-xl bg-muted/40 border border-border/30">
<span className="block text-xs font-medium text-muted-foreground mb-1">
{t('users.language')}
</span>
<span className="text-sm font-medium text-foreground uppercase">
{user.language || 'fa'}
</span>
</div>
{/* تاریخ ثبت‌نام */}
<div className="p-3.5 rounded-xl bg-muted/40 border border-border/30">
<span className="block text-xs font-medium text-muted-foreground mb-1">
{t('users.colDateJoined')}
</span>
<span className="text-sm font-mono text-foreground">
{formatDate(user.date_joined)}
</span>
</div>
{/* آخرین ورود به سیستم */}
<div className="p-3.5 rounded-xl bg-muted/40 border border-border/30">
<span className="block text-xs font-medium text-muted-foreground mb-1">
{t('users.colLastLogin')}
</span>
<span className="text-sm font-mono text-foreground">
{formatDate(user.last_login)}
</span>
</div>
{/* شناسه دستگاه */}
<div className="p-3.5 rounded-xl bg-muted/40 border border-border/30">
<span className="block text-xs font-medium text-muted-foreground mb-1">
{t('users.deviceId')}
</span>
<span className="text-xs font-mono text-muted-foreground truncate block">
{user.device_id || t('users.noData')}
</span>
</div>
</div>
</div>
{/* ۲. بخش مسافران و همراهان ثبت‌شده */}
<div className="bg-card border border-border/60 rounded-2xl p-5 sm:p-6 shadow-xs">
<div className="flex items-center justify-between mb-4 pb-3 border-b border-border/40">
<div className="flex items-center gap-2">
<span className="p-2 rounded-xl bg-amber-500/10 text-amber-600 dark:text-amber-400">
<Ic name="users" className="w-5 h-5" />
</span>
<div>
<h3 className="text-sm sm:text-base font-bold text-foreground">
{t('users.passengersTitle')}
</h3>
<p className="text-xs text-muted-foreground">
{user.passengers.length} {t('users.passengersTitle')}
</p>
</div>
</div>
</div>
{user.passengers.length === 0 ? (
<div className="py-8 text-center text-xs text-muted-foreground">
{t('users.noPassengers')}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-start text-xs sm:text-sm">
<thead>
<tr className="border-b border-border/60 text-muted-foreground">
<th className="py-2.5 px-3 text-start font-medium">#</th>
<th className="py-2.5 px-3 text-start font-medium">{t('users.fullname')}</th>
<th className="py-2.5 px-3 text-start font-medium">{t('users.passportNumber')}</th>
<th className="py-2.5 px-3 text-start font-medium">{t('users.birthdate')}</th>
<th className="py-2.5 px-3 text-start font-medium">{t('users.phone')}</th>
<th className="py-2.5 px-3 text-start font-medium">{t('users.ageCategory')}</th>
</tr>
</thead>
<tbody className="divide-y divide-border/30">
{user.passengers.map((p, idx) => (
<tr key={p.id} className="hover:bg-muted/30 transition-colors">
<td className="py-3 px-3 font-mono text-muted-foreground">{idx + 1}</td>
<td className="py-3 px-3 font-semibold text-foreground">{p.fullname || t('users.noData')}</td>
<td className="py-3 px-3 font-mono text-foreground">{p.passport_number || '—'}</td>
<td className="py-3 px-3 font-mono text-muted-foreground">{p.birthdate || '—'}</td>
<td className="py-3 px-3 font-mono dir-ltr text-end">{p.phone_number || '—'}</td>
<td className="py-3 px-3">
<span className="inline-flex px-2 py-0.5 rounded-full text-xs font-medium bg-muted text-foreground">
{p.age_category}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)
}

236
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<TimelineFilter>('all')
const [loading, setLoading] = React.useState(true)
const [tours, setTours] = React.useState<UserTourItem[]>([])
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 (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
{t('billing.statusApproved')}
</span>
)
case 'PENDING':
return (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-500/10 text-amber-600 dark:text-amber-400">
<span className="w-1.5 h-1.5 rounded-full bg-amber-500" />
{t('billing.statusPending')}
</span>
)
case 'AWAITING_PAYMENT':
return (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400">
<span className="w-1.5 h-1.5 rounded-full bg-blue-500" />
{t('billing.statusAwaiting')}
</span>
)
case 'REJECTED':
return (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-rose-500/10 text-rose-600 dark:text-rose-400">
<span className="w-1.5 h-1.5 rounded-full bg-rose-500" />
{t('billing.statusRejected')}
</span>
)
case 'EXPIRED':
return (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-slate-500/10 text-slate-600 dark:text-slate-400">
<span className="w-1.5 h-1.5 rounded-full bg-slate-500" />
{t('billing.statusExpired')}
</span>
)
default:
return (
<span className="inline-flex px-2 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground">
{status}
</span>
)
}
}
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 (
<span className={`inline-flex px-2 py-0.5 rounded-full text-[11px] ${colorClass}`}>
{tripStatus}
</span>
)
}
return (
<div className="space-y-4 sm:space-y-6">
{/* ساب‌تب‌های کپسولی برای فیلتر وضعیت‌های زمانی تور */}
<div className="flex flex-wrap items-center gap-2 p-1.5 rounded-2xl bg-muted/40 border border-border/40 w-fit">
<button
type="button"
onClick={() => setTimeline('all')}
className={`px-3 py-1.5 rounded-xl text-xs sm:text-sm font-semibold transition-all ${
timeline === 'all'
? 'bg-card text-foreground shadow-xs border border-border/60'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{t('users.toursAll')}
</button>
<button
type="button"
onClick={() => setTimeline('active')}
className={`px-3 py-1.5 rounded-xl text-xs sm:text-sm font-semibold transition-all ${
timeline === 'active'
? 'bg-card text-primary shadow-xs border border-border/60'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{t('users.toursActive')}
</button>
<button
type="button"
onClick={() => setTimeline('upcoming')}
className={`px-3 py-1.5 rounded-xl text-xs sm:text-sm font-semibold transition-all ${
timeline === 'upcoming'
? 'bg-card text-foreground shadow-xs border border-border/60'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{t('users.toursUpcoming')}
</button>
<button
type="button"
onClick={() => setTimeline('past')}
className={`px-3 py-1.5 rounded-xl text-xs sm:text-sm font-semibold transition-all ${
timeline === 'past'
? 'bg-card text-foreground shadow-xs border border-border/60'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{t('users.toursPast')}
</button>
</div>
{/* محتوای تورها */}
{loading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div
key={i}
className="h-24 rounded-2xl bg-muted/40 border border-border/40 animate-pulse"
/>
))}
</div>
) : tours.length === 0 ? (
<div className="p-8 text-center bg-card border border-border/60 rounded-2xl">
<div className="mx-auto w-10 h-10 rounded-full bg-muted/50 flex items-center justify-center text-muted-foreground mb-3">
<Ic name="map" className="w-5 h-5" />
</div>
<p className="text-sm font-medium text-foreground mb-1">
{t('users.noToursFound')}
</p>
<p className="text-xs text-muted-foreground">
{t('billing.noFactorsFilter')}
</p>
</div>
) : (
<div className="grid grid-cols-1 gap-3 sm:gap-4">
{tours.map((order) => (
<div
key={order.id}
className="bg-card border border-border/60 rounded-2xl p-4 sm:p-5 shadow-xs hover:border-border transition-all flex flex-col md:flex-row md:items-center justify-between gap-4"
>
{/* سمت راست: عنوان و جزئیات */}
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<h4 className="text-sm sm:text-base font-bold text-foreground">
{order.tour_title || `سفارش #${order.id}`}
</h4>
{getOrderStatusBadge(order.status)}
{getTripStatusBadge(order.tour_trip_status)}
</div>
<div className="flex flex-wrap items-center gap-3 sm:gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1 font-mono">
<Ic name="calendar" className="w-3.5 h-3.5 text-muted-foreground/70" />
<span>
{formatDate(order.tour_started_at)} الی {formatDate(order.tour_ended_at)}
</span>
</div>
<div className="flex items-center gap-1 font-mono">
<span>ثبت:</span>
<span>{formatDate(order.created?.slice(0, 10))}</span>
</div>
</div>
</div>
{/* سمت چپ: قیمت و وضعیت مالی */}
<div className="flex items-center justify-between md:flex-col md:items-end gap-2 border-t md:border-t-0 pt-3 md:pt-0 border-border/40">
<div className="text-end">
<span className="block text-[11px] text-muted-foreground">
{t('users.tourPrice')}
</span>
<span className="text-base sm:text-lg font-mono font-bold text-foreground">
${Number(order.total_price || 0).toLocaleString()}
</span>
</div>
<div>
<span
className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${
order.is_paid
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'bg-amber-500/10 text-amber-600 dark:text-amber-400'
}`}
>
{order.is_paid ? 'پرداخت شده' : 'پرداخت نشده'}
</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
)
}

93
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<DrfPaginatedResponse<UserListItem>> {
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<DrfPaginatedResponse<UserListItem>>(path)
}
/**
* دریافت آمارهای کلان کاربران برای کارت‌های شاخص عملکرد (KPI)
*/
export async function fetchUserStats(): Promise<import('../types').UserGlobalStats> {
return apiFetch<import('../types').UserGlobalStats>('account/admin/users/stats/')
}
/**
* دریافت اطلاعات تفصیلی پرونده کاربر شامل همراهان و آمار
*/
export async function fetchUserDetail(userId: number): Promise<UserDetail> {
return apiFetch<UserDetail>(`account/admin/users/${userId}/`)
}
/**
* دریافت سفارشات و تورهای کاربر با پشتیبانی از فیلتر وضعیت زمانی (فعال، آینده، گذشته)
*/
export async function fetchUserTours(
userId: number,
timeline?: 'all' | 'active' | 'upcoming' | 'past'
): Promise<DrfPaginatedResponse<UserTourItem>> {
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<DrfPaginatedResponse<UserTourItem>>(path)
}
/**
* دریافت فاکتورهای مالی کاربر با تفکیک بر اساس نوع سرویس
*/
export async function fetchUserFactors(
userId: number,
service?: string
): Promise<DrfPaginatedResponse<UserFactorItem>> {
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<DrfPaginatedResponse<UserFactorItem>>(path)
}

120
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<T> {
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
}

3
src/icons.tsx

@ -99,6 +99,9 @@ const PATHS: Record<string, string> = {
save: '<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/>',
bookmark: '<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z"/>',
mapPin: '<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/>',
key: '<path d="m21 2-2 2m-1.5 1.5L14 9l-1.5-1.5L11 9l-1.5-1.5L8 9c-3.3 0-6 2.7-6 6s2.7 6 6 6 6-2.7 6-6l-1.5-1.5L14 12l1.5-1.5L17 12l1.5-1.5L20 12l2-2V2z"/><circle cx="7.5" cy="15.5" r="1.5"/>',
lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
copy: '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
}
export type IconName = keyof typeof PATHS

17
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;

8
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',

22
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<string, string> = {
'۰': '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ِ خامِ بک‌اند.

172
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": "العربية",

170
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": "العربية",

176
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": "العربية",

Loading…
Cancel
Save