2 Commits
f1180bf49c
...
333d9dfefd
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
333d9dfefd |
Merge branch 'master' of https://git.nwhco.ir/ghorbani/aqila_panel
|
1 week ago |
|
|
1eb794c6eb |
feat: add admin panel modules for tours, users, settings, billing, and services
|
1 week ago |
42 changed files with 5136 additions and 324 deletions
-
6src/app/router.tsx
-
2src/components/ui/dialog.tsx
-
1src/config/navigation.ts
-
8src/features/billing/components/BillingDetailModal.tsx
-
4src/features/billing/components/ReceiptLightboxDialog.tsx
-
740src/features/services/ServicesPage.tsx
-
104src/features/services/components/DuasServiceView.tsx
-
50src/features/services/components/FaqServiceView.tsx
-
2src/features/services/components/LocationDetailSheet.tsx
-
97src/features/services/components/SimcardFormDialog.tsx
-
69src/features/services/components/SimcardServiceView.tsx
-
84src/features/services/components/StoreServiceView.tsx
-
82src/features/services/components/ZayerGuideServiceView.tsx
-
87src/features/settings/SettingsPage.tsx
-
273src/features/settings/components/AdminManagementTab.tsx
-
103src/features/settings/components/AdminStatsCards.tsx
-
321src/features/settings/components/AdminUserTable.tsx
-
283src/features/settings/components/CreateAdminModal.tsx
-
44src/features/settings/components/NotificationsTab.tsx
-
281src/features/settings/components/SetPasswordModal.tsx
-
108src/features/settings/services/settings-api.ts
-
36src/features/settings/types.ts
-
6src/features/tours/OngoingToursPage.tsx
-
16src/features/tours/ToursPage.tsx
-
26src/features/tours/components/CreateTourDialog.tsx
-
20src/features/tours/components/TourDetailDialog.tsx
-
14src/features/tours/components/TourDetailSheet.tsx
-
2src/features/tours/components/TourTranslationsDialog.tsx
-
239src/features/users/UserDetailPage.tsx
-
822src/features/users/UsersPage.tsx
-
297src/features/users/components/UserFactorsTab.tsx
-
216src/features/users/components/UserProfileTab.tsx
-
236src/features/users/components/UserToursTab.tsx
-
93src/features/users/services/users-api.ts
-
120src/features/users/types.ts
-
3src/icons.tsx
-
17src/index.css
-
8src/lib/date-utils.ts
-
22src/lib/utils.ts
-
172src/locales/ar.json
-
170src/locales/en.json
-
176src/locales/fa.json
@ -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> |
||||
|
) |
||||
|
} |
||||
@ -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> |
||||
|
) |
||||
|
} |
||||
@ -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> |
||||
|
) |
||||
|
} |
||||
@ -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> |
||||
|
) |
||||
|
} |
||||
@ -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="admin@example.com" |
||||
|
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> |
||||
|
) |
||||
|
} |
||||
@ -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> |
||||
|
) |
||||
|
} |
||||
@ -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> |
||||
|
) |
||||
|
} |
||||
@ -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, |
||||
|
}) |
||||
|
} |
||||
@ -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 |
||||
|
} |
||||
@ -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> |
||||
|
) |
||||
|
} |
||||
@ -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> |
||||
|
) |
||||
|
} |
||||
@ -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> |
||||
|
) |
||||
|
} |
||||
@ -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> |
||||
|
) |
||||
|
} |
||||
@ -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> |
||||
|
) |
||||
|
} |
||||
@ -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) |
||||
|
} |
||||
@ -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 |
||||
|
} |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue