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
-
53src/components/layout/PanelSlot.tsx
-
12src/config/navigation.ts
-
4src/features/auth/LoginPage.tsx
-
346src/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 { Button } from '@/components/ui/button' |
|||
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() { |
|||
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 ( |
|||
<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"> |
|||
به پنل مدیریت خوش آمدید. خلاصه وضعیت سیستم و گزارشهای سریع را در این صفحه مشاهده نمایید. |
|||
{/* سربرگ خوشآمدگویی */} |
|||
<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"> |
|||
<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> |
|||
<p className="text-xs text-grey-3"> |
|||
پنل مدیریت عقیله پاکسازی شد و آماده اضافه کردن ویجتها، جداول و ماژولهای جدید شماست. |
|||
</p> |
|||
</div> |
|||
<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> |
|||
</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 |
|||
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> |
|||
</div> |
|||
<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 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="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> |
|||
</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 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> |
|||
<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> |
|||
<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> |
|||
<Ic name="chevronLeft" className="size-4 text-grey-4" /> |
|||
</button> |
|||
{/* کارت مشخصات اتصال بکاند */} |
|||
<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 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 className="flex justify-between text-grey-2"> |
|||
<span className="text-grey-3">API Prefix:</span> |
|||
<span>/api</span> |
|||
</div> |
|||
<div className="flex justify-between text-grey-2"> |
|||
<span className="text-grey-3">Auth Header:</span> |
|||
<span>Token (DRF)</span> |
|||
</div> |
|||
</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="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 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 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> |
|||
<p className="text-[11.5px] leading-relaxed text-grey-3"> |
|||
این قالب با قابلیت باز شدن پنل جزئیات (Drawer) از چپ و مدیریت انواع مودالها طراحی شده است. |
|||
</p> |
|||
</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> |
|||
) |
|||
} |
|||
@ -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