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