Browse Source
feat: implement core layout, authentication, navigation, and global search components
master
feat: implement core layout, authentication, navigation, and global search components
master
20 changed files with 201 additions and 2460 deletions
-
12src/app/router.tsx
-
6src/components/layout/AppSidebar.tsx
-
41src/components/layout/PanelSlot.tsx
-
12src/config/navigation.ts
-
4src/features/auth/LoginPage.tsx
-
332src/features/dashboard/DashboardHome.tsx
-
86src/features/filters/FilterDrawer.tsx
-
18src/features/global-search/GlobalSearchDialog.tsx
-
217src/features/items/ItemsPage.tsx
-
235src/features/items/SampleItemModal.tsx
-
113src/features/panels/SampleDetailPanel.tsx
-
125src/features/panels/UserDetailPanel.tsx
-
121src/features/settings/SettingsPage.tsx
-
544src/features/showcase/ComponentsShowcase.tsx
-
224src/features/users/UserModal.tsx
-
230src/features/users/UsersPage.tsx
-
7src/services/auth.ts
-
154src/services/sample-api.ts
-
120src/services/users-api.ts
-
34src/types.ts
@ -1,208 +1,200 @@ |
|||||
|
import { useEffect, useState } from 'react' |
||||
import { Ic } from '@/icons' |
import { Ic } from '@/icons' |
||||
import { Button } from '@/components/ui/button' |
import { Button } from '@/components/ui/button' |
||||
import { Badge } from '@/components/ui/badge' |
import { Badge } from '@/components/ui/badge' |
||||
import { usePanelRouting } from '@/hooks/use-panel-routing' |
|
||||
import { useNavigate } from 'react-router-dom' |
|
||||
|
import { useAuthStore } from '@/stores/auth' |
||||
|
import { apiFetch } from '@/services/http' |
||||
|
|
||||
|
interface BackendHealth { |
||||
|
status: 'checking' | 'connected' | 'error' |
||||
|
message?: string |
||||
|
latencyMs?: number |
||||
|
} |
||||
|
|
||||
export default function DashboardHome() { |
export default function DashboardHome() { |
||||
const { openModal } = usePanelRouting() |
|
||||
const navigate = useNavigate() |
|
||||
|
const user = useAuthStore((s) => s.user) |
||||
|
const [health, setHealth] = useState<BackendHealth>({ status: 'checking' }) |
||||
|
|
||||
const stats = [ |
|
||||
{ |
|
||||
title: 'کل کاربران', |
|
||||
value: '۱,۴۸۲', |
|
||||
change: '+۱۲٪', |
|
||||
trend: 'up', |
|
||||
desc: 'نسبت به ماه گذشته', |
|
||||
icon: 'users' as const, |
|
||||
}, |
|
||||
{ |
|
||||
title: 'تراکنشهای موفق', |
|
||||
value: '۸,۲۴۰,۰۰۰ تومان', |
|
||||
change: '+۲۳.۵٪', |
|
||||
trend: 'up', |
|
||||
desc: 'فروش خالص این ماه', |
|
||||
icon: 'sparkles' as const, |
|
||||
}, |
|
||||
{ |
|
||||
title: 'آیتمهای فعال', |
|
||||
value: '۱۵۴', |
|
||||
change: '+۵ مورد', |
|
||||
trend: 'up', |
|
||||
desc: 'در حال ارائه و سرویس', |
|
||||
icon: 'grid' as const, |
|
||||
}, |
|
||||
{ |
|
||||
title: 'وضعیت سرورها', |
|
||||
value: '۹۹.۹٪', |
|
||||
change: 'پایدار', |
|
||||
trend: 'neutral', |
|
||||
desc: 'پاسخگویی به موقع', |
|
||||
icon: 'globe' as const, |
|
||||
}, |
|
||||
] |
|
||||
|
const checkConnection = async (isSilent = false) => { |
||||
|
if (!isSilent) setHealth((prev) => ({ ...prev, status: 'checking' })) |
||||
|
const start = performance.now() |
||||
|
try { |
||||
|
await apiFetch('about-us/', { skipAuth: true }) |
||||
|
const latency = Math.round(performance.now() - start) |
||||
|
setHealth({ |
||||
|
status: 'connected', |
||||
|
latencyMs: latency, |
||||
|
message: 'ارتباط مستقیم با بکاند جنگو (aqila_backend) برقرار است', |
||||
|
}) |
||||
|
} catch { |
||||
|
setHealth({ |
||||
|
status: 'error', |
||||
|
message: 'عدم دریافت پاسخ از سرور (بررسی کنید که سرور جنگو فعال باشد)', |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
|
||||
const recentActivities = [ |
|
||||
{ |
|
||||
id: '1', |
|
||||
user: 'علیرضا محمدی', |
|
||||
action: 'ورود به پنل مدیریت', |
|
||||
date: '۱۰ دقیقه پیش', |
|
||||
status: 'success', |
|
||||
}, |
|
||||
{ |
|
||||
id: '2', |
|
||||
user: 'سارا رضایی', |
|
||||
action: 'ویرایش آیتم "درگاه پرداخت سپهر"', |
|
||||
date: '۴۵ دقیقه پیش', |
|
||||
status: 'info', |
|
||||
}, |
|
||||
{ |
|
||||
id: '3', |
|
||||
user: 'محمد حسینی', |
|
||||
action: 'ثبت کاربر جدید در سیستم', |
|
||||
date: '۲ ساعت پیش', |
|
||||
status: 'success', |
|
||||
}, |
|
||||
{ |
|
||||
id: '4', |
|
||||
user: 'مریم کمالی', |
|
||||
action: 'خروج از حساب کاربری', |
|
||||
date: 'دیروز ۱۸:۳۰', |
|
||||
status: 'neutral', |
|
||||
}, |
|
||||
] |
|
||||
|
useEffect(() => { |
||||
|
checkConnection(false) |
||||
|
// بررسی خودکار سلامت ارتباط هر ۱۰ ثانیه یکبار
|
||||
|
const timer = setInterval(() => { |
||||
|
checkConnection(true) |
||||
|
}, 10000) |
||||
|
return () => clearInterval(timer) |
||||
|
}, []) |
||||
|
|
||||
return ( |
return ( |
||||
<div className="space-y-6 pt-2"> |
<div className="space-y-6 pt-2"> |
||||
{/* هدر صفحهٔ خوشآمدگویی */} |
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> |
|
||||
<div> |
|
||||
<h1 className="text-2xl font-black text-foreground">داشبورد مدیریت</h1> |
|
||||
<p className="mt-1 text-xs text-grey-3"> |
|
||||
به پنل مدیریت خوش آمدید. خلاصه وضعیت سیستم و گزارشهای سریع را در این صفحه مشاهده نمایید. |
|
||||
</p> |
|
||||
</div> |
|
||||
|
{/* سربرگ خوشآمدگویی */} |
||||
|
<div className="flex flex-col gap-3 rounded-2xl border border-border-soft bg-card/40 p-6 shadow-sm sm:flex-row sm:items-center sm:justify-between"> |
||||
|
<div className="space-y-1"> |
||||
<div className="flex items-center gap-2"> |
<div className="flex items-center gap-2"> |
||||
<Button onClick={() => openModal('user', 'new')} className="gap-2"> |
|
||||
<Ic name="plus" className="size-4" /> |
|
||||
کاربر جدید |
|
||||
</Button> |
|
||||
<Button variant="outline" onClick={() => openModal('item', 'new')} className="gap-2"> |
|
||||
<Ic name="plus" className="size-4" /> |
|
||||
آیتم جدید |
|
||||
</Button> |
|
||||
|
<h1 className="text-xl font-black text-foreground"> |
||||
|
خوش آمدید، {user?.name || 'مدیر سیستم'} |
||||
|
</h1> |
||||
|
<Badge variant="outline" className="border-primary/30 bg-primary/10 text-primary"> |
||||
|
آماده توسعه |
||||
|
</Badge> |
||||
</div> |
</div> |
||||
|
<p className="text-xs text-grey-3"> |
||||
|
پنل مدیریت عقیله پاکسازی شد و آماده اضافه کردن ویجتها، جداول و ماژولهای جدید شماست. |
||||
|
</p> |
||||
</div> |
</div> |
||||
|
|
||||
{/* کارتهای آماری بالا */} |
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4"> |
|
||||
{stats.map((stat) => ( |
|
||||
|
{/* نشانگر وضعیت خودکار رنگی (سبز / قرمز) */} |
||||
|
<div className="flex items-center"> |
||||
<div |
<div |
||||
key={stat.title} |
|
||||
className="rounded-2xl border border-border-soft bg-card/60 p-5 transition-all duration-200 hover:border-primary/30" |
|
||||
|
className={`inline-flex items-center gap-2.5 rounded-full border px-3.5 py-1.5 text-xs font-medium transition-colors ${ |
||||
|
health.status === 'connected' |
||||
|
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400' |
||||
|
: health.status === 'checking' |
||||
|
? 'border-amber-500/30 bg-amber-500/10 text-amber-400' |
||||
|
: 'border-rose-500/30 bg-rose-500/10 text-rose-400' |
||||
|
}`}
|
||||
> |
> |
||||
<div className="flex items-center justify-between"> |
|
||||
<span className="text-xs font-semibold text-grey-3">{stat.title}</span> |
|
||||
<div className="flex size-9 items-center justify-center rounded-xl bg-primary/10 text-primary"> |
|
||||
<Ic name={stat.icon} className="size-5" /> |
|
||||
</div> |
|
||||
</div> |
|
||||
<div className="mt-3 text-2xl font-black text-foreground">{stat.value}</div> |
|
||||
<div className="mt-2 flex items-center gap-2 text-[11.5px]"> |
|
||||
<span className="font-semibold text-emerald-400">{stat.change}</span> |
|
||||
<span className="text-grey-4">{stat.desc}</span> |
|
||||
|
<span className="relative flex size-2.5"> |
||||
|
{health.status === 'connected' && ( |
||||
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" /> |
||||
|
)} |
||||
|
{health.status === 'error' && ( |
||||
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-rose-400 opacity-75" /> |
||||
|
)} |
||||
|
<span |
||||
|
className={`relative inline-flex size-2.5 rounded-full ${ |
||||
|
health.status === 'connected' |
||||
|
? 'bg-emerald-500 shadow-[0_0_8px_#10b981]' |
||||
|
: health.status === 'checking' |
||||
|
? 'bg-amber-400 animate-pulse' |
||||
|
: 'bg-rose-500 shadow-[0_0_8px_#f43f5e]' |
||||
|
}`}
|
||||
|
/> |
||||
|
</span> |
||||
|
<span> |
||||
|
{health.status === 'connected' |
||||
|
? 'ارتباط آنلاین با سرور' |
||||
|
: health.status === 'checking' |
||||
|
? 'در حال بررسی...' |
||||
|
: 'قطع ارتباط'} |
||||
|
</span> |
||||
|
{health.latencyMs !== undefined && health.status === 'connected' && ( |
||||
|
<span className="font-mono text-[11px] opacity-75" dir="ltr"> |
||||
|
({health.latencyMs}ms) |
||||
|
</span> |
||||
|
)} |
||||
</div> |
</div> |
||||
</div> |
</div> |
||||
))} |
|
||||
</div> |
</div> |
||||
|
|
||||
{/* بخش پایینی: جدول فعالیتهای اخیر + کارتهای دسترسی سریع */} |
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3"> |
|
||||
{/* جدول فعالیتهای اخیر */} |
|
||||
<div className="rounded-2xl border border-border-soft bg-card/50 p-5 lg:col-span-2"> |
|
||||
<div className="flex items-center justify-between pb-4 border-b border-border-soft"> |
|
||||
<h2 className="text-sm font-bold text-foreground">آخرین رویدادهای سیستم</h2> |
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate('/users')} className="text-xs text-grey-3"> |
|
||||
مشاهده همه |
|
||||
</Button> |
|
||||
|
{/* کارتهای وضعیت سیستم */} |
||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> |
||||
|
{/* کارت وضعیت بکاند جنگو */} |
||||
|
<div className="rounded-xl border border-border-soft bg-card/60 p-5 shadow-sm"> |
||||
|
<div className="flex items-center justify-between"> |
||||
|
<span className="text-xs font-medium text-grey-3">وضعیت سرور جنگو</span> |
||||
|
<div |
||||
|
className={`size-2.5 rounded-full ${ |
||||
|
health.status === 'connected' |
||||
|
? 'bg-emerald-500 shadow-[0_0_8px_#10b981]' |
||||
|
: health.status === 'checking' |
||||
|
? 'bg-amber-400 animate-pulse' |
||||
|
: 'bg-rose-500 shadow-[0_0_8px_#f43f5e]' |
||||
|
}`}
|
||||
|
/> |
||||
|
</div> |
||||
|
<div className="mt-3 flex items-baseline gap-2"> |
||||
|
<span className="text-base font-bold text-foreground"> |
||||
|
{health.status === 'connected' |
||||
|
? 'متصل' |
||||
|
: health.status === 'checking' |
||||
|
? 'در حال بررسی...' |
||||
|
: 'قطع ارتباط'} |
||||
|
</span> |
||||
|
{health.latencyMs !== undefined && ( |
||||
|
<span className="text-[11px] text-grey-3 font-mono" dir="ltr"> |
||||
|
({health.latencyMs} ms) |
||||
|
</span> |
||||
|
)} |
||||
|
</div> |
||||
|
<p className="mt-2 text-[11px] text-grey-3">{health.message}</p> |
||||
</div> |
</div> |
||||
|
|
||||
<div className="divide-y divide-border-soft"> |
|
||||
{recentActivities.map((act) => ( |
|
||||
<div key={act.id} className="flex items-center justify-between py-3.5"> |
|
||||
<div className="flex items-center gap-3"> |
|
||||
<div className="flex size-8 items-center justify-center rounded-full bg-white/6 text-xs font-bold text-grey-2"> |
|
||||
{act.user.charAt(0)} |
|
||||
</div> |
|
||||
<div> |
|
||||
<div className="text-xs font-semibold text-foreground">{act.user}</div> |
|
||||
<div className="text-[11px] text-grey-3 mt-0.5">{act.action}</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
<div className="flex items-center gap-3"> |
|
||||
<span className="text-[11px] text-grey-4">{act.date}</span> |
|
||||
<Badge variant="outline" className="text-[10px] text-emerald-400 border-emerald-500/20 bg-emerald-500/10"> |
|
||||
موفق |
|
||||
</Badge> |
|
||||
|
{/* کارت مشخصات اتصال بکاند */} |
||||
|
<div className="rounded-xl border border-border-soft bg-card/60 p-5 shadow-sm"> |
||||
|
<div className="flex items-center justify-between"> |
||||
|
<span className="text-xs font-medium text-grey-3">پیکربندی API و سرور</span> |
||||
|
<Ic name="code" className="size-4 text-grey-3" /> |
||||
</div> |
</div> |
||||
|
<div className="mt-3 space-y-1.5 font-mono text-[11px]" dir="ltr"> |
||||
|
<div className="flex justify-between text-grey-2"> |
||||
|
<span className="text-grey-3">Target Server:</span> |
||||
|
<span>http://127.0.0.1:8000</span>
|
||||
</div> |
</div> |
||||
))} |
|
||||
|
<div className="flex justify-between text-grey-2"> |
||||
|
<span className="text-grey-3">API Prefix:</span> |
||||
|
<span>/api</span> |
||||
</div> |
</div> |
||||
|
<div className="flex justify-between text-grey-2"> |
||||
|
<span className="text-grey-3">Auth Header:</span> |
||||
|
<span>Token (DRF)</span> |
||||
</div> |
</div> |
||||
|
|
||||
{/* دسترسیهای سریع */} |
|
||||
<div className="space-y-4 rounded-2xl border border-border-soft bg-card/50 p-5"> |
|
||||
<h2 className="text-sm font-bold text-foreground pb-2 border-b border-border-soft"> |
|
||||
دسترسیهای سریع |
|
||||
</h2> |
|
||||
<div className="space-y-2"> |
|
||||
<button |
|
||||
onClick={() => navigate('/users')} |
|
||||
className="flex w-full items-center justify-between rounded-xl bg-white/4 p-3 text-start transition-colors hover:bg-white/8" |
|
||||
> |
|
||||
<div className="flex items-center gap-2.5"> |
|
||||
<Ic name="users" className="size-4 text-primary" /> |
|
||||
<span className="text-xs font-medium text-foreground">مدیریت کاربران</span> |
|
||||
</div> |
</div> |
||||
<Ic name="chevronLeft" className="size-4 text-grey-4" /> |
|
||||
</button> |
|
||||
|
|
||||
<button |
|
||||
onClick={() => navigate('/items')} |
|
||||
className="flex w-full items-center justify-between rounded-xl bg-white/4 p-3 text-start transition-colors hover:bg-white/8" |
|
||||
> |
|
||||
<div className="flex items-center gap-2.5"> |
|
||||
<Ic name="grid" className="size-4 text-primary" /> |
|
||||
<span className="text-xs font-medium text-foreground">آیتمهای نمونه (CRUD)</span> |
|
||||
</div> |
</div> |
||||
<Ic name="chevronLeft" className="size-4 text-grey-4" /> |
|
||||
</button> |
|
||||
|
|
||||
<button |
|
||||
onClick={() => navigate('/settings')} |
|
||||
className="flex w-full items-center justify-between rounded-xl bg-white/4 p-3 text-start transition-colors hover:bg-white/8" |
|
||||
> |
|
||||
<div className="flex items-center gap-2.5"> |
|
||||
<Ic name="settings" className="size-4 text-primary" /> |
|
||||
<span className="text-xs font-medium text-foreground">تنظیمات سیستم</span> |
|
||||
|
{/* کارت حساب کاربری */} |
||||
|
<div className="rounded-xl border border-border-soft bg-card/60 p-5 shadow-sm sm:col-span-2 lg:col-span-1"> |
||||
|
<div className="flex items-center justify-between"> |
||||
|
<span className="text-xs font-medium text-grey-3">اطلاعات حساب جاری</span> |
||||
|
<Ic name="users" className="size-4 text-grey-3" /> |
||||
</div> |
</div> |
||||
<Ic name="chevronLeft" className="size-4 text-grey-4" /> |
|
||||
</button> |
|
||||
|
<div className="mt-3 space-y-1.5 text-xs"> |
||||
|
<div className="flex justify-between"> |
||||
|
<span className="text-grey-3">نام کاربر:</span> |
||||
|
<span className="font-semibold text-foreground">{user?.name || 'مدیر'}</span> |
||||
</div> |
</div> |
||||
|
|
||||
<div className="rounded-xl border border-primary/20 bg-primary/5 p-3.5 text-xs"> |
|
||||
<div className="flex items-center gap-1.5 font-bold text-primary mb-1"> |
|
||||
<Ic name="sparkles" className="size-4" /> |
|
||||
راهنمای قالب |
|
||||
|
<div className="flex justify-between"> |
||||
|
<span className="text-grey-3">ایمیل:</span> |
||||
|
<span dir="ltr" className="font-mono text-[11px] text-grey-2"> |
||||
|
{user?.email || '-'} |
||||
|
</span> |
||||
|
</div> |
||||
|
<div className="flex justify-between"> |
||||
|
<span className="text-grey-3">نقش:</span> |
||||
|
<span className="text-primary font-medium">{user?.role || 'admin'}</span> |
||||
</div> |
</div> |
||||
<p className="text-[11.5px] leading-relaxed text-grey-3"> |
|
||||
این قالب با قابلیت باز شدن پنل جزئیات (Drawer) از چپ و مدیریت انواع مودالها طراحی شده است. |
|
||||
</p> |
|
||||
</div> |
</div> |
||||
</div> |
</div> |
||||
</div> |
</div> |
||||
|
|
||||
|
{/* بوم خالی آماده توسعه */} |
||||
|
<div className="rounded-2xl border border-dashed border-border-soft bg-card/30 p-8 text-center"> |
||||
|
<div className="mx-auto flex size-12 items-center justify-center rounded-2xl bg-primary/10 text-primary"> |
||||
|
<Ic name="sparkles" className="size-6" /> |
||||
|
</div> |
||||
|
<h2 className="mt-4 text-base font-bold text-foreground">داشبورد تمیز و آماده توسعه</h2> |
||||
|
<p className="mx-auto mt-2 max-w-md text-xs leading-relaxed text-grey-3"> |
||||
|
تمام اطلاعات و یوآیهای ساختگی حذف شدند. میتوانید ماژولهای جدید خود را با کامپوننتهای آماده UI پیادهسازی و مستقیماً به دیتابیس و بکاند عقیله متصل نمایید. |
||||
|
</p> |
||||
|
</div> |
||||
</div> |
</div> |
||||
) |
) |
||||
} |
} |
||||
@ -1,86 +0,0 @@ |
|||||
import { useState } from 'react' |
|
||||
import { Ic } from '@/icons' |
|
||||
import { Button } from '@/components/ui/button' |
|
||||
import { Checkbox } from '@/components/ui/checkbox' |
|
||||
import { |
|
||||
Accordion, AccordionItem, AccordionTrigger, AccordionContent, |
|
||||
} from '@/components/ui/accordion' |
|
||||
import PanelHeader from '@/components/shared/PanelHeader' |
|
||||
import { useFiltersStore } from '@/stores/filters' |
|
||||
|
|
||||
export default function FilterDrawer({ onClose }: { onClose: () => void }) { |
|
||||
const currentStatus = useFiltersStore((s) => s.status) |
|
||||
const setStatus = useFiltersStore((s) => s.setStatus) |
|
||||
const resetFilters = useFiltersStore((s) => s.reset) |
|
||||
|
|
||||
const [draftStatus, setDraftStatus] = useState<string[]>(currentStatus) |
|
||||
|
|
||||
const toggleStatus = (val: string) => { |
|
||||
setDraftStatus((prev) => |
|
||||
prev.includes(val) ? prev.filter((s) => s !== val) : [...prev, val], |
|
||||
) |
|
||||
} |
|
||||
|
|
||||
const onApply = () => { |
|
||||
setStatus(draftStatus) |
|
||||
onClose() |
|
||||
} |
|
||||
|
|
||||
const clearAll = () => { |
|
||||
setDraftStatus([]) |
|
||||
resetFilters() |
|
||||
} |
|
||||
|
|
||||
return ( |
|
||||
<div className="flex h-full flex-col"> |
|
||||
<PanelHeader |
|
||||
className="py-3.5" |
|
||||
title={<span className="text-[15px] font-extrabold">فیلترهای پیشرفته</span>} |
|
||||
action={ |
|
||||
<button |
|
||||
onClick={clearAll} |
|
||||
className="flex cursor-pointer items-center gap-1 text-[11.5px] text-grey-3 hover:text-foreground [&_svg]:size-3" |
|
||||
> |
|
||||
<Ic name="x" /> پاککردن همه |
|
||||
</button> |
|
||||
} |
|
||||
onClose={onClose} |
|
||||
/> |
|
||||
|
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-2"> |
|
||||
<Accordion type="single" collapsible defaultValue="status"> |
|
||||
<AccordionItem value="status"> |
|
||||
<AccordionTrigger className="text-sm font-semibold">وضعیت</AccordionTrigger> |
|
||||
<AccordionContent className="space-y-1"> |
|
||||
{[ |
|
||||
{ label: 'فعال / منتشر شده', value: 'active' }, |
|
||||
{ label: 'پیشنویس / در انتظار', value: 'pending' }, |
|
||||
{ label: 'غیرفعال / بایگانی', value: 'inactive' }, |
|
||||
].map((item) => ( |
|
||||
<label |
|
||||
key={item.value} |
|
||||
className="flex cursor-pointer items-center gap-3 rounded-md px-2 py-2 transition-colors hover:bg-white/4" |
|
||||
> |
|
||||
<Checkbox |
|
||||
checked={draftStatus.includes(item.value)} |
|
||||
onCheckedChange={() => toggleStatus(item.value)} |
|
||||
/> |
|
||||
<span className="text-[13px]">{item.label}</span> |
|
||||
</label> |
|
||||
))} |
|
||||
</AccordionContent> |
|
||||
</AccordionItem> |
|
||||
</Accordion> |
|
||||
</div> |
|
||||
|
|
||||
<div className="border-t border-border-soft p-4 flex gap-2"> |
|
||||
<Button className="flex-1" onClick={onApply}> |
|
||||
اعمال فیلترها |
|
||||
</Button> |
|
||||
<Button variant="outline" onClick={onClose}> |
|
||||
انصراف |
|
||||
</Button> |
|
||||
</div> |
|
||||
</div> |
|
||||
) |
|
||||
} |
|
||||
@ -1,217 +0,0 @@ |
|||||
import { useState } from 'react' |
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' |
|
||||
import { getSampleItems, deleteSampleItem } from '@/services/sample-api' |
|
||||
import { Ic } from '@/icons' |
|
||||
import { Button } from '@/components/ui/button' |
|
||||
import { Input } from '@/components/ui/input' |
|
||||
import { Badge } from '@/components/ui/badge' |
|
||||
import { |
|
||||
Table, |
|
||||
TableHeader, |
|
||||
TableBody, |
|
||||
TableHead, |
|
||||
TableRow, |
|
||||
TableCell, |
|
||||
} from '@/components/ui/table' |
|
||||
import TablePagination from '@/components/shared/TablePagination' |
|
||||
import TableRowsSkeleton from '@/components/shared/TableRowsSkeleton' |
|
||||
import DeleteConfirmDialog from '@/components/shared/DeleteConfirmDialog' |
|
||||
import SampleItemModal from './SampleItemModal' |
|
||||
import { usePanelRouting } from '@/hooks/use-panel-routing' |
|
||||
import type { SampleItem } from '@/types' |
|
||||
|
|
||||
export default function ItemsPage() { |
|
||||
const queryClient = useQueryClient() |
|
||||
const { openPanel, openModal, closeModal, activeModal } = usePanelRouting() |
|
||||
|
|
||||
const [page, setPage] = useState(1) |
|
||||
const [search, setSearch] = useState('') |
|
||||
const [deleteTarget, setDeleteTarget] = useState<SampleItem | null>(null) |
|
||||
|
|
||||
const { data, isLoading } = useQuery({ |
|
||||
queryKey: ['items', { page, search }], |
|
||||
queryFn: () => getSampleItems({ page, pageSize: 10, search }), |
|
||||
}) |
|
||||
|
|
||||
const deleteMutation = useMutation({ |
|
||||
mutationFn: (id: string) => deleteSampleItem(id), |
|
||||
onSuccess: () => { |
|
||||
queryClient.invalidateQueries({ queryKey: ['items'] }) |
|
||||
setDeleteTarget(null) |
|
||||
}, |
|
||||
}) |
|
||||
|
|
||||
const isItemModalOpen = activeModal?.type === 'item' |
|
||||
|
|
||||
const statusBadge = (status: string) => { |
|
||||
switch (status) { |
|
||||
case 'published': |
|
||||
return <Badge className="bg-emerald-500/15 text-emerald-400 border-emerald-500/20">منتشر شده</Badge> |
|
||||
case 'draft': |
|
||||
return <Badge className="bg-amber-500/15 text-amber-400 border-amber-500/20">پیشنویس</Badge> |
|
||||
default: |
|
||||
return <Badge className="bg-grey-4/40 text-grey-3 border-grey-4">بایگانی</Badge> |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
return ( |
|
||||
<div className="space-y-5 pt-2"> |
|
||||
{/* هدر صفحه */} |
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> |
|
||||
<div> |
|
||||
<h1 className="text-xl font-bold text-foreground">مدیریت آیتمها (الگوی CRUD)</h1> |
|
||||
<p className="mt-0.5 text-xs text-grey-3"> |
|
||||
نمونه کامل صفحه مدیریت داده با جدول، فیلتر، مودال و نمایش کشویی جزئیات |
|
||||
</p> |
|
||||
</div> |
|
||||
<Button onClick={() => openModal('item', 'new')} className="gap-2"> |
|
||||
<Ic name="plus" className="size-4" /> |
|
||||
افزودن آیتم جدید |
|
||||
</Button> |
|
||||
</div> |
|
||||
|
|
||||
{/* جستجو و ابزار */} |
|
||||
<div className="flex flex-col gap-3 rounded-2xl border border-border-soft bg-card/40 p-3 sm:flex-row sm:items-center sm:justify-between"> |
|
||||
<div className="relative flex-1 sm:max-w-xs"> |
|
||||
<Ic name="search" className="absolute start-3 top-1/2 size-4 -translate-y-1/2 text-grey-3" /> |
|
||||
<Input |
|
||||
value={search} |
|
||||
onChange={(e) => { |
|
||||
setSearch(e.target.value) |
|
||||
setPage(1) |
|
||||
}} |
|
||||
placeholder="جستجوی عنوان یا دسته..." |
|
||||
className="ps-9 text-xs" |
|
||||
/> |
|
||||
</div> |
|
||||
<div className="text-xs text-grey-3"> |
|
||||
مجموع: <span className="font-bold text-foreground">{data?.total || 0}</span> مورد |
|
||||
</div> |
|
||||
</div> |
|
||||
|
|
||||
{/* جدول دادهها */} |
|
||||
<div className="overflow-hidden rounded-2xl border border-border-soft bg-card/40"> |
|
||||
<Table> |
|
||||
<TableHeader> |
|
||||
<TableRow> |
|
||||
<TableHead className="w-12 text-center">#</TableHead> |
|
||||
<TableHead>عنوان آیتم</TableHead> |
|
||||
<TableHead>دستهبندی</TableHead> |
|
||||
<TableHead>ارزش / مبلغ</TableHead> |
|
||||
<TableHead>وضعیت</TableHead> |
|
||||
<TableHead>تاریخ ثبت</TableHead> |
|
||||
<TableHead className="w-24 text-center">عملیات</TableHead> |
|
||||
</TableRow> |
|
||||
</TableHeader> |
|
||||
<TableBody> |
|
||||
{isLoading ? ( |
|
||||
<TableRowsSkeleton cols={7} rows={5} /> |
|
||||
) : !data?.items || data.items.length === 0 ? ( |
|
||||
<TableRow> |
|
||||
<TableCell colSpan={7} className="py-10 text-center text-xs text-grey-3"> |
|
||||
موردی یافت نشد. |
|
||||
</TableCell> |
|
||||
</TableRow> |
|
||||
) : ( |
|
||||
data.items.map((item, idx) => ( |
|
||||
<TableRow |
|
||||
key={item.id} |
|
||||
className="cursor-pointer transition-colors hover:bg-white/4" |
|
||||
onClick={() => openPanel('item', item.id)} |
|
||||
> |
|
||||
<TableCell className="text-center text-xs text-grey-4 font-mono"> |
|
||||
{(page - 1) * 10 + idx + 1} |
|
||||
</TableCell> |
|
||||
<TableCell> |
|
||||
<div className="font-semibold text-foreground text-xs">{item.title}</div> |
|
||||
{item.description && ( |
|
||||
<div className="line-clamp-1 text-[11px] text-grey-3 mt-0.5 max-w-sm"> |
|
||||
{item.description} |
|
||||
</div> |
|
||||
)} |
|
||||
</TableCell> |
|
||||
<TableCell className="text-xs text-grey-2"> |
|
||||
<span className="rounded-md bg-white/4 px-2 py-0.5 text-xs"> |
|
||||
{item.category} |
|
||||
</span> |
|
||||
</TableCell> |
|
||||
<TableCell className="text-xs font-medium text-foreground"> |
|
||||
{item.price ? `${item.price.toLocaleString('fa-IR')} تومان` : 'رایگان'} |
|
||||
</TableCell> |
|
||||
<TableCell>{statusBadge(item.status)}</TableCell> |
|
||||
<TableCell className="text-xs text-grey-3">{item.createdAt}</TableCell> |
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}> |
|
||||
<div className="flex items-center justify-center gap-1"> |
|
||||
<Button |
|
||||
variant="ghost" |
|
||||
size="icon" |
|
||||
className="size-7 text-grey-3 hover:text-foreground" |
|
||||
title="مشاهده در پنل چپ" |
|
||||
onClick={() => openPanel('item', item.id)} |
|
||||
> |
|
||||
<Ic name="eye" className="size-3.5" /> |
|
||||
</Button> |
|
||||
<Button |
|
||||
variant="ghost" |
|
||||
size="icon" |
|
||||
className="size-7 text-grey-3 hover:text-foreground" |
|
||||
title="ویرایش در مودال" |
|
||||
onClick={() => openModal('item', item.id)} |
|
||||
> |
|
||||
<Ic name="pencil" className="size-3.5" /> |
|
||||
</Button> |
|
||||
<Button |
|
||||
variant="ghost" |
|
||||
size="icon" |
|
||||
className="size-7 text-rose-400/80 hover:bg-rose-500/10 hover:text-rose-400" |
|
||||
title="حذف" |
|
||||
onClick={() => setDeleteTarget(item)} |
|
||||
> |
|
||||
<Ic name="trash" className="size-3.5" /> |
|
||||
</Button> |
|
||||
</div> |
|
||||
</TableCell> |
|
||||
</TableRow> |
|
||||
)) |
|
||||
)} |
|
||||
</TableBody> |
|
||||
</Table> |
|
||||
|
|
||||
{data && data.totalPages > 1 && ( |
|
||||
<div className="p-3 border-t border-border-soft"> |
|
||||
<TablePagination |
|
||||
currentPage={page} |
|
||||
totalPages={data.totalPages} |
|
||||
onPageChange={setPage} |
|
||||
/> |
|
||||
</div> |
|
||||
)} |
|
||||
</div> |
|
||||
|
|
||||
{/* مودال ایجاد / ویرایش */} |
|
||||
<SampleItemModal |
|
||||
open={isItemModalOpen} |
|
||||
itemId={activeModal?.id} |
|
||||
onOpenChange={(isOpen) => { |
|
||||
if (!isOpen) closeModal() |
|
||||
}} |
|
||||
onSuccess={() => { |
|
||||
queryClient.invalidateQueries({ queryKey: ['items'] }) |
|
||||
}} |
|
||||
/> |
|
||||
|
|
||||
{/* دیالوگ تأیید حذف */} |
|
||||
<DeleteConfirmDialog |
|
||||
open={Boolean(deleteTarget)} |
|
||||
onOpenChange={(o) => !o && setDeleteTarget(null)} |
|
||||
title={`آیا از حذف "${deleteTarget?.title}" اطمینان دارید؟`} |
|
||||
isLoading={deleteMutation.isPending} |
|
||||
onConfirm={() => { |
|
||||
if (deleteTarget) { |
|
||||
deleteMutation.mutate(deleteTarget.id) |
|
||||
} |
|
||||
}} |
|
||||
/> |
|
||||
</div> |
|
||||
) |
|
||||
} |
|
||||
@ -1,235 +0,0 @@ |
|||||
import { useState, useEffect } from 'react' |
|
||||
import { |
|
||||
Dialog, |
|
||||
DialogContent, |
|
||||
DialogHeader, |
|
||||
DialogTitle, |
|
||||
DialogDescription, |
|
||||
DialogFooter, |
|
||||
} from '@/components/ui/dialog' |
|
||||
import { Input } from '@/components/ui/input' |
|
||||
import { Button } from '@/components/ui/button' |
|
||||
import { Textarea } from '@/components/ui/textarea' |
|
||||
import { |
|
||||
Select, |
|
||||
SelectContent, |
|
||||
SelectItem, |
|
||||
SelectTrigger, |
|
||||
SelectValue, |
|
||||
} from '@/components/ui/select' |
|
||||
import { createSampleItem, updateSampleItem, getSampleItem } from '@/services/sample-api' |
|
||||
import type { SampleItem, ItemStatus } from '@/types' |
|
||||
|
|
||||
interface SampleItemModalProps { |
|
||||
open: boolean |
|
||||
itemId?: string | null |
|
||||
onOpenChange: (open: boolean) => void |
|
||||
onSuccess?: (item: SampleItem) => void |
|
||||
} |
|
||||
|
|
||||
export default function SampleItemModal({ |
|
||||
open, |
|
||||
itemId, |
|
||||
onOpenChange, |
|
||||
onSuccess, |
|
||||
}: SampleItemModalProps) { |
|
||||
const isEditing = Boolean(itemId && itemId !== 'new') |
|
||||
|
|
||||
const [title, setTitle] = useState('') |
|
||||
const [category, setCategory] = useState('نرمافزار') |
|
||||
const [status, setStatus] = useState<ItemStatus>('published') |
|
||||
const [price, setPrice] = useState<string>('') |
|
||||
const [description, setDescription] = useState('') |
|
||||
const [tags, setTags] = useState('') |
|
||||
const [loading, setLoading] = useState(false) |
|
||||
const [submitting, setSubmitting] = useState(false) |
|
||||
const [error, setError] = useState<string | null>(null) |
|
||||
|
|
||||
useEffect(() => { |
|
||||
if (open) { |
|
||||
setError(null) |
|
||||
if (isEditing && itemId) { |
|
||||
setLoading(true) |
|
||||
getSampleItem(itemId) |
|
||||
.then((it) => { |
|
||||
setTitle(it.title) |
|
||||
setCategory(it.category) |
|
||||
setStatus(it.status) |
|
||||
setPrice(it.price ? String(it.price) : '') |
|
||||
setDescription(it.description || '') |
|
||||
setTags(it.tags?.join('، ') || '') |
|
||||
}) |
|
||||
.catch(() => setError('دریافت اطلاعات با خطا مواجه شد.')) |
|
||||
.finally(() => setLoading(false)) |
|
||||
} else { |
|
||||
setTitle('') |
|
||||
setCategory('نرمافزار') |
|
||||
setStatus('published') |
|
||||
setPrice('') |
|
||||
setDescription('') |
|
||||
setTags('') |
|
||||
} |
|
||||
} |
|
||||
}, [open, isEditing, itemId]) |
|
||||
|
|
||||
const handleSubmit = async (e: React.FormEvent) => { |
|
||||
e.preventDefault() |
|
||||
if (!title.trim()) { |
|
||||
setError('عنوان الزامی است.') |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
setSubmitting(true) |
|
||||
setError(null) |
|
||||
|
|
||||
const parsedTags = tags |
|
||||
.split(/[،,]/) |
|
||||
.map((t) => t.trim()) |
|
||||
.filter(Boolean) |
|
||||
|
|
||||
let result: SampleItem |
|
||||
if (isEditing && itemId) { |
|
||||
result = await updateSampleItem(itemId, { |
|
||||
title, |
|
||||
category, |
|
||||
status, |
|
||||
price: price ? Number(price) : undefined, |
|
||||
description, |
|
||||
tags: parsedTags, |
|
||||
}) |
|
||||
} else { |
|
||||
result = await createSampleItem({ |
|
||||
title, |
|
||||
category, |
|
||||
status, |
|
||||
price: price ? Number(price) : undefined, |
|
||||
description, |
|
||||
tags: parsedTags, |
|
||||
}) |
|
||||
} |
|
||||
|
|
||||
onSuccess?.(result) |
|
||||
onOpenChange(false) |
|
||||
} catch (err: any) { |
|
||||
setError(err?.message || 'عملیات با خطا مواجه شد.') |
|
||||
} finally { |
|
||||
setSubmitting(false) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
return ( |
|
||||
<Dialog open={open} onOpenChange={onOpenChange}> |
|
||||
<DialogContent className="sm:max-w-[500px]"> |
|
||||
<DialogHeader className="text-start"> |
|
||||
<DialogTitle className="text-lg font-bold text-foreground"> |
|
||||
{isEditing ? 'ویرایش اطلاعات آیتم' : 'افزودن آیتم جدید'} |
|
||||
</DialogTitle> |
|
||||
<DialogDescription className="text-xs text-grey-3"> |
|
||||
فرم زیر را تکمیل کرده و دکمه ثبت را کلیک کنید. |
|
||||
</DialogDescription> |
|
||||
</DialogHeader> |
|
||||
|
|
||||
{loading ? ( |
|
||||
<div className="py-8 text-center text-sm text-grey-3">در حال بارگذاری...</div> |
|
||||
) : ( |
|
||||
<form onSubmit={handleSubmit} className="space-y-4 py-2"> |
|
||||
{error && ( |
|
||||
<div className="rounded-lg bg-rose-500/10 border border-rose-500/30 p-3 text-xs text-rose-400"> |
|
||||
{error} |
|
||||
</div> |
|
||||
)} |
|
||||
|
|
||||
<div className="space-y-1.5"> |
|
||||
<label className="text-xs font-semibold text-grey-2">عنوان آیتم *</label> |
|
||||
<Input |
|
||||
placeholder="عنوان خدمت یا محصول..." |
|
||||
value={title} |
|
||||
onChange={(e) => setTitle(e.target.value)} |
|
||||
required |
|
||||
/> |
|
||||
</div> |
|
||||
|
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> |
|
||||
<div className="space-y-1.5"> |
|
||||
<label className="text-xs font-semibold text-grey-2">دستهبندی</label> |
|
||||
<Select value={category} onValueChange={setCategory}> |
|
||||
<SelectTrigger> |
|
||||
<SelectValue placeholder="دستهبندی" /> |
|
||||
</SelectTrigger> |
|
||||
<SelectContent> |
|
||||
<SelectItem value="نرمافزار">نرمافزار</SelectItem> |
|
||||
<SelectItem value="مالی">مالی و پرداخت</SelectItem> |
|
||||
<SelectItem value="ارتباطات">ارتباطات</SelectItem> |
|
||||
<SelectItem value="لجستیک">لجستیک</SelectItem> |
|
||||
<SelectItem value="پشتیبانی">پشتیبانی</SelectItem> |
|
||||
</SelectContent> |
|
||||
</Select> |
|
||||
</div> |
|
||||
|
|
||||
<div className="space-y-1.5"> |
|
||||
<label className="text-xs font-semibold text-grey-2">وضعیت انتشار</label> |
|
||||
<Select value={status} onValueChange={(v: ItemStatus) => setStatus(v)}> |
|
||||
<SelectTrigger> |
|
||||
<SelectValue placeholder="وضعیت" /> |
|
||||
</SelectTrigger> |
|
||||
<SelectContent> |
|
||||
<SelectItem value="published">منتشر شده</SelectItem> |
|
||||
<SelectItem value="draft">پیشنویس</SelectItem> |
|
||||
<SelectItem value="archived">بایگانی شده</SelectItem> |
|
||||
</SelectContent> |
|
||||
</Select> |
|
||||
</div> |
|
||||
</div> |
|
||||
|
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> |
|
||||
<div className="space-y-1.5"> |
|
||||
<label className="text-xs font-semibold text-grey-2">قیمت / مبلغ (تومان)</label> |
|
||||
<Input |
|
||||
dir="ltr" |
|
||||
type="number" |
|
||||
placeholder="مثلاً: 1500000" |
|
||||
value={price} |
|
||||
onChange={(e) => setPrice(e.target.value)} |
|
||||
/> |
|
||||
</div> |
|
||||
|
|
||||
<div className="space-y-1.5"> |
|
||||
<label className="text-xs font-semibold text-grey-2">برچسبها (با ویرگول جدا کنید)</label> |
|
||||
<Input |
|
||||
placeholder="مالی، پرداخت، امنیت" |
|
||||
value={tags} |
|
||||
onChange={(e) => setTags(e.target.value)} |
|
||||
/> |
|
||||
</div> |
|
||||
</div> |
|
||||
|
|
||||
<div className="space-y-1.5"> |
|
||||
<label className="text-xs font-semibold text-grey-2">توضیحات تکمیلی</label> |
|
||||
<Textarea |
|
||||
placeholder="توضیحات کوتاه درباره آیتم..." |
|
||||
value={description} |
|
||||
onChange={(e) => setDescription(e.target.value)} |
|
||||
rows={3} |
|
||||
/> |
|
||||
</div> |
|
||||
|
|
||||
<DialogFooter className="mt-6 gap-2 sm:justify-start"> |
|
||||
<Button type="submit" disabled={submitting}> |
|
||||
{submitting ? 'در حال ذخیره...' : isEditing ? 'ذخیره تغییرات' : 'افزودن آیتم'} |
|
||||
</Button> |
|
||||
<Button |
|
||||
type="button" |
|
||||
variant="outline" |
|
||||
disabled={submitting} |
|
||||
onClick={() => onOpenChange(false)} |
|
||||
> |
|
||||
انصراف |
|
||||
</Button> |
|
||||
</DialogFooter> |
|
||||
</form> |
|
||||
)} |
|
||||
</DialogContent> |
|
||||
</Dialog> |
|
||||
) |
|
||||
} |
|
||||
@ -1,113 +0,0 @@ |
|||||
import { useQuery } from '@tanstack/react-query' |
|
||||
import { getSampleItem } from '@/services/sample-api' |
|
||||
import PanelHeader from '@/components/shared/PanelHeader' |
|
||||
import { Skeleton } from '@/components/ui/skeleton' |
|
||||
import { Badge } from '@/components/ui/badge' |
|
||||
import { Button } from '@/components/ui/button' |
|
||||
import { Ic } from '@/icons' |
|
||||
import { usePanelRouting } from '@/hooks/use-panel-routing' |
|
||||
|
|
||||
interface SampleDetailPanelProps { |
|
||||
id?: string |
|
||||
onClose: () => void |
|
||||
} |
|
||||
|
|
||||
export default function SampleDetailPanel({ id, onClose }: SampleDetailPanelProps) { |
|
||||
const { openModal } = usePanelRouting() |
|
||||
const { data: item, isLoading, error } = useQuery({ |
|
||||
queryKey: ['sample-item', id], |
|
||||
queryFn: () => getSampleItem(id || 'item-1'), |
|
||||
enabled: Boolean(id), |
|
||||
}) |
|
||||
|
|
||||
if (isLoading) { |
|
||||
return ( |
|
||||
<div className="flex h-full flex-col p-5 space-y-4"> |
|
||||
<PanelHeader title="جزئیات آیتم" onClose={onClose} /> |
|
||||
<Skeleton className="h-20 w-full rounded-xl" /> |
|
||||
<Skeleton className="h-28 w-full rounded-xl" /> |
|
||||
<Skeleton className="h-40 w-full rounded-xl" /> |
|
||||
</div> |
|
||||
) |
|
||||
} |
|
||||
|
|
||||
if (error || !item) { |
|
||||
return ( |
|
||||
<div className="flex h-full flex-col p-5"> |
|
||||
<PanelHeader title="خطا" onClose={onClose} /> |
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-grey-3"> |
|
||||
آیتم مورد نظر یافت نشد. |
|
||||
</div> |
|
||||
</div> |
|
||||
) |
|
||||
} |
|
||||
|
|
||||
const statusBadge = { |
|
||||
published: <Badge className="bg-emerald-500/15 text-emerald-400 border-emerald-500/25">منتشر شده</Badge>, |
|
||||
draft: <Badge className="bg-amber-500/15 text-amber-400 border-amber-500/25">پیشنویس</Badge>, |
|
||||
archived: <Badge className="bg-grey-4/40 text-grey-3 border-grey-4">بایگانی شده</Badge>, |
|
||||
}[item.status] |
|
||||
|
|
||||
return ( |
|
||||
<div className="flex h-full flex-col overflow-y-auto p-5"> |
|
||||
<PanelHeader title="مشاهده جزئیات" onClose={onClose} /> |
|
||||
|
|
||||
<div className="mt-5 rounded-xl border border-border-soft bg-card/60 p-4"> |
|
||||
<div className="flex items-center justify-between gap-2"> |
|
||||
<span className="text-xs text-primary font-medium">{item.category}</span> |
|
||||
{statusBadge} |
|
||||
</div> |
|
||||
<h3 className="mt-2 text-base font-bold text-foreground">{item.title}</h3> |
|
||||
{item.description && ( |
|
||||
<p className="mt-2 text-xs text-grey-2 leading-relaxed">{item.description}</p> |
|
||||
)} |
|
||||
</div> |
|
||||
|
|
||||
<div className="mt-4 space-y-3 rounded-xl border border-border-soft bg-card/40 p-4 text-xs"> |
|
||||
<div className="flex items-center justify-between py-1 border-b border-border-soft"> |
|
||||
<span className="text-grey-3">مبلغ / ارزش</span> |
|
||||
<span className="font-semibold text-foreground"> |
|
||||
{item.price ? `${item.price.toLocaleString('fa-IR')} تومان` : 'رایگان'} |
|
||||
</span> |
|
||||
</div> |
|
||||
<div className="flex items-center justify-between py-1 border-b border-border-soft"> |
|
||||
<span className="text-grey-3">تاریخ ثبت</span> |
|
||||
<span className="font-medium text-foreground">{item.createdAt}</span> |
|
||||
</div> |
|
||||
<div className="flex items-center justify-between py-1"> |
|
||||
<span className="text-grey-3">آخرین بروزرسانی</span> |
|
||||
<span className="font-medium text-foreground">{item.updatedAt}</span> |
|
||||
</div> |
|
||||
</div> |
|
||||
|
|
||||
{item.tags && item.tags.length > 0 && ( |
|
||||
<div className="mt-4 rounded-xl border border-border-soft bg-card/40 p-4"> |
|
||||
<span className="text-xs font-semibold text-grey-3 block mb-2">برچسبها</span> |
|
||||
<div className="flex flex-wrap gap-1.5"> |
|
||||
{item.tags.map((t) => ( |
|
||||
<span key={t} className="rounded-md bg-white/6 px-2 py-0.5 text-[11px] text-grey-2"> |
|
||||
#{t} |
|
||||
</span> |
|
||||
))} |
|
||||
</div> |
|
||||
</div> |
|
||||
)} |
|
||||
|
|
||||
<div className="mt-auto pt-6 flex items-center gap-2"> |
|
||||
<Button |
|
||||
className="flex-1 gap-2" |
|
||||
onClick={() => { |
|
||||
onClose() |
|
||||
openModal('item', item.id) |
|
||||
}} |
|
||||
> |
|
||||
<Ic name="pencil" className="size-4" /> |
|
||||
ویرایش آیتم |
|
||||
</Button> |
|
||||
<Button variant="outline" onClick={onClose}> |
|
||||
بستن |
|
||||
</Button> |
|
||||
</div> |
|
||||
</div> |
|
||||
) |
|
||||
} |
|
||||
@ -1,125 +0,0 @@ |
|||||
import { useQuery } from '@tanstack/react-query' |
|
||||
import { getUser } from '@/services/users-api' |
|
||||
import PanelHeader from '@/components/shared/PanelHeader' |
|
||||
import { Skeleton } from '@/components/ui/skeleton' |
|
||||
import { Badge } from '@/components/ui/badge' |
|
||||
import { Button } from '@/components/ui/button' |
|
||||
import { Ic } from '@/icons' |
|
||||
import { usePanelRouting } from '@/hooks/use-panel-routing' |
|
||||
|
|
||||
interface UserDetailPanelProps { |
|
||||
id?: string |
|
||||
onClose: () => void |
|
||||
} |
|
||||
|
|
||||
export default function UserDetailPanel({ id, onClose }: UserDetailPanelProps) { |
|
||||
const { openModal } = usePanelRouting() |
|
||||
const { data: user, isLoading, error } = useQuery({ |
|
||||
queryKey: ['user', id], |
|
||||
queryFn: () => getUser(id || '1'), |
|
||||
enabled: Boolean(id), |
|
||||
}) |
|
||||
|
|
||||
if (isLoading) { |
|
||||
return ( |
|
||||
<div className="flex h-full flex-col p-5 space-y-4"> |
|
||||
<PanelHeader title="جزئیات کاربر" onClose={onClose} /> |
|
||||
<div className="flex items-center gap-3 pt-4"> |
|
||||
<Skeleton className="size-16 rounded-full" /> |
|
||||
<div className="space-y-2 flex-1"> |
|
||||
<Skeleton className="h-4 w-3/4" /> |
|
||||
<Skeleton className="h-3 w-1/2" /> |
|
||||
</div> |
|
||||
</div> |
|
||||
<Skeleton className="h-28 w-full rounded-xl" /> |
|
||||
<Skeleton className="h-40 w-full rounded-xl" /> |
|
||||
</div> |
|
||||
) |
|
||||
} |
|
||||
|
|
||||
if (error || !user) { |
|
||||
return ( |
|
||||
<div className="flex h-full flex-col p-5"> |
|
||||
<PanelHeader title="خطا" onClose={onClose} /> |
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-grey-3"> |
|
||||
کاربر مورد نظر یافت نشد. |
|
||||
</div> |
|
||||
</div> |
|
||||
) |
|
||||
} |
|
||||
|
|
||||
const roleLabel = { |
|
||||
admin: 'مدیر کل', |
|
||||
manager: 'مدیر بخش', |
|
||||
editor: 'ویرایشگر', |
|
||||
viewer: 'مشاهدهکننده', |
|
||||
}[user.role] ?? user.role |
|
||||
|
|
||||
const statusBadge = { |
|
||||
active: <Badge className="bg-emerald-500/15 text-emerald-400 border-emerald-500/25">فعال</Badge>, |
|
||||
inactive: <Badge className="bg-rose-500/15 text-rose-400 border-rose-500/25">غیرفعال</Badge>, |
|
||||
pending: <Badge className="bg-amber-500/15 text-amber-400 border-amber-500/25">در انتظار تایید</Badge>, |
|
||||
}[user.status] |
|
||||
|
|
||||
return ( |
|
||||
<div className="flex h-full flex-col overflow-y-auto p-5"> |
|
||||
<PanelHeader title="جزئیات کاربر" onClose={onClose} /> |
|
||||
|
|
||||
{/* آواتار و نام کاربر */} |
|
||||
<div className="mt-5 flex items-center gap-4 rounded-xl border border-border-soft bg-card/60 p-4"> |
|
||||
<div className="flex size-14 items-center justify-center rounded-full bg-primary/20 text-xl font-bold text-primary"> |
|
||||
{user.name.charAt(0)} |
|
||||
</div> |
|
||||
<div className="min-w-0 flex-1"> |
|
||||
<div className="flex items-center gap-2"> |
|
||||
<h3 className="truncate font-bold text-foreground">{user.name}</h3> |
|
||||
{statusBadge} |
|
||||
</div> |
|
||||
<p dir="ltr" className="truncate text-xs text-grey-3 text-start mt-0.5">{user.email}</p> |
|
||||
<p className="text-[11px] text-grey-4 mt-1">نقش: {roleLabel}</p> |
|
||||
</div> |
|
||||
</div> |
|
||||
|
|
||||
{/* اطلاعات تکمیلی */} |
|
||||
<div className="mt-5 space-y-3 rounded-xl border border-border-soft bg-card/40 p-4 text-xs"> |
|
||||
<div className="flex items-center justify-between py-1 border-b border-border-soft"> |
|
||||
<span className="text-grey-3">شماره تماس</span> |
|
||||
<span dir="ltr" className="font-medium text-foreground">{user.phone || 'ثبتنشده'}</span> |
|
||||
</div> |
|
||||
<div className="flex items-center justify-between py-1 border-b border-border-soft"> |
|
||||
<span className="text-grey-3">تاریخ عضویت</span> |
|
||||
<span className="font-medium text-foreground">{user.createdAt}</span> |
|
||||
</div> |
|
||||
<div className="flex items-center justify-between py-1"> |
|
||||
<span className="text-grey-3">آخرین فعالیت</span> |
|
||||
<span className="font-medium text-foreground">{user.lastActive || 'مشخص نیست'}</span> |
|
||||
</div> |
|
||||
</div> |
|
||||
|
|
||||
{/* بیوگرافی یا توضیحات */} |
|
||||
{user.bio && ( |
|
||||
<div className="mt-4 rounded-xl border border-border-soft bg-card/40 p-4"> |
|
||||
<span className="text-xs font-semibold text-grey-3 block mb-1">درباره کاربر</span> |
|
||||
<p className="text-xs text-grey-2 leading-relaxed">{user.bio}</p> |
|
||||
</div> |
|
||||
)} |
|
||||
|
|
||||
{/* اکشنهای سریع */} |
|
||||
<div className="mt-auto pt-6 flex items-center gap-2"> |
|
||||
<Button |
|
||||
className="flex-1 gap-2" |
|
||||
onClick={() => { |
|
||||
onClose() |
|
||||
openModal('user', user.id) |
|
||||
}} |
|
||||
> |
|
||||
<Ic name="pencil" className="size-4" /> |
|
||||
ویرایش اطلاعات |
|
||||
</Button> |
|
||||
<Button variant="outline" onClick={onClose}> |
|
||||
بستن |
|
||||
</Button> |
|
||||
</div> |
|
||||
</div> |
|
||||
) |
|
||||
} |
|
||||
@ -1,121 +0,0 @@ |
|||||
import { useState } from 'react' |
|
||||
import { Button } from '@/components/ui/button' |
|
||||
import { Input } from '@/components/ui/input' |
|
||||
import { Switch } from '@/components/ui/switch' |
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' |
|
||||
import { siteConfig } from '@/config/site' |
|
||||
|
|
||||
export default function SettingsPage() { |
|
||||
const [siteName, setSiteName] = useState(siteConfig.name) |
|
||||
const [emailAlerts, setEmailAlerts] = useState(true) |
|
||||
const [browserAlerts, setBrowserAlerts] = useState(false) |
|
||||
const [saved, setSaved] = useState(false) |
|
||||
|
|
||||
const handleSave = (e: React.FormEvent) => { |
|
||||
e.preventDefault() |
|
||||
setSaved(true) |
|
||||
setTimeout(() => setSaved(false), 2500) |
|
||||
} |
|
||||
|
|
||||
return ( |
|
||||
<div className="space-y-6 pt-2 max-w-4xl"> |
|
||||
<div> |
|
||||
<h1 className="text-xl font-bold text-foreground">تنظیمات سیستم</h1> |
|
||||
<p className="mt-0.5 text-xs text-grey-3"> |
|
||||
مدیریت پیکربندیهای کلی داشبورد، اعلانها و امنیت حساب کاربری |
|
||||
</p> |
|
||||
</div> |
|
||||
|
|
||||
<Tabs defaultValue="general" className="w-full"> |
|
||||
<TabsList className="grid w-full grid-cols-3 max-w-md"> |
|
||||
<TabsTrigger value="general">تنظیمات عمومی</TabsTrigger> |
|
||||
<TabsTrigger value="notifications">اعلانها</TabsTrigger> |
|
||||
<TabsTrigger value="security">امنیت</TabsTrigger> |
|
||||
</TabsList> |
|
||||
|
|
||||
<TabsContent value="general" className="mt-5 space-y-5"> |
|
||||
<form onSubmit={handleSave} className="rounded-2xl border border-border-soft bg-card/40 p-5 space-y-4"> |
|
||||
<h2 className="text-sm font-bold text-foreground pb-2 border-b border-border-soft"> |
|
||||
مشخصات پنل |
|
||||
</h2> |
|
||||
|
|
||||
<div className="space-y-1.5"> |
|
||||
<label className="text-xs font-semibold text-grey-2">نام پنل / برند</label> |
|
||||
<Input |
|
||||
value={siteName} |
|
||||
onChange={(e) => setSiteName(e.target.value)} |
|
||||
placeholder="عنوان داشبورد" |
|
||||
/> |
|
||||
</div> |
|
||||
|
|
||||
<div className="space-y-1.5"> |
|
||||
<label className="text-xs font-semibold text-grey-2">ایمیل پشتیبانی</label> |
|
||||
<Input |
|
||||
dir="ltr" |
|
||||
defaultValue="support@example.com" |
|
||||
placeholder="info@example.com" |
|
||||
/> |
|
||||
</div> |
|
||||
|
|
||||
<div className="pt-2"> |
|
||||
<Button type="submit"> |
|
||||
{saved ? 'ذخیره شد ✓' : 'ذخیره تنظیمات'} |
|
||||
</Button> |
|
||||
</div> |
|
||||
</form> |
|
||||
</TabsContent> |
|
||||
|
|
||||
<TabsContent value="notifications" className="mt-5 space-y-5"> |
|
||||
<div className="rounded-2xl border border-border-soft bg-card/40 p-5 space-y-4"> |
|
||||
<h2 className="text-sm font-bold text-foreground pb-2 border-b border-border-soft"> |
|
||||
تنظیمات اطلاعرسانی |
|
||||
</h2> |
|
||||
|
|
||||
<div className="flex items-center justify-between py-2"> |
|
||||
<div> |
|
||||
<div className="text-xs font-semibold text-foreground">ارسال اعلان ایمیلی</div> |
|
||||
<div className="text-[11px] text-grey-3 mt-0.5">دریافت گزارشهای هفتگی و رویدادهای مهم از طریق ایمیل</div> |
|
||||
</div> |
|
||||
<Switch checked={emailAlerts} onCheckedChange={setEmailAlerts} /> |
|
||||
</div> |
|
||||
|
|
||||
<div className="flex items-center justify-between py-2"> |
|
||||
<div> |
|
||||
<div className="text-xs font-semibold text-foreground">اعلانهای مرورگر (Push)</div> |
|
||||
<div className="text-[11px] text-grey-3 mt-0.5">نمایش نوتیفیکیشن هنگام ثبت رویداد جدید</div> |
|
||||
</div> |
|
||||
<Switch checked={browserAlerts} onCheckedChange={setBrowserAlerts} /> |
|
||||
</div> |
|
||||
</div> |
|
||||
</TabsContent> |
|
||||
|
|
||||
<TabsContent value="security" className="mt-5 space-y-5"> |
|
||||
<form onSubmit={handleSave} className="rounded-2xl border border-border-soft bg-card/40 p-5 space-y-4"> |
|
||||
<h2 className="text-sm font-bold text-foreground pb-2 border-b border-border-soft"> |
|
||||
تغییر رمز عبور |
|
||||
</h2> |
|
||||
|
|
||||
<div className="space-y-1.5"> |
|
||||
<label className="text-xs font-semibold text-grey-2">رمز عبور فعلی</label> |
|
||||
<Input type="password" placeholder="••••••••" dir="ltr" /> |
|
||||
</div> |
|
||||
|
|
||||
<div className="space-y-1.5"> |
|
||||
<label className="text-xs font-semibold text-grey-2">رمز عبور جدید</label> |
|
||||
<Input type="password" placeholder="••••••••" dir="ltr" /> |
|
||||
</div> |
|
||||
|
|
||||
<div className="space-y-1.5"> |
|
||||
<label className="text-xs font-semibold text-grey-2">تکرار رمز عبور جدید</label> |
|
||||
<Input type="password" placeholder="••••••••" dir="ltr" /> |
|
||||
</div> |
|
||||
|
|
||||
<div className="pt-2"> |
|
||||
<Button type="submit">بروزرسانی رمز عبور</Button> |
|
||||
</div> |
|
||||
</form> |
|
||||
</TabsContent> |
|
||||
</Tabs> |
|
||||
</div> |
|
||||
) |
|
||||
} |
|
||||
@ -1,544 +0,0 @@ |
|||||
import { useState } from 'react' |
|
||||
import { Ic, type IconName } from '@/icons' |
|
||||
import { Button } from '@/components/ui/button' |
|
||||
import { Badge } from '@/components/ui/badge' |
|
||||
import { Input } from '@/components/ui/input' |
|
||||
import { Textarea } from '@/components/ui/textarea' |
|
||||
import { Switch } from '@/components/ui/switch' |
|
||||
import { Checkbox } from '@/components/ui/checkbox' |
|
||||
import { Slider } from '@/components/ui/slider' |
|
||||
import { |
|
||||
Select, |
|
||||
SelectContent, |
|
||||
SelectItem, |
|
||||
SelectTrigger, |
|
||||
SelectValue, |
|
||||
} from '@/components/ui/select' |
|
||||
import { |
|
||||
Dialog, |
|
||||
DialogTrigger, |
|
||||
DialogContent, |
|
||||
DialogHeader, |
|
||||
DialogTitle, |
|
||||
DialogDescription, |
|
||||
DialogFooter, |
|
||||
} from '@/components/ui/dialog' |
|
||||
import { |
|
||||
AlertDialog, |
|
||||
AlertDialogTrigger, |
|
||||
AlertDialogContent, |
|
||||
AlertDialogHeader, |
|
||||
AlertDialogTitle, |
|
||||
AlertDialogDescription, |
|
||||
AlertDialogFooter, |
|
||||
AlertDialogAction, |
|
||||
AlertDialogCancel, |
|
||||
} from '@/components/ui/alert-dialog' |
|
||||
import { |
|
||||
Sheet, |
|
||||
SheetTrigger, |
|
||||
SheetContent, |
|
||||
SheetHeader, |
|
||||
SheetTitle, |
|
||||
SheetDescription, |
|
||||
} from '@/components/ui/sheet' |
|
||||
import { |
|
||||
DropdownMenu, |
|
||||
DropdownMenuTrigger, |
|
||||
DropdownMenuContent, |
|
||||
DropdownMenuItem, |
|
||||
DropdownMenuSeparator, |
|
||||
DropdownMenuLabel, |
|
||||
} from '@/components/ui/dropdown-menu' |
|
||||
import { |
|
||||
Tooltip, |
|
||||
TooltipTrigger, |
|
||||
TooltipContent, |
|
||||
} from '@/components/ui/tooltip' |
|
||||
import { |
|
||||
Popover, |
|
||||
PopoverTrigger, |
|
||||
PopoverContent, |
|
||||
} from '@/components/ui/popover' |
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' |
|
||||
import { |
|
||||
Accordion, |
|
||||
AccordionItem, |
|
||||
AccordionTrigger, |
|
||||
AccordionContent, |
|
||||
} from '@/components/ui/accordion' |
|
||||
import { Skeleton } from '@/components/ui/skeleton' |
|
||||
import { Separator } from '@/components/ui/separator' |
|
||||
import { Avatar } from '@/components/ui/avatar' |
|
||||
import { usePanelRouting } from '@/hooks/use-panel-routing' |
|
||||
|
|
||||
export default function ComponentsShowcase() { |
|
||||
const { openPanel, openModal } = usePanelRouting() |
|
||||
|
|
||||
// State examples for interactive controls
|
|
||||
const [switchVal, setSwitchVal] = useState(true) |
|
||||
const [checkboxVal, setCheckboxVal] = useState(true) |
|
||||
const [sliderVal, setSliderVal] = useState([45]) |
|
||||
const [selectVal, setSelectVal] = useState('react') |
|
||||
const [copiedToken, setCopiedToken] = useState<string | null>(null) |
|
||||
|
|
||||
const copyToClipboard = (text: string) => { |
|
||||
navigator.clipboard.writeText(text) |
|
||||
setCopiedToken(text) |
|
||||
setTimeout(() => setCopiedToken(null), 2000) |
|
||||
} |
|
||||
|
|
||||
const colorTokens = [ |
|
||||
{ name: '--primary', label: 'رنگ اصلی (Primary)', color: 'bg-primary', text: 'text-primary-foreground', hex: '#6366f1' }, |
|
||||
{ name: '--background', label: 'پسزمینه (Background)', color: 'bg-background', text: 'text-foreground', hex: '#18191d' }, |
|
||||
{ name: '--card', label: 'کارتها (Card)', color: 'bg-card', text: 'text-card-foreground', hex: '#22252c' }, |
|
||||
{ name: '--popover', label: 'پاپاور و منو (Popover)', color: 'bg-popover', text: 'text-popover-foreground', hex: '#1e2128' }, |
|
||||
{ name: '--secondary', label: 'ثانویه (Secondary)', color: 'bg-secondary', text: 'text-secondary-foreground', hex: '#2c303b' }, |
|
||||
{ name: '--destructive', label: 'خطا و حذف (Destructive)', color: 'bg-destructive', text: 'text-white', hex: '#ef4444' }, |
|
||||
{ name: '--muted', label: 'خاموش (Muted)', color: 'bg-muted', text: 'text-muted-foreground', hex: '#22252c' }, |
|
||||
{ name: '--border', label: 'حاشیه (Border)', color: 'border-2 border-white/25 bg-card/40', text: 'text-foreground', hex: 'rgba(255,255,255,0.12)' }, |
|
||||
] |
|
||||
|
|
||||
const availableIcons: IconName[] = [ |
|
||||
'home', 'users', 'grid', 'chart', 'settings', 'shield', 'folder', |
|
||||
'search', 'bell', 'pencil', 'trash', 'plus', 'check', 'x', 'filter', |
|
||||
'globe', 'upload', 'download', 'refresh', 'link', 'alert', 'clock', |
|
||||
'sparkles', 'eye', 'eyeOff', 'logout', 'history', 'file', 'external', |
|
||||
] |
|
||||
|
|
||||
return ( |
|
||||
<div className="space-y-8 pt-2 pb-16 max-w-6xl"> |
|
||||
{/* هدر بخش ویترین */} |
|
||||
<div className="flex flex-col gap-2 border-b border-border-soft pb-5 sm:flex-row sm:items-center sm:justify-between"> |
|
||||
<div> |
|
||||
<div className="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-3 py-0.5 text-xs font-semibold text-primary mb-2"> |
|
||||
<Ic name="sparkles" className="size-3.5" /> دیزاینسیستم و کاتالوگ کامپوننتها |
|
||||
</div> |
|
||||
<h1 className="text-2xl font-black text-foreground">راهنمای استفاده از کامپوننتهای داشبورد</h1> |
|
||||
<p className="mt-1 text-xs text-grey-3 max-w-2xl leading-relaxed"> |
|
||||
این صفحه برای برنامهنویسان و طراحان پروژه آماده شده تا نحوه استفاده از کامپوننتهای شادسن، پالت رنگی، دیالوگها، مودالها و آیکونها را بهصورت زنده مشاهده و کپی کنند. |
|
||||
</p> |
|
||||
</div> |
|
||||
|
|
||||
<div className="flex items-center gap-2"> |
|
||||
<Button |
|
||||
size="sm" |
|
||||
onClick={() => openModal('user', 'new')} |
|
||||
className="gap-1.5 text-xs" |
|
||||
> |
|
||||
<Ic name="plus" className="size-3.5" /> تست مودال فرم |
|
||||
</Button> |
|
||||
<Button |
|
||||
size="sm" |
|
||||
variant="outline" |
|
||||
onClick={() => openPanel('user', '1')} |
|
||||
className="gap-1.5 text-xs" |
|
||||
> |
|
||||
<Ic name="eye" className="size-3.5" /> تست پنل کشویی چپ |
|
||||
</Button> |
|
||||
</div> |
|
||||
</div> |
|
||||
|
|
||||
<Tabs defaultValue="tokens" className="w-full"> |
|
||||
<TabsList className="grid w-full grid-cols-2 md:grid-cols-5 max-w-2xl"> |
|
||||
<TabsTrigger value="tokens">رنگها و تم</TabsTrigger> |
|
||||
<TabsTrigger value="buttons">دکمه و نشان</TabsTrigger> |
|
||||
<TabsTrigger value="forms">فرم و ورودیها</TabsTrigger> |
|
||||
<TabsTrigger value="overlays">مودال و منوها</TabsTrigger> |
|
||||
<TabsTrigger value="data">ساختار و آیکونها</TabsTrigger> |
|
||||
</TabsList> |
|
||||
|
|
||||
{/* ─── تب ۱: پالت رنگی و متغیرهای تم ─── */} |
|
||||
<TabsContent value="tokens" className="mt-6 space-y-6"> |
|
||||
<div className="rounded-2xl border border-border-soft bg-card/40 p-6 space-y-4"> |
|
||||
<div> |
|
||||
<h2 className="text-base font-bold text-foreground">پالت توکنهای طراحی (Design Tokens)</h2> |
|
||||
<p className="text-xs text-grey-3 mt-0.5"> |
|
||||
تمام استایلهای این پروژه بر اساس متغیرهای استاندارد CSS در فایل <code className="font-mono text-primary text-xs">src/index.css</code> تعریف شدهاند. |
|
||||
</p> |
|
||||
</div> |
|
||||
|
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> |
|
||||
{colorTokens.map((token) => ( |
|
||||
<div |
|
||||
key={token.name} |
|
||||
onClick={() => copyToClipboard(`var(${token.name})`)} |
|
||||
className="group relative flex flex-col justify-between rounded-xl border border-border-soft bg-card/60 p-3.5 cursor-pointer transition-all hover:border-primary/50" |
|
||||
> |
|
||||
<div className="flex items-center justify-between"> |
|
||||
<div className={`size-8 rounded-lg shadow-inner ${token.color}`} /> |
|
||||
<span className="text-[11px] font-mono text-grey-4 group-hover:text-primary"> |
|
||||
{copiedToken === `var(${token.name})` ? 'کپی شد! ✓' : 'کپی'} |
|
||||
</span> |
|
||||
</div> |
|
||||
<div className="mt-4"> |
|
||||
<div className="text-xs font-bold text-foreground">{token.label}</div> |
|
||||
<div dir="ltr" className="text-[11px] font-mono text-grey-3 text-start mt-0.5"> |
|
||||
{token.name} |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
))} |
|
||||
</div> |
|
||||
|
|
||||
<div className="rounded-xl border border-primary/25 bg-primary/5 p-4 text-xs space-y-2"> |
|
||||
<span className="font-bold text-primary block">💡 چطور رنگ برند پروژه جدید را عوض کنیم؟</span> |
|
||||
<p className="text-grey-3 leading-relaxed"> |
|
||||
فایل <code className="font-mono text-foreground bg-white/6 px-1.5 py-0.5 rounded">src/index.css</code> را باز کنید و در بلوک <code className="font-mono">:root</code>، مقدار <code className="font-mono">--primary</code> را با کد رنگ دلخواهتان جایگزین کنید (مثلاً آبی، سبز، نارنجی یا بنفش). کل دکمهها، فوکوسها، نشانها و منوها خودکار تغییر رنگ میدهند! |
|
||||
</p> |
|
||||
</div> |
|
||||
</div> |
|
||||
</TabsContent> |
|
||||
|
|
||||
{/* ─── تب ۲: دکمهها و نشانها ─── */} |
|
||||
<TabsContent value="buttons" className="mt-6 space-y-6"> |
|
||||
{/* انواع دکمهها */} |
|
||||
<div className="rounded-2xl border border-border-soft bg-card/40 p-6 space-y-5"> |
|
||||
<div> |
|
||||
<h2 className="text-base font-bold text-foreground">دکمهها (Button Component)</h2> |
|
||||
<p className="text-xs text-grey-3 mt-0.5">انواع و اندازههای مختلف دکمه در تمپلیت</p> |
|
||||
</div> |
|
||||
|
|
||||
<div className="flex flex-wrap items-center gap-3"> |
|
||||
<Button>پیشفرض (Primary)</Button> |
|
||||
<Button variant="secondary">ثانویه (Secondary)</Button> |
|
||||
<Button variant="outline">حاشیهدار (Outline)</Button> |
|
||||
<Button variant="ghost">شفاف (Ghost)</Button> |
|
||||
<Button variant="destructive">حذف و اخطار (Destructive)</Button> |
|
||||
<Button variant="white">دکمه روشن (White)</Button> |
|
||||
<Button disabled>غیرفعال (Disabled)</Button> |
|
||||
</div> |
|
||||
|
|
||||
<Separator /> |
|
||||
|
|
||||
<div> |
|
||||
<span className="text-xs font-semibold text-grey-3 block mb-3">اندازهها (Sizes) و همراه با آیکون:</span> |
|
||||
<div className="flex flex-wrap items-center gap-3"> |
|
||||
<Button size="sm">کوچک (sm)</Button> |
|
||||
<Button size="default">متوسط (default)</Button> |
|
||||
<Button size="lg">بزرگ (lg)</Button> |
|
||||
<Button className="gap-2"> |
|
||||
<Ic name="plus" className="size-4" /> با آیکون |
|
||||
</Button> |
|
||||
<Button variant="outline" size="icon" title="جستجو"> |
|
||||
<Ic name="search" className="size-4" /> |
|
||||
</Button> |
|
||||
<Button variant="ghost" size="icon" title="تنظیمات"> |
|
||||
<Ic name="settings" className="size-4" /> |
|
||||
</Button> |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
|
|
||||
{/* نشانها (Badges) */} |
|
||||
<div className="rounded-2xl border border-border-soft bg-card/40 p-6 space-y-4"> |
|
||||
<div> |
|
||||
<h2 className="text-base font-bold text-foreground">نشانها (Badge Component)</h2> |
|
||||
<p className="text-xs text-grey-3 mt-0.5">برای نمایش وضعیتها، تگها و شمارندهها</p> |
|
||||
</div> |
|
||||
|
|
||||
<div className="flex flex-wrap items-center gap-3"> |
|
||||
<Badge>پیشفرض</Badge> |
|
||||
<Badge variant="secondary">ثانویه</Badge> |
|
||||
<Badge variant="outline">حاشیهدار</Badge> |
|
||||
<Badge variant="success">موفق / فعال</Badge> |
|
||||
<Badge variant="destructive">رد شده / غیرفعال</Badge> |
|
||||
<Badge variant="count">۱۲</Badge> |
|
||||
</div> |
|
||||
</div> |
|
||||
</TabsContent> |
|
||||
|
|
||||
{/* ─── تب ۳: فرمها و فیلدها ─── */} |
|
||||
<TabsContent value="forms" className="mt-6 space-y-6"> |
|
||||
<div className="rounded-2xl border border-border-soft bg-card/40 p-6 space-y-6"> |
|
||||
<div> |
|
||||
<h2 className="text-base font-bold text-foreground">فیلدهای ورودی فرم (Form Controls)</h2> |
|
||||
<p className="text-xs text-grey-3 mt-0.5">ورودیهای تکخطی، چندخطی، چکباکس، سلکت و اسلایدر</p> |
|
||||
</div> |
|
||||
|
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> |
|
||||
<div className="space-y-1.5"> |
|
||||
<label className="text-xs font-semibold text-grey-2">اینپوت متنی ساده</label> |
|
||||
<Input placeholder="نام و نام خانوادگی را وارد کنید..." /> |
|
||||
</div> |
|
||||
|
|
||||
<div className="space-y-1.5"> |
|
||||
<label className="text-xs font-semibold text-grey-2">اینپوت انگلیسی / LTR (ایمیل یا شماره)</label> |
|
||||
<Input dir="ltr" type="email" placeholder="example@domain.com" /> |
|
||||
</div> |
|
||||
|
|
||||
<div className="space-y-1.5"> |
|
||||
<label className="text-xs font-semibold text-grey-2">منوی انتخابی (Select)</label> |
|
||||
<Select value={selectVal} onValueChange={setSelectVal}> |
|
||||
<SelectTrigger> |
|
||||
<SelectValue placeholder="انتخاب گزینه" /> |
|
||||
</SelectTrigger> |
|
||||
<SelectContent> |
|
||||
<SelectItem value="react">ریاکت (React 19)</SelectItem> |
|
||||
<SelectItem value="vue">ویو (Vue.js)</SelectItem> |
|
||||
<SelectItem value="tailwind">تیلویند (Tailwind 4)</SelectItem> |
|
||||
<SelectItem value="node">نود جیاس (Node.js)</SelectItem> |
|
||||
</SelectContent> |
|
||||
</Select> |
|
||||
</div> |
|
||||
|
|
||||
<div className="space-y-1.5"> |
|
||||
<label className="text-xs font-semibold text-grey-2">اسلایدر (Slider): {sliderVal[0]}٪</label> |
|
||||
<div className="pt-2"> |
|
||||
<Slider value={sliderVal} onValueChange={setSliderVal} max={100} step={1} /> |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
|
|
||||
<div className="space-y-1.5"> |
|
||||
<label className="text-xs font-semibold text-grey-2">توضیحات متنی (Textarea)</label> |
|
||||
<Textarea placeholder="یادداشت یا توضیحات طولانی خود را بنویسید..." rows={3} /> |
|
||||
</div> |
|
||||
|
|
||||
<Separator /> |
|
||||
|
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> |
|
||||
<div className="flex items-center justify-between rounded-xl bg-white/4 p-3.5"> |
|
||||
<div> |
|
||||
<span className="text-xs font-semibold text-foreground block">سوئیچ وضعیت (Switch)</span> |
|
||||
<span className="text-[11px] text-grey-3">روشن / خاموش بودن تنظیمات</span> |
|
||||
</div> |
|
||||
<Switch checked={switchVal} onCheckedChange={setSwitchVal} /> |
|
||||
</div> |
|
||||
|
|
||||
<div className="flex items-center gap-3 rounded-xl bg-white/4 p-3.5"> |
|
||||
<Checkbox |
|
||||
id="chk-terms" |
|
||||
checked={checkboxVal} |
|
||||
onCheckedChange={(c) => setCheckboxVal(Boolean(c))} |
|
||||
/> |
|
||||
<label htmlFor="chk-terms" className="text-xs text-foreground cursor-pointer select-none"> |
|
||||
پذیرش قوانین و شرایط استفاده (Checkbox) |
|
||||
</label> |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
</TabsContent> |
|
||||
|
|
||||
{/* ─── تب ۴: مودالها و منوها ─── */} |
|
||||
<TabsContent value="overlays" className="mt-6 space-y-6"> |
|
||||
<div className="rounded-2xl border border-border-soft bg-card/40 p-6 space-y-6"> |
|
||||
<div> |
|
||||
<h2 className="text-base font-bold text-foreground">لایههای شناور و دیالوگها (Overlays & Modals)</h2> |
|
||||
<p className="text-xs text-grey-3 mt-0.5">مودال، دیالوگ تایید، منوی کشویی، دراور و تولتیپ</p> |
|
||||
</div> |
|
||||
|
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> |
|
||||
{/* ۱. Dialog استاندارد */} |
|
||||
<div className="rounded-xl border border-border-soft bg-card/60 p-4 space-y-3"> |
|
||||
<div className="text-xs font-bold text-foreground">دیالوگ استاندارد (Dialog)</div> |
|
||||
<p className="text-[11px] text-grey-3">مناسب فرمها و جزئیات با کنترل کامل.</p> |
|
||||
<Dialog> |
|
||||
<DialogTrigger asChild> |
|
||||
<Button variant="outline" size="sm" className="w-full"> |
|
||||
باز کردن دیالوگ |
|
||||
</Button> |
|
||||
</DialogTrigger> |
|
||||
<DialogContent className="sm:max-w-[420px]"> |
|
||||
<DialogHeader> |
|
||||
<DialogTitle>نمونه دیالوگ</DialogTitle> |
|
||||
<DialogDescription>این یک دیالوگ ماژولار مبتنی بر Radix است.</DialogDescription> |
|
||||
</DialogHeader> |
|
||||
<div className="py-4 space-y-3"> |
|
||||
<Input placeholder="نام مورد نظر..." /> |
|
||||
<Input placeholder="توضیحات..." /> |
|
||||
</div> |
|
||||
<DialogFooter> |
|
||||
<Button>تایید و ثبت</Button> |
|
||||
</DialogFooter> |
|
||||
</DialogContent> |
|
||||
</Dialog> |
|
||||
</div> |
|
||||
|
|
||||
{/* ۲. AlertDialog تایید حساس */} |
|
||||
<div className="rounded-xl border border-border-soft bg-card/60 p-4 space-y-3"> |
|
||||
<div className="text-xs font-bold text-foreground">تأییدیه حساس (AlertDialog)</div> |
|
||||
<p className="text-[11px] text-grey-3">برای عملیات حذف یا تغییرات غیرقابل بازگشت.</p> |
|
||||
<AlertDialog> |
|
||||
<AlertDialogTrigger asChild> |
|
||||
<Button variant="destructive" size="sm" className="w-full"> |
|
||||
تست دیالوگ حذف |
|
||||
</Button> |
|
||||
</AlertDialogTrigger> |
|
||||
<AlertDialogContent className="sm:max-w-[400px]"> |
|
||||
<AlertDialogHeader> |
|
||||
<AlertDialogTitle>آیا مطمئن هستید؟</AlertDialogTitle> |
|
||||
<AlertDialogDescription>این عملیات قابل بازگشت نخواهد بود.</AlertDialogDescription> |
|
||||
</AlertDialogHeader> |
|
||||
<AlertDialogFooter className="mt-4 gap-2"> |
|
||||
<AlertDialogAction asChild> |
|
||||
<Button variant="destructive">بله، حذف کن</Button> |
|
||||
</AlertDialogAction> |
|
||||
<AlertDialogCancel asChild> |
|
||||
<Button variant="outline">انصراف</Button> |
|
||||
</AlertDialogCancel> |
|
||||
</AlertDialogFooter> |
|
||||
</AlertDialogContent> |
|
||||
</AlertDialog> |
|
||||
</div> |
|
||||
|
|
||||
{/* ۳. Sheet کشویی */} |
|
||||
<div className="rounded-xl border border-border-soft bg-card/60 p-4 space-y-3"> |
|
||||
<div className="text-xs font-bold text-foreground">دراور کشویی (Sheet)</div> |
|
||||
<p className="text-[11px] text-grey-3">باز شدن کشویی از چپ یا راست صفحه.</p> |
|
||||
<Sheet> |
|
||||
<SheetTrigger asChild> |
|
||||
<Button variant="secondary" size="sm" className="w-full"> |
|
||||
باز کردن Sheet |
|
||||
</Button> |
|
||||
</SheetTrigger> |
|
||||
<SheetContent side="left" className="p-6"> |
|
||||
<SheetHeader> |
|
||||
<SheetTitle>پنل کشویی Sheet</SheetTitle> |
|
||||
<SheetDescription>محتوای جانبی یا تنظیمات سریع در این بخش نمایش مییابد.</SheetDescription> |
|
||||
</SheetHeader> |
|
||||
<div className="py-6 text-xs text-grey-2"> |
|
||||
این کامپوننت با ترنزیشن نرم باز میشود و برای موبایل و دسکتاپ عالی است. |
|
||||
</div> |
|
||||
</SheetContent> |
|
||||
</Sheet> |
|
||||
</div> |
|
||||
</div> |
|
||||
|
|
||||
<Separator /> |
|
||||
|
|
||||
{/* منوهای کشویی، پاپاور و تولتیپ */} |
|
||||
<div className="flex flex-wrap items-center gap-4"> |
|
||||
{/* Dropdown Menu */} |
|
||||
<DropdownMenu> |
|
||||
<DropdownMenuTrigger asChild> |
|
||||
<Button variant="outline" className="gap-2"> |
|
||||
<Ic name="dots" className="size-4" /> منوی آبشاری (Dropdown) |
|
||||
</Button> |
|
||||
</DropdownMenuTrigger> |
|
||||
<DropdownMenuContent align="end" className="w-48"> |
|
||||
<DropdownMenuLabel>عملیاتهای کاربر</DropdownMenuLabel> |
|
||||
<DropdownMenuItem className="gap-2"> |
|
||||
<Ic name="pencil" className="size-3.5" /> ویرایش رکورد |
|
||||
</DropdownMenuItem> |
|
||||
<DropdownMenuItem className="gap-2"> |
|
||||
<Ic name="eye" className="size-3.5" /> مشاهده در پنل |
|
||||
</DropdownMenuItem> |
|
||||
<DropdownMenuSeparator /> |
|
||||
<DropdownMenuItem className="gap-2 text-rose-400"> |
|
||||
<Ic name="trash" className="size-3.5" /> حذف آیتم |
|
||||
</DropdownMenuItem> |
|
||||
</DropdownMenuContent> |
|
||||
</DropdownMenu> |
|
||||
|
|
||||
{/* Popover */} |
|
||||
<Popover> |
|
||||
<PopoverTrigger asChild> |
|
||||
<Button variant="secondary" className="gap-2"> |
|
||||
<Ic name="filter" className="size-4" /> پاپاور (Popover) |
|
||||
</Button> |
|
||||
</PopoverTrigger> |
|
||||
<PopoverContent className="w-64 p-4 text-xs space-y-2"> |
|
||||
<span className="font-bold text-foreground block">اطلاعات سریع</span> |
|
||||
<p className="text-grey-3 leading-relaxed"> |
|
||||
پاپاور میتواند هر کامپوننت یا فرم کوچکی را در خود جای دهد. |
|
||||
</p> |
|
||||
</PopoverContent> |
|
||||
</Popover> |
|
||||
|
|
||||
{/* Tooltip */} |
|
||||
<Tooltip> |
|
||||
<TooltipTrigger asChild> |
|
||||
<Button variant="ghost" size="icon" className="border border-border-soft"> |
|
||||
<Ic name="bell" className="size-4" /> |
|
||||
</Button> |
|
||||
</TooltipTrigger> |
|
||||
<TooltipContent> |
|
||||
<span>اعلانهای جدید سیستم</span> |
|
||||
</TooltipContent> |
|
||||
</Tooltip> |
|
||||
</div> |
|
||||
</div> |
|
||||
</TabsContent> |
|
||||
|
|
||||
{/* ─── تب ۵: ساختار و آیکونها ─── */} |
|
||||
<TabsContent value="data" className="mt-6 space-y-6"> |
|
||||
{/* آکاردئون */} |
|
||||
<div className="rounded-2xl border border-border-soft bg-card/40 p-6 space-y-4"> |
|
||||
<div> |
|
||||
<h2 className="text-base font-bold text-foreground">آکاردئون (Accordion)</h2> |
|
||||
<p className="text-xs text-grey-3 mt-0.5">بخشهای جمعشونده برای سوالات متداول یا تنظیمات فشرده</p> |
|
||||
</div> |
|
||||
|
|
||||
<Accordion type="single" collapsible defaultValue="item-1"> |
|
||||
<AccordionItem value="item-1"> |
|
||||
<AccordionTrigger className="text-xs font-semibold"> |
|
||||
چگونه یک صفحه جدید در داشبورد پیادهسازی کنم؟ |
|
||||
</AccordionTrigger> |
|
||||
<AccordionContent className="text-xs text-grey-3 leading-relaxed"> |
|
||||
کافی است کامپوننت صفحه خود را در پوشه <code className="text-primary font-mono">src/features/</code> ایجاد کنید و مسیر آن را در <code className="text-primary font-mono">src/app/router.tsx</code> رجیستر کنید. |
|
||||
</AccordionContent> |
|
||||
</AccordionItem> |
|
||||
|
|
||||
<AccordionItem value="item-2"> |
|
||||
<AccordionTrigger className="text-xs font-semibold"> |
|
||||
آیا برای مودالها نیاز به استیتهای محلی جداگانه دارم؟ |
|
||||
</AccordionTrigger> |
|
||||
<AccordionContent className="text-xs text-grey-3 leading-relaxed"> |
|
||||
خیر! با استفاده از هوک <code className="text-primary font-mono">usePanelRouting</code> میتوانید تنها با فراخوانی <code className="text-primary font-mono">openModal('name', id)</code> مودالها را از هر کجای برنامه با URL مدیریت کنید. |
|
||||
</AccordionContent> |
|
||||
</AccordionItem> |
|
||||
</Accordion> |
|
||||
</div> |
|
||||
|
|
||||
{/* اسکلتون لودینگ و آواتار */} |
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> |
|
||||
<div className="rounded-2xl border border-border-soft bg-card/40 p-6 space-y-3"> |
|
||||
<h3 className="text-xs font-bold text-foreground">اسکلتون بارگذاری (Skeleton)</h3> |
|
||||
<div className="space-y-2"> |
|
||||
<Skeleton className="h-4 w-3/4" /> |
|
||||
<Skeleton className="h-3 w-1/2" /> |
|
||||
<Skeleton className="h-10 w-full rounded-lg" /> |
|
||||
</div> |
|
||||
</div> |
|
||||
|
|
||||
<div className="rounded-2xl border border-border-soft bg-card/40 p-6 space-y-3"> |
|
||||
<h3 className="text-xs font-bold text-foreground">آواتارها (Avatar)</h3> |
|
||||
<div className="flex items-center gap-3"> |
|
||||
<Avatar letter="ع" className="size-10 bg-primary text-sm font-bold" /> |
|
||||
<Avatar letter="س" className="size-10 bg-emerald-600 text-sm font-bold" /> |
|
||||
<Avatar letter="م" className="size-10 bg-amber-600 text-sm font-bold" /> |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
|
|
||||
{/* ویترین آیکونهای SVG توکار */} |
|
||||
<div className="rounded-2xl border border-border-soft bg-card/40 p-6 space-y-4"> |
|
||||
<div> |
|
||||
<h2 className="text-base font-bold text-foreground">آیکونهای توکار سیستم (Built-in SVG Icons)</h2> |
|
||||
<p className="text-xs text-grey-3 mt-0.5"> |
|
||||
استفاده سریع با کامپوننت سبک: <code className="font-mono text-primary text-xs"><Ic name="..." /></code> |
|
||||
</p> |
|
||||
</div> |
|
||||
|
|
||||
<div className="grid grid-cols-3 gap-2.5 sm:grid-cols-6 md:grid-cols-8"> |
|
||||
{availableIcons.map((ic) => ( |
|
||||
<div |
|
||||
key={ic} |
|
||||
onClick={() => copyToClipboard(`<Ic name="${ic}" className="size-4" />`)} |
|
||||
title="کلیک برای کپی تگ" |
|
||||
className="flex flex-col items-center justify-center rounded-xl border border-border-soft bg-card/60 p-3 cursor-pointer transition-colors hover:border-primary/40 hover:bg-white/6 group" |
|
||||
> |
|
||||
<Ic name={ic} className="size-5 text-grey-2 group-hover:text-primary transition-colors" /> |
|
||||
<span className="mt-2 truncate text-[10.5px] font-mono text-grey-4 group-hover:text-foreground"> |
|
||||
{ic} |
|
||||
</span> |
|
||||
</div> |
|
||||
))} |
|
||||
</div> |
|
||||
</div> |
|
||||
</TabsContent> |
|
||||
</Tabs> |
|
||||
</div> |
|
||||
) |
|
||||
} |
|
||||
@ -1,224 +0,0 @@ |
|||||
import { useState, useEffect } from 'react' |
|
||||
import { |
|
||||
Dialog, |
|
||||
DialogContent, |
|
||||
DialogHeader, |
|
||||
DialogTitle, |
|
||||
DialogDescription, |
|
||||
DialogFooter, |
|
||||
} from '@/components/ui/dialog' |
|
||||
import { Input } from '@/components/ui/input' |
|
||||
import { Button } from '@/components/ui/button' |
|
||||
import { Textarea } from '@/components/ui/textarea' |
|
||||
import { |
|
||||
Select, |
|
||||
SelectContent, |
|
||||
SelectItem, |
|
||||
SelectTrigger, |
|
||||
SelectValue, |
|
||||
} from '@/components/ui/select' |
|
||||
import { createUser, updateUser, getUser } from '@/services/users-api' |
|
||||
import type { User, UserRole, UserStatus } from '@/types' |
|
||||
|
|
||||
interface UserModalProps { |
|
||||
open: boolean |
|
||||
userId?: string | null |
|
||||
onOpenChange: (open: boolean) => void |
|
||||
onSuccess?: (user: User) => void |
|
||||
} |
|
||||
|
|
||||
export default function UserModal({ |
|
||||
open, |
|
||||
userId, |
|
||||
onOpenChange, |
|
||||
onSuccess, |
|
||||
}: UserModalProps) { |
|
||||
const isEditing = Boolean(userId && userId !== 'new') |
|
||||
|
|
||||
const [name, setName] = useState('') |
|
||||
const [email, setEmail] = useState('') |
|
||||
const [phone, setPhone] = useState('') |
|
||||
const [role, setRole] = useState<UserRole>('editor') |
|
||||
const [status, setStatus] = useState<UserStatus>('active') |
|
||||
const [bio, setBio] = useState('') |
|
||||
const [loading, setLoading] = useState(false) |
|
||||
const [submitting, setSubmitting] = useState(false) |
|
||||
const [error, setError] = useState<string | null>(null) |
|
||||
|
|
||||
useEffect(() => { |
|
||||
if (open) { |
|
||||
setError(null) |
|
||||
if (isEditing && userId) { |
|
||||
setLoading(true) |
|
||||
getUser(userId) |
|
||||
.then((u) => { |
|
||||
setName(u.name) |
|
||||
setEmail(u.email) |
|
||||
setPhone(u.phone || '') |
|
||||
setRole(u.role) |
|
||||
setStatus(u.status) |
|
||||
setBio(u.bio || '') |
|
||||
}) |
|
||||
.catch(() => setError('دریافت اطلاعات کاربر با خطا مواجه شد.')) |
|
||||
.finally(() => setLoading(false)) |
|
||||
} else { |
|
||||
// Reset form for create
|
|
||||
setName('') |
|
||||
setEmail('') |
|
||||
setPhone('') |
|
||||
setRole('editor') |
|
||||
setStatus('active') |
|
||||
setBio('') |
|
||||
} |
|
||||
} |
|
||||
}, [open, isEditing, userId]) |
|
||||
|
|
||||
const handleSubmit = async (e: React.FormEvent) => { |
|
||||
e.preventDefault() |
|
||||
if (!name.trim()) { |
|
||||
setError('نام و نام خانوادگی الزامی است.') |
|
||||
return |
|
||||
} |
|
||||
if (!email.trim()) { |
|
||||
setError('ایمیل الزامی است.') |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
setSubmitting(true) |
|
||||
setError(null) |
|
||||
|
|
||||
let result: User |
|
||||
if (isEditing && userId) { |
|
||||
result = await updateUser(userId, { name, email, phone, role, status, bio }) |
|
||||
} else { |
|
||||
result = await createUser({ name, email, phone, role, status, bio }) |
|
||||
} |
|
||||
|
|
||||
onSuccess?.(result) |
|
||||
onOpenChange(false) |
|
||||
} catch (err: any) { |
|
||||
setError(err?.message || 'عملیات با خطا مواجه شد.') |
|
||||
} finally { |
|
||||
setSubmitting(false) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
return ( |
|
||||
<Dialog open={open} onOpenChange={onOpenChange}> |
|
||||
<DialogContent className="sm:max-w-[500px]"> |
|
||||
<DialogHeader className="text-start"> |
|
||||
<DialogTitle className="text-lg font-bold text-foreground"> |
|
||||
{isEditing ? 'ویرایش اطلاعات کاربر' : 'افزودن کاربر جدید'} |
|
||||
</DialogTitle> |
|
||||
<DialogDescription className="text-xs text-grey-3"> |
|
||||
{isEditing |
|
||||
? 'اطلاعات مورد نظر را تغییر داده و دکمه ذخیره را بزنید.' |
|
||||
: 'مشخصات کاربر جدید را تکمیل نمایید.'} |
|
||||
</DialogDescription> |
|
||||
</DialogHeader> |
|
||||
|
|
||||
{loading ? ( |
|
||||
<div className="py-8 text-center text-sm text-grey-3">در حال بارگذاری اطلاعات...</div> |
|
||||
) : ( |
|
||||
<form onSubmit={handleSubmit} className="space-y-4 py-2"> |
|
||||
{error && ( |
|
||||
<div className="rounded-lg bg-rose-500/10 border border-rose-500/30 p-3 text-xs text-rose-400"> |
|
||||
{error} |
|
||||
</div> |
|
||||
)} |
|
||||
|
|
||||
<div className="space-y-1.5"> |
|
||||
<label className="text-xs font-semibold text-grey-2">نام و نام خانوادگی *</label> |
|
||||
<Input |
|
||||
placeholder="مثلاً: علی رضایی" |
|
||||
value={name} |
|
||||
onChange={(e) => setName(e.target.value)} |
|
||||
required |
|
||||
/> |
|
||||
</div> |
|
||||
|
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> |
|
||||
<div className="space-y-1.5"> |
|
||||
<label className="text-xs font-semibold text-grey-2">ایمیل *</label> |
|
||||
<Input |
|
||||
dir="ltr" |
|
||||
type="email" |
|
||||
placeholder="name@example.com" |
|
||||
value={email} |
|
||||
onChange={(e) => setEmail(e.target.value)} |
|
||||
required |
|
||||
/> |
|
||||
</div> |
|
||||
|
|
||||
<div className="space-y-1.5"> |
|
||||
<label className="text-xs font-semibold text-grey-2">شماره تماس</label> |
|
||||
<Input |
|
||||
dir="ltr" |
|
||||
placeholder="0912..." |
|
||||
value={phone} |
|
||||
onChange={(e) => setPhone(e.target.value)} |
|
||||
/> |
|
||||
</div> |
|
||||
</div> |
|
||||
|
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> |
|
||||
<div className="space-y-1.5"> |
|
||||
<label className="text-xs font-semibold text-grey-2">نقش کاربری</label> |
|
||||
<Select value={role} onValueChange={(val: UserRole) => setRole(val)}> |
|
||||
<SelectTrigger> |
|
||||
<SelectValue placeholder="انتخاب نقش" /> |
|
||||
</SelectTrigger> |
|
||||
<SelectContent> |
|
||||
<SelectItem value="admin">مدیر کل</SelectItem> |
|
||||
<SelectItem value="manager">مدیر بخش</SelectItem> |
|
||||
<SelectItem value="editor">ویرایشگر</SelectItem> |
|
||||
<SelectItem value="viewer">مشاهدهکننده</SelectItem> |
|
||||
</SelectContent> |
|
||||
</Select> |
|
||||
</div> |
|
||||
|
|
||||
<div className="space-y-1.5"> |
|
||||
<label className="text-xs font-semibold text-grey-2">وضعیت حساب</label> |
|
||||
<Select value={status} onValueChange={(val: UserStatus) => setStatus(val)}> |
|
||||
<SelectTrigger> |
|
||||
<SelectValue placeholder="انتخاب وضعیت" /> |
|
||||
</SelectTrigger> |
|
||||
<SelectContent> |
|
||||
<SelectItem value="active">فعال</SelectItem> |
|
||||
<SelectItem value="pending">در انتظار تایید</SelectItem> |
|
||||
<SelectItem value="inactive">غیرفعال</SelectItem> |
|
||||
</SelectContent> |
|
||||
</Select> |
|
||||
</div> |
|
||||
</div> |
|
||||
|
|
||||
<div className="space-y-1.5"> |
|
||||
<label className="text-xs font-semibold text-grey-2">توضیحات یا یادداشت</label> |
|
||||
<Textarea |
|
||||
placeholder="توضیحات اختیاری درباره این کاربر..." |
|
||||
value={bio} |
|
||||
onChange={(e) => setBio(e.target.value)} |
|
||||
rows={3} |
|
||||
/> |
|
||||
</div> |
|
||||
|
|
||||
<DialogFooter className="mt-6 gap-2 sm:justify-start"> |
|
||||
<Button type="submit" disabled={submitting}> |
|
||||
{submitting ? 'در حال ثبت...' : isEditing ? 'ذخیره تغییرات' : 'ایجاد کاربر'} |
|
||||
</Button> |
|
||||
<Button |
|
||||
type="button" |
|
||||
variant="outline" |
|
||||
disabled={submitting} |
|
||||
onClick={() => onOpenChange(false)} |
|
||||
> |
|
||||
انصراف |
|
||||
</Button> |
|
||||
</DialogFooter> |
|
||||
</form> |
|
||||
)} |
|
||||
</DialogContent> |
|
||||
</Dialog> |
|
||||
) |
|
||||
} |
|
||||
@ -1,230 +0,0 @@ |
|||||
import { useState } from 'react' |
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' |
|
||||
import { getUsers, deleteUser } from '@/services/users-api' |
|
||||
import { Ic } from '@/icons' |
|
||||
import { Button } from '@/components/ui/button' |
|
||||
import { Input } from '@/components/ui/input' |
|
||||
import { Badge } from '@/components/ui/badge' |
|
||||
import { |
|
||||
Table, |
|
||||
TableHeader, |
|
||||
TableBody, |
|
||||
TableHead, |
|
||||
TableRow, |
|
||||
TableCell, |
|
||||
} from '@/components/ui/table' |
|
||||
import TablePagination from '@/components/shared/TablePagination' |
|
||||
import TableRowsSkeleton from '@/components/shared/TableRowsSkeleton' |
|
||||
import DeleteConfirmDialog from '@/components/shared/DeleteConfirmDialog' |
|
||||
import UserModal from './UserModal' |
|
||||
import { usePanelRouting } from '@/hooks/use-panel-routing' |
|
||||
import type { User } from '@/types' |
|
||||
|
|
||||
export default function UsersPage() { |
|
||||
const queryClient = useQueryClient() |
|
||||
const { openPanel, openModal, closeModal, activeModal } = usePanelRouting() |
|
||||
|
|
||||
const [page, setPage] = useState(1) |
|
||||
const [search, setSearch] = useState('') |
|
||||
const [deleteTarget, setDeleteTarget] = useState<User | null>(null) |
|
||||
|
|
||||
const { data, isLoading } = useQuery({ |
|
||||
queryKey: ['users', { page, search }], |
|
||||
queryFn: () => getUsers({ page, pageSize: 10, search }), |
|
||||
}) |
|
||||
|
|
||||
const deleteMutation = useMutation({ |
|
||||
mutationFn: (id: string) => deleteUser(id), |
|
||||
onSuccess: () => { |
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] }) |
|
||||
setDeleteTarget(null) |
|
||||
}, |
|
||||
}) |
|
||||
|
|
||||
const isUserModalOpen = activeModal?.type === 'user' |
|
||||
|
|
||||
const roleLabel = (role: string) => { |
|
||||
switch (role) { |
|
||||
case 'admin': return 'مدیر کل' |
|
||||
case 'manager': return 'مدیر بخش' |
|
||||
case 'editor': return 'ویرایشگر' |
|
||||
case 'viewer': return 'مشاهدهکننده' |
|
||||
default: return role |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
const statusBadge = (status: string) => { |
|
||||
switch (status) { |
|
||||
case 'active': |
|
||||
return <Badge className="bg-emerald-500/15 text-emerald-400 border-emerald-500/20">فعال</Badge> |
|
||||
case 'pending': |
|
||||
return <Badge className="bg-amber-500/15 text-amber-400 border-amber-500/20">در انتظار</Badge> |
|
||||
default: |
|
||||
return <Badge className="bg-rose-500/15 text-rose-400 border-rose-500/20">غیرفعال</Badge> |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
return ( |
|
||||
<div className="space-y-5 pt-2"> |
|
||||
{/* هدر صفحه */} |
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> |
|
||||
<div> |
|
||||
<h1 className="text-xl font-bold text-foreground">مدیریت کاربران</h1> |
|
||||
<p className="mt-0.5 text-xs text-grey-3"> |
|
||||
مشاهده، جستجو، ایجاد و مدیریت دسترسیهای کاربران سیستم |
|
||||
</p> |
|
||||
</div> |
|
||||
<Button onClick={() => openModal('user', 'new')} className="gap-2"> |
|
||||
<Ic name="plus" className="size-4" /> |
|
||||
افزودن کاربر جدید |
|
||||
</Button> |
|
||||
</div> |
|
||||
|
|
||||
{/* نوار جستجو و ابزارها */} |
|
||||
<div className="flex flex-col gap-3 rounded-2xl border border-border-soft bg-card/40 p-3 sm:flex-row sm:items-center sm:justify-between"> |
|
||||
<div className="relative flex-1 sm:max-w-xs"> |
|
||||
<Ic name="search" className="absolute start-3 top-1/2 size-4 -translate-y-1/2 text-grey-3" /> |
|
||||
<Input |
|
||||
value={search} |
|
||||
onChange={(e) => { |
|
||||
setSearch(e.target.value) |
|
||||
setPage(1) |
|
||||
}} |
|
||||
placeholder="جستجوی نام یا ایمیل..." |
|
||||
className="ps-9 text-xs" |
|
||||
/> |
|
||||
</div> |
|
||||
<div className="text-xs text-grey-3"> |
|
||||
مجموع: <span className="font-bold text-foreground">{data?.total || 0}</span> کاربر |
|
||||
</div> |
|
||||
</div> |
|
||||
|
|
||||
{/* جدول کاربران */} |
|
||||
<div className="overflow-hidden rounded-2xl border border-border-soft bg-card/40"> |
|
||||
<Table> |
|
||||
<TableHeader> |
|
||||
<TableRow> |
|
||||
<TableHead className="w-12 text-center">#</TableHead> |
|
||||
<TableHead>نام کاربر</TableHead> |
|
||||
<TableHead>ایمیل</TableHead> |
|
||||
<TableHead>شماره تماس</TableHead> |
|
||||
<TableHead>نقش</TableHead> |
|
||||
<TableHead>وضعیت</TableHead> |
|
||||
<TableHead>تاریخ عضویت</TableHead> |
|
||||
<TableHead className="w-24 text-center">عملیات</TableHead> |
|
||||
</TableRow> |
|
||||
</TableHeader> |
|
||||
<TableBody> |
|
||||
{isLoading ? ( |
|
||||
<TableRowsSkeleton cols={8} rows={5} /> |
|
||||
) : !data?.items || data.items.length === 0 ? ( |
|
||||
<TableRow> |
|
||||
<TableCell colSpan={8} className="py-10 text-center text-xs text-grey-3"> |
|
||||
کاربری با این مشخصات یافت نشد. |
|
||||
</TableCell> |
|
||||
</TableRow> |
|
||||
) : ( |
|
||||
data.items.map((user, idx) => ( |
|
||||
<TableRow |
|
||||
key={user.id} |
|
||||
className="cursor-pointer transition-colors hover:bg-white/4" |
|
||||
onClick={() => openPanel('user', user.id)} |
|
||||
> |
|
||||
<TableCell className="text-center text-xs text-grey-4 font-mono"> |
|
||||
{(page - 1) * 10 + idx + 1} |
|
||||
</TableCell> |
|
||||
<TableCell> |
|
||||
<div className="flex items-center gap-2.5"> |
|
||||
<div className="flex size-7 items-center justify-center rounded-full bg-primary/20 text-xs font-bold text-primary"> |
|
||||
{user.name.charAt(0)} |
|
||||
</div> |
|
||||
<span className="font-semibold text-foreground text-xs">{user.name}</span> |
|
||||
</div> |
|
||||
</TableCell> |
|
||||
<TableCell dir="ltr" className="text-start text-xs text-grey-3 font-mono"> |
|
||||
{user.email} |
|
||||
</TableCell> |
|
||||
<TableCell dir="ltr" className="text-start text-xs text-grey-3"> |
|
||||
{user.phone || '-'} |
|
||||
</TableCell> |
|
||||
<TableCell className="text-xs text-grey-2"> |
|
||||
{roleLabel(user.role)} |
|
||||
</TableCell> |
|
||||
<TableCell>{statusBadge(user.status)}</TableCell> |
|
||||
<TableCell className="text-xs text-grey-3">{user.createdAt}</TableCell> |
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}> |
|
||||
<div className="flex items-center justify-center gap-1"> |
|
||||
<Button |
|
||||
variant="ghost" |
|
||||
size="icon" |
|
||||
className="size-7 text-grey-3 hover:text-foreground" |
|
||||
title="مشاهده جزئیات در پنل" |
|
||||
onClick={() => openPanel('user', user.id)} |
|
||||
> |
|
||||
<Ic name="eye" className="size-3.5" /> |
|
||||
</Button> |
|
||||
<Button |
|
||||
variant="ghost" |
|
||||
size="icon" |
|
||||
className="size-7 text-grey-3 hover:text-foreground" |
|
||||
title="ویرایش در مودال" |
|
||||
onClick={() => openModal('user', user.id)} |
|
||||
> |
|
||||
<Ic name="pencil" className="size-3.5" /> |
|
||||
</Button> |
|
||||
<Button |
|
||||
variant="ghost" |
|
||||
size="icon" |
|
||||
className="size-7 text-rose-400/80 hover:bg-rose-500/10 hover:text-rose-400" |
|
||||
title="حذف" |
|
||||
onClick={() => setDeleteTarget(user)} |
|
||||
> |
|
||||
<Ic name="trash" className="size-3.5" /> |
|
||||
</Button> |
|
||||
</div> |
|
||||
</TableCell> |
|
||||
</TableRow> |
|
||||
)) |
|
||||
)} |
|
||||
</TableBody> |
|
||||
</Table> |
|
||||
|
|
||||
{/* صفحهبندی */} |
|
||||
{data && data.totalPages > 1 && ( |
|
||||
<div className="p-3 border-t border-border-soft"> |
|
||||
<TablePagination |
|
||||
currentPage={page} |
|
||||
totalPages={data.totalPages} |
|
||||
onPageChange={setPage} |
|
||||
/> |
|
||||
</div> |
|
||||
)} |
|
||||
</div> |
|
||||
|
|
||||
{/* مودال ایجاد / ویرایش کاربر */} |
|
||||
<UserModal |
|
||||
open={isUserModalOpen} |
|
||||
userId={activeModal?.id} |
|
||||
onOpenChange={(isOpen) => { |
|
||||
if (!isOpen) closeModal() |
|
||||
}} |
|
||||
onSuccess={() => { |
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] }) |
|
||||
}} |
|
||||
/> |
|
||||
|
|
||||
{/* دیالوگ تأیید حذف */} |
|
||||
<DeleteConfirmDialog |
|
||||
open={Boolean(deleteTarget)} |
|
||||
onOpenChange={(o) => !o && setDeleteTarget(null)} |
|
||||
title={`آیا از حذف "${deleteTarget?.name}" اطمینان دارید؟`} |
|
||||
isLoading={deleteMutation.isPending} |
|
||||
onConfirm={() => { |
|
||||
if (deleteTarget) { |
|
||||
deleteMutation.mutate(deleteTarget.id) |
|
||||
} |
|
||||
}} |
|
||||
/> |
|
||||
</div> |
|
||||
) |
|
||||
} |
|
||||
@ -1,154 +0,0 @@ |
|||||
import { apiFetch } from '@/services/http' |
|
||||
import type { SampleItem, PaginatedResponse, PaginationParams } from '@/types' |
|
||||
|
|
||||
/* ────────────────────────────────────────────────────────────────────────── |
|
||||
* سرویس نمونه CRUD برای استفاده به عنوان الگو در سایر بخشهای پروژه |
|
||||
* |
|
||||
* در صورتی که بکاند واقعی در دسترس نباشد، این سرویس با دادههای شبیهسازیشده (Mock) |
|
||||
* کار میکند تا ظاهر، مودالها، فیلترها و جداول در محیط توسعه بدون قطعی کار کنند. |
|
||||
* ────────────────────────────────────────────────────────────────────────── */ |
|
||||
|
|
||||
// دادههای تستی محلی برای تست اولیه تمپلیت
|
|
||||
let mockItems: SampleItem[] = [ |
|
||||
{ |
|
||||
id: 'item-1', |
|
||||
title: 'سیستم گزارشگیری هوشمند', |
|
||||
category: 'نرمافزار', |
|
||||
status: 'published', |
|
||||
price: 1500000, |
|
||||
createdAt: '1403/06/15', |
|
||||
updatedAt: '1403/06/20', |
|
||||
description: 'ماژول استخراج خودکار گزارشهای تحلیلی و اکسل برای مدیران ارشد', |
|
||||
tags: ['گزارش', 'داشبورد', 'تحلیل'], |
|
||||
}, |
|
||||
{ |
|
||||
id: 'item-2', |
|
||||
title: 'درگاه پرداخت سپهر', |
|
||||
category: 'مالی', |
|
||||
status: 'published', |
|
||||
price: 3200000, |
|
||||
createdAt: '1403/06/10', |
|
||||
updatedAt: '1403/06/18', |
|
||||
description: 'اتصال خودکار به سوئیچ شاپرک با قابلیت تسویه آنی', |
|
||||
tags: ['مالی', 'پرداخت', 'بانک'], |
|
||||
}, |
|
||||
{ |
|
||||
id: 'item-3', |
|
||||
title: 'سرویس اعلانهای پیامکی', |
|
||||
category: 'ارتباطات', |
|
||||
status: 'draft', |
|
||||
price: 850000, |
|
||||
createdAt: '1403/06/05', |
|
||||
updatedAt: '1403/06/12', |
|
||||
description: 'ارسال پیامکهای ورود دو مرحلهای و رویدادهای مهم سیستم', |
|
||||
tags: ['پیامک', 'اعلان', 'امنیت'], |
|
||||
}, |
|
||||
{ |
|
||||
id: 'item-4', |
|
||||
title: 'اتصال به وبسرویس گمرک', |
|
||||
category: 'لجستیک', |
|
||||
status: 'archived', |
|
||||
price: 4900000, |
|
||||
createdAt: '1403/05/22', |
|
||||
updatedAt: '1403/06/01', |
|
||||
description: 'استعلام خودکار ترخیص کالا و کد رهگیری پستی', |
|
||||
tags: ['گمرک', 'حملونقل'], |
|
||||
}, |
|
||||
{ |
|
||||
id: 'item-5', |
|
||||
title: 'پنل پشتیبانی تیکتینگ', |
|
||||
category: 'پشتیبانی', |
|
||||
status: 'published', |
|
||||
price: 2100000, |
|
||||
createdAt: '1403/06/18', |
|
||||
updatedAt: '1403/06/21', |
|
||||
description: 'سیستم ثبت و پیگیری درخواستهای کاربران با امکان ضمیمه فایل', |
|
||||
tags: ['پشتیبانی', 'تیکت', 'CRM'], |
|
||||
}, |
|
||||
] |
|
||||
|
|
||||
export async function getSampleItems(params?: PaginationParams): Promise<PaginatedResponse<SampleItem>> { |
|
||||
try { |
|
||||
const query = new URLSearchParams() |
|
||||
if (params?.page) query.set('page', String(params.page)) |
|
||||
if (params?.pageSize) query.set('page_size', String(params.pageSize)) |
|
||||
if (params?.search) query.set('search', params.search) |
|
||||
|
|
||||
return await apiFetch<PaginatedResponse<SampleItem>>(`items/?${query.toString()}`) |
|
||||
} catch { |
|
||||
// Fallback to mock data
|
|
||||
let filtered = [...mockItems] |
|
||||
if (params?.search) { |
|
||||
const q = params.search.trim().toLowerCase() |
|
||||
filtered = filtered.filter( |
|
||||
(it) => it.title.toLowerCase().includes(q) || it.category.toLowerCase().includes(q), |
|
||||
) |
|
||||
} |
|
||||
const page = params?.page ?? 1 |
|
||||
const pageSize = params?.pageSize ?? 10 |
|
||||
const start = (page - 1) * pageSize |
|
||||
const paged = filtered.slice(start, start + pageSize) |
|
||||
|
|
||||
return { |
|
||||
items: paged, |
|
||||
total: filtered.length, |
|
||||
page, |
|
||||
pageSize, |
|
||||
totalPages: Math.ceil(filtered.length / pageSize), |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
export async function getSampleItem(id: string): Promise<SampleItem> { |
|
||||
try { |
|
||||
return await apiFetch<SampleItem>(`items/${id}/`) |
|
||||
} catch { |
|
||||
const found = mockItems.find((it) => it.id === id) |
|
||||
if (!found) throw new Error('آیتم یافت نشد') |
|
||||
return found |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
export async function createSampleItem(data: Omit<SampleItem, 'id' | 'createdAt' | 'updatedAt'>): Promise<SampleItem> { |
|
||||
try { |
|
||||
return await apiFetch<SampleItem>('items/', { |
|
||||
method: 'POST', |
|
||||
body: data, |
|
||||
}) |
|
||||
} catch { |
|
||||
const newItem: SampleItem = { |
|
||||
...data, |
|
||||
id: `item-${Date.now()}`, |
|
||||
createdAt: new Date().toLocaleDateString('fa-IR'), |
|
||||
updatedAt: new Date().toLocaleDateString('fa-IR'), |
|
||||
} |
|
||||
mockItems = [newItem, ...mockItems] |
|
||||
return newItem |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
export async function updateSampleItem(id: string, data: Partial<SampleItem>): Promise<SampleItem> { |
|
||||
try { |
|
||||
return await apiFetch<SampleItem>(`items/${id}/`, { |
|
||||
method: 'PUT', |
|
||||
body: data, |
|
||||
}) |
|
||||
} catch { |
|
||||
const idx = mockItems.findIndex((it) => it.id === id) |
|
||||
if (idx === -1) throw new Error('آیتم یافت نشد') |
|
||||
mockItems[idx] = { |
|
||||
...mockItems[idx], |
|
||||
...data, |
|
||||
updatedAt: new Date().toLocaleDateString('fa-IR'), |
|
||||
} |
|
||||
return mockItems[idx] |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
export async function deleteSampleItem(id: string): Promise<void> { |
|
||||
try { |
|
||||
await apiFetch(`items/${id}/`, { method: 'DELETE' }) |
|
||||
} catch { |
|
||||
mockItems = mockItems.filter((it) => it.id !== id) |
|
||||
} |
|
||||
} |
|
||||
@ -1,120 +0,0 @@ |
|||||
import { apiFetch } from '@/services/http' |
|
||||
import type { User, PaginatedResponse, PaginationParams } from '@/types' |
|
||||
|
|
||||
let mockUsers: User[] = [ |
|
||||
{ |
|
||||
id: '1', |
|
||||
name: 'علیرضا محمدی', |
|
||||
email: 'alireza@example.com', |
|
||||
phone: '09121234567', |
|
||||
role: 'admin', |
|
||||
status: 'active', |
|
||||
createdAt: '1403/01/10', |
|
||||
lastActive: 'هماکنون آنلاین', |
|
||||
bio: 'مدیر ارشد فنی و توسعهدهنده سیستم', |
|
||||
}, |
|
||||
{ |
|
||||
id: '2', |
|
||||
name: 'سارا رضایی', |
|
||||
email: 'sara@example.com', |
|
||||
phone: '09129876543', |
|
||||
role: 'manager', |
|
||||
status: 'active', |
|
||||
createdAt: '1403/02/15', |
|
||||
lastActive: '۲ ساعت پیش', |
|
||||
bio: 'مدیر محصول و سرپرست تیم طراحی', |
|
||||
}, |
|
||||
{ |
|
||||
id: '3', |
|
||||
name: 'محمد حسینی', |
|
||||
email: 'hosseini@example.com', |
|
||||
phone: '09135554433', |
|
||||
role: 'editor', |
|
||||
status: 'pending', |
|
||||
createdAt: '1403/05/20', |
|
||||
lastActive: 'دیروز', |
|
||||
bio: 'کارشناس ثبت محتوا و بررسی مستندات', |
|
||||
}, |
|
||||
{ |
|
||||
id: '4', |
|
||||
name: 'مریم کمالی', |
|
||||
email: 'kamali@example.com', |
|
||||
phone: '09351112233', |
|
||||
role: 'viewer', |
|
||||
status: 'inactive', |
|
||||
createdAt: '1403/04/01', |
|
||||
lastActive: '۱ هفته پیش', |
|
||||
bio: 'حسابدار و ناظر مالی پروژه', |
|
||||
}, |
|
||||
] |
|
||||
|
|
||||
export async function getUsers(params?: PaginationParams): Promise<PaginatedResponse<User>> { |
|
||||
try { |
|
||||
const query = new URLSearchParams() |
|
||||
if (params?.page) query.set('page', String(params.page)) |
|
||||
if (params?.pageSize) query.set('page_size', String(params.pageSize)) |
|
||||
if (params?.search) query.set('search', params.search) |
|
||||
return await apiFetch<PaginatedResponse<User>>(`users/?${query.toString()}`) |
|
||||
} catch { |
|
||||
let filtered = [...mockUsers] |
|
||||
if (params?.search) { |
|
||||
const q = params.search.trim().toLowerCase() |
|
||||
filtered = filtered.filter( |
|
||||
(u) => u.name.toLowerCase().includes(q) || u.email.toLowerCase().includes(q), |
|
||||
) |
|
||||
} |
|
||||
const page = params?.page ?? 1 |
|
||||
const pageSize = params?.pageSize ?? 10 |
|
||||
const start = (page - 1) * pageSize |
|
||||
return { |
|
||||
items: filtered.slice(start, start + pageSize), |
|
||||
total: filtered.length, |
|
||||
page, |
|
||||
pageSize, |
|
||||
totalPages: Math.ceil(filtered.length / pageSize), |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
export async function getUser(id: string): Promise<User> { |
|
||||
try { |
|
||||
return await apiFetch<User>(`users/${id}/`) |
|
||||
} catch { |
|
||||
const found = mockUsers.find((u) => u.id === id) |
|
||||
if (!found) throw new Error('کاربر یافت نشد') |
|
||||
return found |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
export async function createUser(data: Omit<User, 'id' | 'createdAt'>): Promise<User> { |
|
||||
try { |
|
||||
return await apiFetch<User>('users/', { method: 'POST', body: data }) |
|
||||
} catch { |
|
||||
const newUser: User = { |
|
||||
...data, |
|
||||
id: String(Date.now()), |
|
||||
createdAt: new Date().toLocaleDateString('fa-IR'), |
|
||||
} |
|
||||
mockUsers = [newUser, ...mockUsers] |
|
||||
return newUser |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
export async function updateUser(id: string, data: Partial<User>): Promise<User> { |
|
||||
try { |
|
||||
return await apiFetch<User>(`users/${id}/`, { method: 'PUT', body: data }) |
|
||||
} catch { |
|
||||
const idx = mockUsers.findIndex((u) => u.id === id) |
|
||||
if (idx === -1) throw new Error('کاربر یافت نشد') |
|
||||
mockUsers[idx] = { ...mockUsers[idx], ...data } |
|
||||
return mockUsers[idx] |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
export async function deleteUser(id: string): Promise<void> { |
|
||||
try { |
|
||||
await apiFetch(`users/${id}/`, { method: 'DELETE' }) |
|
||||
} catch { |
|
||||
mockUsers = mockUsers.filter((u) => u.id !== id) |
|
||||
} |
|
||||
} |
|
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue