Browse Source

feat: implement core layout, authentication, navigation, and global search components

master
parent
commit
2161b6bf2a
  1. 12
      src/app/router.tsx
  2. 6
      src/components/layout/AppSidebar.tsx
  3. 41
      src/components/layout/PanelSlot.tsx
  4. 12
      src/config/navigation.ts
  5. 4
      src/features/auth/LoginPage.tsx
  6. 332
      src/features/dashboard/DashboardHome.tsx
  7. 86
      src/features/filters/FilterDrawer.tsx
  8. 18
      src/features/global-search/GlobalSearchDialog.tsx
  9. 217
      src/features/items/ItemsPage.tsx
  10. 235
      src/features/items/SampleItemModal.tsx
  11. 113
      src/features/panels/SampleDetailPanel.tsx
  12. 125
      src/features/panels/UserDetailPanel.tsx
  13. 121
      src/features/settings/SettingsPage.tsx
  14. 544
      src/features/showcase/ComponentsShowcase.tsx
  15. 224
      src/features/users/UserModal.tsx
  16. 230
      src/features/users/UsersPage.tsx
  17. 7
      src/services/auth.ts
  18. 154
      src/services/sample-api.ts
  19. 120
      src/services/users-api.ts
  20. 34
      src/types.ts

12
src/app/router.tsx

@ -3,13 +3,9 @@ import { Routes, Route, Navigate, Outlet } from 'react-router-dom'
import { RouteProgressStart, PageReady } from './nprogress'
import RequireAuth from '@/features/auth/RequireAuth'
/* لود تنبل (Lazy Loading) صفحات */
/* لود صفحات اصلی */
const LoginPage = lazy(() => import('@/features/auth/LoginPage'))
const DashboardHome = lazy(() => import('@/features/dashboard/DashboardHome'))
const UsersPage = lazy(() => import('@/features/users/UsersPage'))
const ItemsPage = lazy(() => import('@/features/items/ItemsPage'))
const SettingsPage = lazy(() => import('@/features/settings/SettingsPage'))
const ComponentsShowcase = lazy(() => import('@/features/showcase/ComponentsShowcase'))
export default function AppRouter() {
return (
@ -23,12 +19,8 @@ export default function AppRouter() {
{/* تمام مسیرهای محافظت‌شده پشت گارد امنیتی */}
<Route element={<RequireAuth><Outlet /></RequireAuth>}>
<Route path="/" element={<PageReady><DashboardHome /></PageReady>} />
<Route path="/users" element={<PageReady><UsersPage /></PageReady>} />
<Route path="/items" element={<PageReady><ItemsPage /></PageReady>} />
<Route path="/components" element={<PageReady><ComponentsShowcase /></PageReady>} />
<Route path="/settings" element={<PageReady><SettingsPage /></PageReady>} />
{/* مسیرهای نامعتبر → هدایت به صفحه اصلی */}
{/* هدایت مسیرهای ناشناخته به صفحه اصلی */}
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>

6
src/components/layout/AppSidebar.tsx

@ -70,7 +70,7 @@ function SidebarUserFooter() {
const firstLetter = user?.name?.[0] ?? user?.email?.[0]?.toUpperCase() ?? 'م'
return (
<SidebarFooter>
<SidebarFooter className="mt-auto">
<SidebarSeparator className="mb-1" />
<div className="flex items-center gap-2.5 px-2 py-1">
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary text-xs font-bold text-white">
@ -78,10 +78,10 @@ function SidebarUserFooter() {
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-semibold text-grey-1">
{user?.name?.trim() || 'مدیر سیستم'}
{user?.name?.trim() || 'مدیر کل سیستم'}
</div>
<div dir="ltr" className="truncate text-start text-[10px] text-grey-3">
{user?.email ?? 'admin@example.com'}
{user?.email ?? 'admin@gmail.com'}
</div>
</div>
</div>

41
src/components/layout/PanelSlot.tsx

@ -10,22 +10,16 @@ import {
SheetDescription,
} from '@/components/ui/sheet'
import { Sidebar, SidebarContext, useSidebar } from '@/components/ui/sidebar'
import UserDetailPanel from '@/features/panels/UserDetailPanel'
import SampleDetailPanel from '@/features/panels/SampleDetailPanel'
import FilterDrawer from '@/features/filters/FilterDrawer'
/*
* شل سمت چپ (RTL): پنل کشویی جزئیات رکوردها / دراور فیلترها (قرینهٔ AppSidebar).
*
* دسکتاپ 1280px Sidebar تمامارتفاع offcanvas از چپ اسلاید میکند بدون اینکه محتوای وسط فشرده شود.
* شل سمت چپ (RTL): اسلات پنل کشویی جزئیات رکوردها (قرینهٔ AppSidebar).
* دسکتاپ 1280px Sidebar تمامارتفاع offcanvas از چپ اسلاید میکند.
* زیر 1280px Sheet استاندارد به صورت Modal Drawer از چپ باز میشود.
*/
export default function PanelSlot() {
const isOverlay = useIsPanelOverlay()
const sidebar = useSidebar()
const { activePanel, drawerParam, closePanel, closeDrawer, isPanelOpen } = usePanelRouting()
const onClose = drawerParam ? closeDrawer : closePanel
const { activePanel, closePanel, isPanelOpen } = usePanelRouting()
const [activeState, setActiveState] = useState<{ type: string; id?: string } | null>(activePanel)
useEffect(() => {
@ -35,29 +29,20 @@ export default function PanelSlot() {
}
}, [activePanel])
/* رندر پنل مورد نظر بر اساس نوع موجود در URL */
/* رندر پنل مورد نظر */
const renderPanel = (type: string, id?: string) => {
switch (type) {
case 'user':
return <UserDetailPanel id={id} onClose={closePanel} />
case 'item':
return <SampleDetailPanel id={id} onClose={closePanel} />
case 'filters':
return <FilterDrawer onClose={closeDrawer} />
default:
return (
<div className="flex h-full flex-col p-5">
<div className="flex items-center justify-between border-b pb-3">
<span className="font-bold">پنل جزئیات</span>
<button onClick={closePanel} className="text-sm text-grey-3">بستن</button>
<div className="flex items-center justify-between border-b border-border-soft pb-3">
<span className="font-bold text-sm">پنل جزئیات ({type})</span>
<button onClick={closePanel} className="text-xs text-grey-3 hover:text-white">بستن</button>
</div>
<div className="flex flex-1 items-center justify-center text-sm text-grey-3">
پنل ناشناخته ({type})
<div className="flex flex-1 items-center justify-center text-xs text-grey-3">
شناسه رکورد: {id ?? 'نامشخص'}
</div>
</div>
)
}
}
const [mounted, setMounted] = useState(isPanelOpen)
useEffect(() => {
@ -75,21 +60,21 @@ export default function PanelSlot() {
const handleEsc = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return
if (useGlobalSearchStore.getState().open) return
onClose()
closePanel()
}
window.addEventListener('keydown', handleEsc, true)
return () => window.removeEventListener('keydown', handleEsc, true)
}, [isPanelOpen, onClose])
}, [isPanelOpen, closePanel])
const shouldRender = isPanelOpen || mounted
const current = isPanelOpen ? (activePanel || (drawerParam ? { type: 'filters' } : null)) : activeState
const current = isPanelOpen ? activePanel : activeState
const content = shouldRender && current ? renderPanel(current.type, current.id) : null
/* ── در ابعاد کوچک (<1280px): نمایش به صورت Sheet کشویی ── */
if (isOverlay) {
if (!shouldRender) return null
return (
<Sheet open={isPanelOpen} onOpenChange={(o) => !o && onClose()}>
<Sheet open={isPanelOpen} onOpenChange={(o) => !o && closePanel()}>
<SheetContent
side="left"
className="w-[min(88vw,var(--panel-width))] max-w-none border-border-soft bg-[#26272c] p-0 [&>button]:hidden"

12
src/config/navigation.ts

@ -15,19 +15,9 @@ export interface NavGroupConfig {
export const navigationConfig: NavGroupConfig[] = [
{
title: 'اصلی',
title: 'پنل مدیریت عقیله',
items: [
{ title: 'داشبورد', to: '/', icon: 'home' },
{ title: 'مدیریت کاربران', to: '/users', icon: 'users', badge: 'CRUD' },
{ title: 'آیتم‌های نمونه', to: '/items', icon: 'grid' },
],
},
{
title: 'دیزاین‌سیستم و ابزارها',
items: [
{ title: 'ویترین کامپوننت‌ها (UI)', to: '/components', icon: 'sparkles', badge: 'کاتالوگ' },
{ title: 'تنظیمات سیستم', to: '/settings', icon: 'settings' },
{ title: 'آمار و گزارش‌ها', to: '/reports', icon: 'chart', soon: true },
],
},
]

4
src/features/auth/LoginPage.tsx

@ -41,7 +41,7 @@ export default function LoginPage() {
}
const fillDemo = () => {
setEmail('admin@example.com')
setEmail('admin@gmail.com')
setPassword('admin')
}
@ -79,7 +79,7 @@ export default function LoginPage() {
<div className="flex items-center justify-between rounded-xl border border-primary/20 bg-primary/10 p-3 text-xs">
<div>
<span className="font-bold text-primary block">حساب آزمایشی آماده:</span>
<span dir="ltr" className="text-[11px] text-grey-2 font-mono">admin@example.com / admin</span>
<span dir="ltr" className="text-[11px] text-grey-2 font-mono">admin@gmail.com / admin</span>
</div>
<Button
type="button"

332
src/features/dashboard/DashboardHome.tsx

@ -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">
به پنل مدیریت خوش آمدید. خلاصه وضعیت سیستم و گزارشهای سریع را در این صفحه مشاهده نمایید.
</p>
</div>
{/* سربرگ خوش‌آمدگویی */}
<div className="flex flex-col gap-3 rounded-2xl border border-border-soft bg-card/40 p-6 shadow-sm sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Button onClick={() => openModal('user', 'new')} className="gap-2">
<Ic name="plus" className="size-4" />
کاربر جدید
</Button>
<Button variant="outline" onClick={() => openModal('item', 'new')} className="gap-2">
<Ic name="plus" className="size-4" />
آیتم جدید
</Button>
<h1 className="text-xl font-black text-foreground">
خوش آمدید، {user?.name || 'مدیر سیستم'}
</h1>
<Badge variant="outline" className="border-primary/30 bg-primary/10 text-primary">
آماده توسعه
</Badge>
</div>
<p className="text-xs text-grey-3">
پنل مدیریت عقیله پاکسازی شد و آماده اضافه کردن ویجتها، جداول و ماژولهای جدید شماست.
</p>
</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" />
<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 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>
</div>
))}
</div>
{/* بخش پایینی: جدول فعالیت‌های اخیر + کارت‌های دسترسی سریع */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* جدول فعالیت‌های اخیر */}
<div className="rounded-2xl border border-border-soft bg-card/50 p-5 lg:col-span-2">
<div className="flex items-center justify-between pb-4 border-b border-border-soft">
<h2 className="text-sm font-bold text-foreground">آخرین رویدادهای سیستم</h2>
<Button variant="ghost" size="sm" onClick={() => navigate('/users')} className="text-xs text-grey-3">
مشاهده همه
</Button>
{/* کارت‌های وضعیت سیستم */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{/* کارت وضعیت بک‌اند جنگو */}
<div className="rounded-xl border border-border-soft bg-card/60 p-5 shadow-sm">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-grey-3">وضعیت سرور جنگو</span>
<div
className={`size-2.5 rounded-full ${
health.status === 'connected'
? 'bg-emerald-500 shadow-[0_0_8px_#10b981]'
: health.status === 'checking'
? 'bg-amber-400 animate-pulse'
: 'bg-rose-500 shadow-[0_0_8px_#f43f5e]'
}`}
/>
</div>
<div className="mt-3 flex items-baseline gap-2">
<span className="text-base font-bold text-foreground">
{health.status === 'connected'
? 'متصل'
: health.status === 'checking'
? 'در حال بررسی...'
: 'قطع ارتباط'}
</span>
{health.latencyMs !== undefined && (
<span className="text-[11px] text-grey-3 font-mono" dir="ltr">
({health.latencyMs} ms)
</span>
)}
</div>
<p className="mt-2 text-[11px] text-grey-3">{health.message}</p>
</div>
<div className="divide-y divide-border-soft">
{recentActivities.map((act) => (
<div key={act.id} className="flex items-center justify-between py-3.5">
<div className="flex items-center gap-3">
<div className="flex size-8 items-center justify-center rounded-full bg-white/6 text-xs font-bold text-grey-2">
{act.user.charAt(0)}
</div>
<div>
<div className="text-xs font-semibold text-foreground">{act.user}</div>
<div className="text-[11px] text-grey-3 mt-0.5">{act.action}</div>
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-[11px] text-grey-4">{act.date}</span>
<Badge variant="outline" className="text-[10px] text-emerald-400 border-emerald-500/20 bg-emerald-500/10">
موفق
</Badge>
{/* کارت مشخصات اتصال بک‌اند */}
<div className="rounded-xl border border-border-soft bg-card/60 p-5 shadow-sm">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-grey-3">پیکربندی API و سرور</span>
<Ic name="code" className="size-4 text-grey-3" />
</div>
<div 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 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 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>
<Ic name="chevronLeft" className="size-4 text-grey-4" />
</button>
<div className="mt-3 space-y-1.5 text-xs">
<div className="flex justify-between">
<span className="text-grey-3">نام کاربر:</span>
<span className="font-semibold text-foreground">{user?.name || 'مدیر'}</span>
</div>
<div className="rounded-xl border border-primary/20 bg-primary/5 p-3.5 text-xs">
<div className="flex items-center gap-1.5 font-bold text-primary mb-1">
<Ic name="sparkles" className="size-4" />
راهنمای قالب
<div className="flex justify-between">
<span className="text-grey-3">ایمیل:</span>
<span dir="ltr" className="font-mono text-[11px] text-grey-2">
{user?.email || '-'}
</span>
</div>
<div className="flex justify-between">
<span className="text-grey-3">نقش:</span>
<span className="text-primary font-medium">{user?.role || 'admin'}</span>
</div>
<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>
)
}

86
src/features/filters/FilterDrawer.tsx

@ -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>
)
}

18
src/features/global-search/GlobalSearchDialog.tsx

@ -12,13 +12,11 @@ import {
} from '@/components/ui/command'
import { useGlobalSearchStore } from '@/stores/global-search'
import { navigationConfig } from '@/config/navigation'
import { usePanelRouting } from '@/hooks/use-panel-routing'
import { useLogout } from '@/features/auth/use-auth'
export default function GlobalSearchDialog() {
const { open, setOpen } = useGlobalSearchStore()
const navigate = useNavigate()
const { openModal } = usePanelRouting()
const logout = useLogout()
// میانبر صفحه‌کلید Cmd+K یا Ctrl+K
@ -73,22 +71,6 @@ export default function GlobalSearchDialog() {
{/* اکشن‌های سریع */}
<CommandGroup heading="عملیات سریع">
<CommandItem
onSelect={() => runAction(() => openModal('user', 'new'))}
className="flex cursor-pointer items-center gap-2.5 rounded-lg px-3 py-2 text-xs"
>
<Ic name="plus" className="size-4 text-primary" />
<span>افزودن کاربر جدید</span>
</CommandItem>
<CommandItem
onSelect={() => runAction(() => openModal('item', 'new'))}
className="flex cursor-pointer items-center gap-2.5 rounded-lg px-3 py-2 text-xs"
>
<Ic name="plus" className="size-4 text-primary" />
<span>افزودن آیتم جدید</span>
</CommandItem>
<CommandItem
onSelect={() => runAction(() => logout())}
className="flex cursor-pointer items-center gap-2.5 rounded-lg px-3 py-2 text-xs text-rose-400"

217
src/features/items/ItemsPage.tsx

@ -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>
)
}

235
src/features/items/SampleItemModal.tsx

@ -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>
)
}

113
src/features/panels/SampleDetailPanel.tsx

@ -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>
)
}

125
src/features/panels/UserDetailPanel.tsx

@ -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>
)
}

121
src/features/settings/SettingsPage.tsx

@ -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>
)
}

544
src/features/showcase/ComponentsShowcase.tsx

@ -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">&lt;Ic name="..." /&gt;</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>
)
}

224
src/features/users/UserModal.tsx

@ -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>
)
}

230
src/features/users/UsersPage.tsx

@ -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>
)
}

7
src/services/auth.ts

@ -11,12 +11,15 @@ import type { AuthUser, LoginInput } from '@/types'
export async function login(input: LoginInput): Promise<{ token: string; user: AuthUser }> {
// لاگین آزمایشی پیش‌فرض برای اجرای مستقیم تمپلیت
if (input.email === 'admin@example.com' && input.password === 'admin') {
if (
(input.email === 'admin@gmail.com' || input.email === 'admin@example.com') &&
input.password === 'admin'
) {
return {
token: 'demo-mock-jwt-token',
user: {
id: 1,
email: 'admin@example.com',
email: input.email,
name: 'مدیر کل سیستم',
role: 'admin',
isStaff: true,

154
src/services/sample-api.ts

@ -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)
}
}

120
src/services/users-api.ts

@ -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)
}
}

34
src/types.ts

@ -48,37 +48,3 @@ export interface ApiError {
statusCode: number
errors?: Record<string, string[]>
}
/* ── مدل کاربر نمونه (User Entity) ── */
export type UserStatus = 'active' | 'inactive' | 'pending'
export type UserRole = 'admin' | 'manager' | 'editor' | 'viewer'
export interface User {
id: string
name: string
email: string
phone?: string
role: UserRole
status: UserStatus
avatar?: string | null
createdAt: string
lastActive?: string
bio?: string
}
/* ── مدل آیتم نمونه (Sample CRUD Entity) ── */
export type ItemStatus = 'published' | 'draft' | 'archived'
export interface SampleItem {
id: string
title: string
description?: string
category: string
status: ItemStatus
price?: number
createdAt: string
updatedAt: string
tags?: string[]
}
Loading…
Cancel
Save