chore(root): initial commit with gitignore

This commit is contained in:
hosseintaromi 2025-06-14 14:23:34 +03:30
commit 50e1034d46
50 changed files with 9958 additions and 0 deletions

38
.gitignore vendored Normal file
View File

@ -0,0 +1,38 @@
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
.env
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# IDE
.idea
.vscode

180
README.md Normal file
View File

@ -0,0 +1,180 @@
# پنل مدیریت مدرن
یک پنل مدیریت کامل و مدرن با React TypeScript و Tailwind CSS که کاملاً responsive است و قابلیت‌های پیشرفته‌ای دارد.
## ویژگی‌ها
### 🎨 طراحی و UI/UX
- **طراحی مدرن**: استفاده از Tailwind CSS برای طراحی زیبا و مدرن
- **Responsive Design**: کاملاً سازگار با دستگاه‌های مختلف
- **Dark/Light Mode**: قابلیت تغییر تم تاریک و روشن
- **انیمیشن‌های نرم**: استفاده از انیمیشن‌های CSS برای تجربه کاربری بهتر
### 🔐 احراز هویت و امنیت
- **سیستم لاگین**: احراز هویت با ایمیل و رمز عبور
- **Access Control**: سیستم کنترل دسترسی پیشرفته
- **Permission Wrapper**: کامپوننت کنترل دسترسی قابل استفاده مجدد
- **Protected Routes**: محافظت از مسیرها بر اساس احراز هویت
### 📊 داده‌ها و نمودارها
- **جداول هوشمند**: جداول responsive که در موبایل به صورت کارت نمایش داده می‌شوند
- **نمودارهای متنوع**: Bar Chart، Line Chart، Pie Chart
- **آمار و ارقام**: نمایش آمار کلیدی با انیمیشن
- **فیلتر و جستجو**: قابلیت جستجو و فیلتر داده‌ها
### 🏗️ معماری و توسعه
- **Component-Based**: معماری مبتنی بر کامپوننت
- **TypeScript**: استفاده کامل از TypeScript برای type safety
- **Context API**: مدیریت state با React Context
- **Best Practices**: پیروی از بهترین شیوه‌های React
## نصب و راه‌اندازی
### پیش‌نیازها
- Node.js 18+
- npm یا yarn
### مراحل نصب
1. **کلون کردن پروژه**
```bash
git clone <repository-url>
cd admin-panel
```
2. **نصب dependencies**
```bash
npm install
```
3. **اجرای پروژه**
```bash
npm run dev
```
4. **مشاهده در مرورگر**
```
http://localhost:5173
```
## اطلاعات ورود
برای تست پنل از اطلاعات زیر استفاده کنید:
- **ایمیل**: `admin@test.com`
- **رمز عبور**: `admin123`
## ساختار پروژه
```
src/
├── components/ # کامپوننت‌های UI
│ ├── charts/ # کامپوننت‌های نمودار
│ ├── common/ # کامپوننت‌های مشترک
│ ├── dashboard/ # کامپوننت‌های داشبورد
│ ├── layout/ # کامپوننت‌های layout
│ └── ui/ # کامپوننت‌های UI پایه
├── contexts/ # Context های React
├── pages/ # صفحات اصلی
├── types/ # تایپ‌های TypeScript
└── App.tsx # کامپوننت اصلی
```
## سیستم دسترسی
پنل دارای سیستم دسترسی پیشرفته‌ای است که بر اساس کدهای عددی عمل می‌کند:
- **10**: دسترسی کاربران
- **15**: دسترسی محصولات
- **20**: دسترسی آنالیتیکس
- **22**: دسترسی حذف کاربر
- **25**: دسترسی افزودن جدید
- **30**: دسترسی تنظیمات
### استفاده از Permission Wrapper
```tsx
<PermissionWrapper permission={22}>
<Button variant="danger">حذف</Button>
</PermissionWrapper>
```
## کامپوننت‌های اصلی
### Table Component
جدول responsive که در دسکتاپ به صورت جدول و در موبایل به صورت کارت نمایش داده می‌شود.
### Charts
- **BarChart**: نمودار ستونی
- **LineChart**: نمودار خطی
- **PieChart**: نمودار دایره‌ای
### Button Component
دکمه‌های قابل تنظیم با انواع variant و size مختلف.
### StatsCard
کارت نمایش آمار با آیکون و درصد تغییر.
## تنظیمات Theme
پنل دارای سیستم theme پیشرفته‌ای است که شامل:
- **حالت روشن**: طراحی مینیمال با رنگ‌های روشن
- **حالت تاریک**: طراحی مدرن با رنگ‌های تیره
- **تغییر خودکار**: تغییر theme بر اساس تنظیمات سیستم
- **ذخیره تنظیمات**: ذخیره انتخاب کاربر در localStorage
## صفحات موجود
### 📊 داشبورد
- آمار کلیدی با نمودارها
- جدول کاربران اخیر
- نمودارهای تعاملی
### 👥 مدیریت کاربران
- لیست تمام کاربران
- جستجو و فیلتر
- عملیات CRUD
### 🔐 صفحه ورود
- فرم ورود زیبا و امن
- validation
- نمایش خطاها
## تکنولوژی‌های استفاده شده
- **React 18**: کتابخانه اصلی UI
- **TypeScript**: برای type safety
- **Tailwind CSS**: برای styling
- **React Router**: برای routing
- **Recharts**: برای نمودارها
- **Lucide React**: برای آیکون‌ها
- **Vite**: برای build و development
## کمک به توسعه
برای کمک به توسعه این پروژه:
1. Fork کنید
2. شاخه جدید بسازید
3. تغییرات خود را commit کنید
4. Pull Request ارسال کنید
## لایسنس
این پروژه تحت لایسنس MIT منتشر شده است.

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>پنل مدیریت</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5063
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

45
package.json Normal file
View File

@ -0,0 +1,45 @@
{
"name": "admin-panel",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@headlessui/react": "^1.7.17",
"@hookform/resolvers": "^5.1.1",
"@tanstack/react-query": "^5.80.6",
"@tanstack/react-query-devtools": "^5.80.6",
"axios": "^1.9.0",
"clsx": "^2.0.0",
"lucide-react": "^0.263.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.57.0",
"react-hot-toast": "^2.5.2",
"react-router-dom": "^6.15.0",
"recharts": "^2.8.0",
"yup": "^1.6.1",
"zustand": "^5.0.5"
},
"devDependencies": {
"@types/node": "^24.0.0",
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"@vitejs/plugin-react": "^4.0.3",
"autoprefixer": "^10.4.14",
"eslint": "^8.45.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.3",
"postcss": "^8.4.27",
"tailwindcss": "^3.3.3",
"typescript": "^5.0.2",
"vite": "^4.4.5"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

63
src/App.tsx Normal file
View File

@ -0,0 +1,63 @@
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { AuthProvider } from './contexts/AuthContext';
import { ThemeProvider } from './contexts/ThemeContext';
import { ToastProvider } from './contexts/ToastContext';
import { ErrorBoundary } from './components/common/ErrorBoundary';
import { queryClient } from './lib/queryClient';
import { useAuth } from './contexts/AuthContext';
import { Login } from './pages/Login';
import { Dashboard } from './pages/Dashboard';
import { Users } from './pages/Users';
import Products from './pages/Products';
import { Orders } from './pages/Orders';
import { Reports } from './pages/Reports';
import { Notifications } from './pages/Notifications';
import { Layout } from './components/layout/Layout';
const ProtectedRoute = ({ children }: { children: any }) => {
const { user } = useAuth();
return user ? children : <Navigate to="/login" replace />;
};
const AppRoutes = () => {
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={
<ProtectedRoute>
<Layout />
</ProtectedRoute>
}>
<Route index element={<Dashboard />} />
<Route path="users" element={<Users />} />
<Route path="products" element={<Products />} />
<Route path="orders" element={<Orders />} />
<Route path="reports" element={<Reports />} />
<Route path="notifications" element={<Notifications />} />
</Route>
</Routes>
);
};
function App() {
return (
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<ToastProvider>
<AuthProvider>
<Router>
<AppRoutes />
</Router>
</AuthProvider>
</ToastProvider>
</ThemeProvider>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</ErrorBoundary>
);
}
export default App;

View File

@ -0,0 +1,44 @@
import { BarChart as RechartsBarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import { ChartData } from '../../types';
interface BarChartProps {
data: ChartData[];
title?: string;
color?: string;
}
export const BarChart = ({ data, title, color = '#3b82f6' }: BarChartProps) => {
return (
<div className="card p-6">
{title && (
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
{title}
</h3>
)}
<ResponsiveContainer width="100%" height={300}>
<RechartsBarChart data={data}>
<CartesianGrid strokeDasharray="3 3" className="stroke-gray-300 dark:stroke-gray-600" />
<XAxis
dataKey="name"
className="text-gray-600 dark:text-gray-400"
tick={{ fontSize: 12 }}
/>
<YAxis
className="text-gray-600 dark:text-gray-400"
tick={{ fontSize: 12 }}
/>
<Tooltip
contentStyle={{
backgroundColor: 'var(--tooltip-bg)',
border: 'none',
borderRadius: '8px',
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
}}
labelStyle={{ color: 'var(--tooltip-text)' }}
/>
<Bar dataKey="value" fill={color} radius={[4, 4, 0, 0]} />
</RechartsBarChart>
</ResponsiveContainer>
</div>
);
};

View File

@ -0,0 +1,44 @@
import { LineChart as RechartsLineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import { ChartData } from '../../types';
interface LineChartProps {
data: ChartData[];
title?: string;
color?: string;
}
export const LineChart = ({ data, title, color = '#10b981' }: LineChartProps) => {
return (
<div className="card p-6">
{title && (
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
{title}
</h3>
)}
<ResponsiveContainer width="100%" height={300}>
<RechartsLineChart data={data}>
<CartesianGrid strokeDasharray="3 3" className="stroke-gray-300 dark:stroke-gray-600" />
<XAxis
dataKey="name"
className="text-gray-600 dark:text-gray-400"
tick={{ fontSize: 12 }}
/>
<YAxis
className="text-gray-600 dark:text-gray-400"
tick={{ fontSize: 12 }}
/>
<Tooltip
contentStyle={{
backgroundColor: 'var(--tooltip-bg)',
border: 'none',
borderRadius: '8px',
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
}}
labelStyle={{ color: 'var(--tooltip-text)' }}
/>
<Line type="monotone" dataKey="value" stroke={color} strokeWidth={3} dot={{ r: 6 }} />
</RechartsLineChart>
</ResponsiveContainer>
</div>
);
};

View File

@ -0,0 +1,49 @@
import { PieChart as RechartsPieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts';
import { ChartData } from '../../types';
interface PieChartProps {
data: ChartData[];
title?: string;
colors?: string[];
}
const DEFAULT_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
export const PieChart = ({ data, title, colors = DEFAULT_COLORS }: PieChartProps) => {
return (
<div className="card p-6">
{title && (
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
{title}
</h3>
)}
<ResponsiveContainer width="100%" height={300}>
<RechartsPieChart>
<Pie
data={data}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
outerRadius={80}
fill="#8884d8"
dataKey="value"
>
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={colors[index % colors.length]} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: 'var(--tooltip-bg)',
border: 'none',
borderRadius: '8px',
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
}}
labelStyle={{ color: 'var(--tooltip-text)' }}
/>
</RechartsPieChart>
</ResponsiveContainer>
</div>
);
};

View File

@ -0,0 +1,127 @@
import React, { Component, ErrorInfo, ReactNode } from 'react';
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
import { Button } from '../ui/Button';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
errorInfo?: ErrorInfo;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
this.setState({
error,
errorInfo,
});
console.error('ErrorBoundary caught an error:', error, errorInfo);
if (process.env.NODE_ENV === 'production') {
this.logErrorToService(error, errorInfo);
}
}
private logErrorToService = (error: Error, errorInfo: ErrorInfo) => {
console.log('Error logged to service:', { error, errorInfo });
};
private handleRetry = () => {
this.setState({ hasError: false, error: undefined, errorInfo: undefined });
};
private handleGoHome = () => {
window.location.href = '/';
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center p-4">
<div className="max-w-md w-full bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 text-center">
<div className="mb-4">
<AlertTriangle className="h-16 w-16 text-red-500 mx-auto" />
</div>
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-2">
خطایی رخ داده است
</h1>
<p className="text-gray-600 dark:text-gray-400 mb-6">
متأسفانه مشکلی در برنامه رخ داده است. لطفاً دوباره تلاش کنید یا با پشتیبانی تماس بگیرید.
</p>
{process.env.NODE_ENV === 'development' && this.state.error && (
<details className="mb-6 text-left">
<summary className="cursor-pointer text-sm text-gray-500 dark:text-gray-400 mb-2">
جزئیات خطا (فقط در حالت توسعه)
</summary>
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded p-3">
<pre className="text-xs text-red-700 dark:text-red-300 overflow-auto">
{this.state.error.toString()}
{this.state.errorInfo?.componentStack}
</pre>
</div>
</details>
)}
<div className="flex flex-col sm:flex-row gap-3">
<Button
onClick={this.handleRetry}
className="flex-1"
variant="primary"
>
<RefreshCw className="h-4 w-4 ml-2" />
تلاش دوباره
</Button>
<Button
onClick={this.handleGoHome}
className="flex-1"
variant="secondary"
>
<Home className="h-4 w-4 ml-2" />
بازگشت به خانه
</Button>
</div>
</div>
</div>
);
}
return this.props.children;
}
}
export const withErrorBoundary = <P extends object>(
Component: React.ComponentType<P>,
fallback?: ReactNode
) => {
const WrappedComponent = (props: P) => (
<ErrorBoundary fallback={fallback}>
<Component {...props} />
</ErrorBoundary>
);
WrappedComponent.displayName = `withErrorBoundary(${Component.displayName || Component.name})`;
return WrappedComponent;
};

View File

@ -0,0 +1,21 @@
import { useAuth } from '../../contexts/AuthContext';
interface PermissionWrapperProps {
permission: number;
children: any;
fallback?: any;
}
export const PermissionWrapper = ({
permission,
children,
fallback = null
}: PermissionWrapperProps) => {
const { hasPermission } = useAuth();
if (!hasPermission(permission)) {
return fallback;
}
return children;
};

View File

@ -0,0 +1,63 @@
import { TrendingUp, TrendingDown } from 'lucide-react';
interface StatsCardProps {
title: string;
value: string | number;
change?: number;
icon: any;
color?: string;
}
export const StatsCard = ({
title,
value,
change,
icon: Icon,
color = 'blue'
}: StatsCardProps) => {
const colorClasses = {
blue: 'bg-blue-500',
green: 'bg-green-500',
yellow: 'bg-yellow-500',
red: 'bg-red-500',
purple: 'bg-purple-500',
};
const isPositive = change && change > 0;
const isNegative = change && change < 0;
return (
<div className="card p-6 animate-fade-in">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className={`p-3 rounded-lg ${colorClasses[color as keyof typeof colorClasses] || colorClasses.blue}`}>
<Icon className="h-6 w-6 text-white" />
</div>
</div>
<div className="mr-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">
{title}
</dt>
<dd className="flex items-baseline">
<div className="text-2xl font-semibold text-gray-900 dark:text-gray-100">
{typeof value === 'number' ? value.toLocaleString() : value}
</div>
{change !== undefined && (
<div className={`mr-2 flex items-baseline text-sm font-semibold ${isPositive ? 'text-green-600' : isNegative ? 'text-red-600' : 'text-gray-500'
}`}>
{isPositive && <TrendingUp className="h-4 w-4 flex-shrink-0 self-center ml-1" />}
{isNegative && <TrendingDown className="h-4 w-4 flex-shrink-0 self-center ml-1" />}
<span className="sr-only">
{isPositive ? 'افزایش' : 'کاهش'}
</span>
{Math.abs(change)}%
</div>
)}
</dd>
</dl>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,129 @@
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import { User, Phone, Mail, UserCircle } from 'lucide-react';
import { Input } from '../ui/Input';
import { Button } from '../ui/Button';
import { userSchema, UserFormData } from '../../utils/validationSchemas';
interface UserFormProps {
initialData?: Partial<UserFormData>;
onSubmit: (data: UserFormData) => void;
onCancel: () => void;
loading?: boolean;
isEdit?: boolean;
}
export const UserForm = ({
initialData,
onSubmit,
onCancel,
loading = false,
isEdit = false
}: UserFormProps) => {
const {
register,
handleSubmit,
formState: { errors, isValid },
} = useForm<UserFormData>({
resolver: yupResolver(userSchema),
mode: 'onChange',
defaultValues: initialData,
});
return (
<div className="card p-6">
<div className="mb-6">
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100">
{isEdit ? 'ویرایش کاربر' : 'افزودن کاربر جدید'}
</h2>
<p className="text-gray-600 dark:text-gray-400 mt-1">
اطلاعات کاربر را وارد کنید
</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Input
label="نام و نام خانوادگی"
placeholder="علی احمدی"
icon={User}
error={errors.name?.message}
{...register('name')}
/>
<Input
label="ایمیل"
type="email"
placeholder="ali@example.com"
icon={Mail}
error={errors.email?.message}
{...register('email')}
/>
<Input
label="شماره تلفن"
type="tel"
placeholder="09123456789"
icon={Phone}
error={errors.phone?.message}
{...register('phone')}
/>
<div className="space-y-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
نقش
</label>
<div className="relative">
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<UserCircle className="h-5 w-5 text-gray-400" />
</div>
<select
className={`input pr-10 ${errors.role ? 'border-red-500 dark:border-red-500 focus:ring-red-500' : ''
}`}
{...register('role')}
>
<option value="">انتخاب کنید</option>
<option value="کاربر">کاربر</option>
<option value="مدیر">مدیر</option>
<option value="ادمین">ادمین</option>
</select>
</div>
{errors.role && (
<p className="text-sm text-red-600 dark:text-red-400">
{errors.role.message}
</p>
)}
</div>
</div>
{!isEdit && (
<Input
label="رمز عبور"
type="password"
placeholder="حداقل ۶ کاراکتر"
error={errors.password?.message}
{...register('password')}
/>
)}
<div className="flex items-center justify-end space-x-4 pt-6 border-t border-gray-200 dark:border-gray-700">
<Button
type="button"
variant="secondary"
onClick={onCancel}
disabled={loading}
>
انصراف
</Button>
<Button
type="submit"
loading={loading}
disabled={!isValid}
>
{isEdit ? 'ویرایش' : 'افزودن'}
</Button>
</div>
</form>
</div>
);
};

View File

@ -0,0 +1,96 @@
import { Menu, Sun, Moon, Bell, User, LogOut } from 'lucide-react';
import { useState } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { useTheme } from '../../contexts/ThemeContext';
import { Button } from '../ui/Button';
interface HeaderProps {
onMenuClick: () => void;
}
export const Header = ({ onMenuClick }: HeaderProps) => {
const { user, logout } = useAuth();
const { mode, toggleTheme } = useTheme();
const [showUserMenu, setShowUserMenu] = useState(false);
return (
<header className="bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between px-4 py-3">
<div className="flex items-center">
<button
onClick={onMenuClick}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 lg:hidden"
>
<Menu className="h-5 w-5 text-gray-600 dark:text-gray-400" />
</button>
<h1 className="mr-4 text-lg font-semibold text-gray-900 dark:text-gray-100">
خوش آمدید
</h1>
</div>
<div className="flex items-center space-x-4">
<button
onClick={toggleTheme}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
{mode === 'dark' ? (
<Sun className="h-5 w-5 text-gray-600 dark:text-gray-400" />
) : (
<Moon className="h-5 w-5 text-gray-600 dark:text-gray-400" />
)}
</button>
<button className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors relative">
<Bell className="h-5 w-5 text-gray-600 dark:text-gray-400" />
<span className="absolute top-0 left-0 w-2 h-2 bg-red-500 rounded-full"></span>
</button>
<div className="relative">
<button
onClick={() => setShowUserMenu(!showUserMenu)}
className="flex items-center space-x-2 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<div className="w-8 h-8 bg-primary-600 rounded-full flex items-center justify-center">
<span className="text-white text-sm font-medium">
{user?.name?.charAt(0) || 'A'}
</span>
</div>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 hidden md:block">
{user?.name || 'کاربر'}
</span>
</button>
{showUserMenu && (
<div className="absolute left-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-50">
<div className="py-1">
<div className="px-4 py-2 border-b border-gray-200 dark:border-gray-700">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
{user?.name}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{user?.email}
</p>
</div>
<button className="w-full text-right px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center">
<User className="h-4 w-4 ml-2" />
پروفایل
</button>
<button
onClick={() => {
logout();
setShowUserMenu(false);
}}
className="w-full text-right px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center"
>
<LogOut className="h-4 w-4 ml-2" />
خروج
</button>
</div>
</div>
)}
</div>
</div>
</div>
</header>
);
};

View File

@ -0,0 +1,25 @@
import { useState } from 'react';
import { Outlet } from 'react-router-dom';
import { Sidebar } from './Sidebar';
import { Header } from './Header';
export const Layout = () => {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="flex h-screen bg-gray-50 dark:bg-gray-900">
<Sidebar
isOpen={sidebarOpen}
onClose={() => setSidebarOpen(false)}
/>
<div className="flex-1 flex flex-col overflow-hidden">
<Header onMenuClick={() => setSidebarOpen(true)} />
<main className="flex-1 overflow-y-auto bg-gray-50 dark:bg-gray-900">
<Outlet />
</main>
</div>
</div>
);
};

View File

@ -0,0 +1,223 @@
import { useState } from 'react';
import { NavLink } from 'react-router-dom';
import {
LayoutDashboard,
Users,
ShoppingBag,
ShoppingCart,
FileText,
Bell,
Menu,
X,
ChevronDown
} from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext';
import { PermissionWrapper } from '../common/PermissionWrapper';
import { MenuItem } from '../../types';
const menuItems: MenuItem[] = [
{
id: 'dashboard',
label: 'داشبورد',
icon: LayoutDashboard,
path: '/',
},
{
id: 'users',
label: 'کاربران',
icon: Users,
path: '/users',
permission: 10,
},
{
id: 'products',
label: 'محصولات',
icon: ShoppingBag,
path: '/products',
permission: 15,
},
{
id: 'orders',
label: 'سفارشات',
icon: ShoppingCart,
path: '/orders',
permission: 20,
},
{
id: 'reports',
label: 'گزارش‌ها',
icon: FileText,
path: '/reports',
permission: 25,
},
{
id: 'notifications',
label: 'اعلانات',
icon: Bell,
path: '/notifications',
permission: 30,
},
];
interface SidebarProps {
isOpen: boolean;
onClose: () => void;
}
export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
const { user } = useAuth();
const [expandedItems, setExpandedItems] = useState<string[]>([]);
const toggleExpanded = (itemId: string) => {
setExpandedItems(prev =>
prev.includes(itemId)
? prev.filter(id => id !== itemId)
: [...prev, itemId]
);
};
const renderMenuItem = (item: MenuItem) => {
const hasChildren = item.children && item.children.length > 0;
const isExpanded = expandedItems.includes(item.id);
const menuContent = (
<>
<div
className={`flex items-center justify-between px-4 py-3 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors ${hasChildren ? 'cursor-pointer' : ''
}`}
onClick={hasChildren ? () => toggleExpanded(item.id) : undefined}
>
<div className="flex items-center">
<item.icon className="h-5 w-5 ml-3" />
<span className="font-medium">{item.label}</span>
</div>
{hasChildren && (
<ChevronDown
className={`h-4 w-4 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
/>
)}
</div>
{hasChildren && isExpanded && (
<div className="mr-8 mt-1 space-y-1">
{item.children?.map(child => (
<div key={child.id}>
{child.permission ? (
<PermissionWrapper permission={child.permission}>
<NavLink
to={child.path}
className={({ isActive }) =>
`block px-4 py-2 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors ${isActive ? 'bg-primary-100 dark:bg-primary-900 text-primary-700 dark:text-primary-300' : ''
}`
}
onClick={onClose}
>
{child.label}
</NavLink>
</PermissionWrapper>
) : (
<NavLink
to={child.path}
className={({ isActive }) =>
`block px-4 py-2 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors ${isActive ? 'bg-primary-100 dark:bg-primary-900 text-primary-700 dark:text-primary-300' : ''
}`
}
onClick={onClose}
>
{child.label}
</NavLink>
)}
</div>
))}
</div>
)}
</>
);
if (!hasChildren) {
return (
<NavLink
to={item.path}
className={({ isActive }) =>
`flex items-center px-4 py-3 rounded-lg transition-colors ${isActive
? 'text-primary-700 dark:text-primary-300 bg-primary-100 dark:bg-primary-900'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`
}
onClick={onClose}
>
<item.icon className="h-5 w-5 ml-3" />
<span className="font-medium">{item.label}</span>
</NavLink>
);
}
return <div>{menuContent}</div>;
};
return (
<>
{isOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden"
onClick={onClose}
/>
)}
<div className={`
fixed top-0 right-0 h-full w-64 bg-white dark:bg-gray-800 shadow-lg z-50 transform transition-transform duration-300 ease-in-out
${isOpen ? 'translate-x-0' : 'translate-x-full'}
lg:relative lg:translate-x-0
`}>
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center">
<div className="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
<LayoutDashboard className="h-5 w-5 text-white" />
</div>
<span className="mr-3 text-xl font-bold text-gray-900 dark:text-gray-100">
پنل مدیریت
</span>
</div>
<button
onClick={onClose}
className="p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 lg:hidden"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="p-4">
<div className="flex items-center mb-6 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div className="w-10 h-10 bg-primary-600 rounded-full flex items-center justify-center">
<span className="text-white font-medium">
{user?.name?.charAt(0) || 'A'}
</span>
</div>
<div className="mr-3">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
{user?.name || 'کاربر'}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{user?.role || 'مدیر'}
</p>
</div>
</div>
<nav className="space-y-2">
{menuItems.map(item => (
<div key={item.id}>
{item.permission ? (
<PermissionWrapper permission={item.permission}>
{renderMenuItem(item)}
</PermissionWrapper>
) : (
renderMenuItem(item)
)}
</div>
))}
</nav>
</div>
</div>
</>
);
};

View File

@ -0,0 +1,80 @@
import { clsx } from 'clsx';
interface ButtonProps {
children: any;
variant?: 'primary' | 'secondary' | 'danger' | 'success';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
loading?: boolean;
onClick?: () => void;
type?: 'button' | 'submit' | 'reset';
className?: string;
}
export const Button = ({
children,
variant = 'primary',
size = 'md',
disabled = false,
loading = false,
onClick,
type = 'button',
className = '',
}: ButtonProps) => {
const baseClasses = 'inline-flex items-center justify-center rounded-lg font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2';
const variantClasses = {
primary: 'bg-primary-600 hover:bg-primary-700 text-white focus:ring-primary-500',
secondary: 'bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 focus:ring-gray-500',
danger: 'bg-red-600 hover:bg-red-700 text-white focus:ring-red-500',
success: 'bg-green-600 hover:bg-green-700 text-white focus:ring-green-500',
};
const sizeClasses = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-3 text-base',
};
const disabledClasses = disabled || loading
? 'opacity-50 cursor-not-allowed pointer-events-none'
: '';
return (
<button
type={type}
onClick={onClick}
disabled={disabled || loading}
className={clsx(
baseClasses,
variantClasses[variant],
sizeClasses[size],
disabledClasses,
className
)}
>
{loading && (
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
)}
{children}
</button>
);
};

View File

@ -0,0 +1,54 @@
import { forwardRef } from 'react';
import { clsx } from 'clsx';
interface InputProps {
label?: string;
error?: string;
type?: string;
placeholder?: string;
className?: string;
icon?: any;
disabled?: boolean;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, error, type = 'text', placeholder, className, icon: Icon, disabled, ...props }, ref) => {
return (
<div className="space-y-1">
{label && (
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
{label}
</label>
)}
<div className="relative">
{Icon && (
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<Icon className="h-5 w-5 text-gray-400" />
</div>
)}
<input
ref={ref}
type={type}
placeholder={placeholder}
disabled={disabled}
className={clsx(
'input',
Icon && 'pr-10',
error && 'border-red-500 dark:border-red-500 focus:ring-red-500',
disabled && 'opacity-50 cursor-not-allowed',
className
)}
{...props}
/>
</div>
{error && (
<p className="text-sm text-red-600 dark:text-red-400">
{error}
</p>
)}
</div>
);
}
);
Input.displayName = 'Input';

View File

@ -0,0 +1,40 @@
import { Loader2 } from 'lucide-react';
interface LoadingSpinnerProps {
size?: 'sm' | 'md' | 'lg';
text?: string;
fullScreen?: boolean;
}
export const LoadingSpinner = ({
size = 'md',
text = 'در حال بارگذاری...',
fullScreen = false
}: LoadingSpinnerProps) => {
const sizeClasses = {
sm: 'h-4 w-4',
md: 'h-8 w-8',
lg: 'h-12 w-12'
};
const Component = (
<div className={`flex flex-col items-center justify-center ${fullScreen ? 'min-h-screen' : 'p-8'}`}>
<Loader2 className={`${sizeClasses[size]} animate-spin text-primary-600 dark:text-primary-400`} />
{text && (
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
{text}
</p>
)}
</div>
);
if (fullScreen) {
return (
<div className="fixed inset-0 bg-white dark:bg-gray-900 z-50">
{Component}
</div>
);
}
return Component;
};

View File

@ -0,0 +1,79 @@
import { useEffect } from 'react';
import { X } from 'lucide-react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title?: string;
children: any;
size?: 'sm' | 'md' | 'lg' | 'xl';
}
export const Modal = ({
isOpen,
onClose,
title,
children,
size = 'md'
}: ModalProps) => {
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
if (isOpen) {
document.addEventListener('keydown', handleEscape);
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleEscape);
document.body.style.overflow = 'unset';
};
}, [isOpen, onClose]);
if (!isOpen) return null;
const sizeClasses = {
sm: 'max-w-md',
md: 'max-w-lg',
lg: 'max-w-2xl',
xl: 'max-w-4xl',
};
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex min-h-screen items-center justify-center p-4">
<div
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
onClick={onClose}
/>
<div className={`
relative bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full
${sizeClasses[size]} transform transition-all
`}>
{title && (
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{title}
</h3>
<button
onClick={onClose}
className="p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<X className="h-5 w-5 text-gray-500 dark:text-gray-400" />
</button>
</div>
)}
<div className="p-6">
{children}
</div>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,122 @@
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from './Button';
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
itemsPerPage: number;
totalItems: number;
}
export const Pagination = ({
currentPage,
totalPages,
onPageChange,
itemsPerPage,
totalItems,
}: PaginationProps) => {
const startItem = (currentPage - 1) * itemsPerPage + 1;
const endItem = Math.min(currentPage * itemsPerPage, totalItems);
const getVisiblePages = () => {
const pages = [];
const maxVisible = 5;
if (totalPages <= maxVisible) {
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
const start = Math.max(1, currentPage - 2);
const end = Math.min(totalPages, start + maxVisible - 1);
for (let i = start; i <= end; i++) {
pages.push(i);
}
if (start > 1) {
pages.unshift('...');
pages.unshift(1);
}
if (end < totalPages) {
pages.push('...');
pages.push(totalPages);
}
}
return pages;
};
if (totalPages <= 1) return null;
return (
<div className="flex items-center justify-between px-4 py-3 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 sm:px-6">
<div className="flex flex-1 justify-between sm:hidden">
<Button
variant="secondary"
size="sm"
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
>
قبلی
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
>
بعدی
</Button>
</div>
<div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700 dark:text-gray-300">
نمایش <span className="font-medium">{startItem}</span> تا{' '}
<span className="font-medium">{endItem}</span> از{' '}
<span className="font-medium">{totalItems}</span> نتیجه
</p>
</div>
<div>
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm font-medium text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronRight className="h-5 w-5" />
</button>
{getVisiblePages().map((page, index) => (
<button
key={index}
onClick={() => typeof page === 'number' && onPageChange(page)}
disabled={page === '...'}
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${page === currentPage
? 'z-10 bg-primary-50 dark:bg-primary-900 border-primary-500 text-primary-600 dark:text-primary-400'
: page === '...'
? 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 cursor-default'
: 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-600'
}`}
>
{page}
</button>
))}
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm font-medium text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeft className="h-5 w-5" />
</button>
</nav>
</div>
</div>
</div>
);
};

150
src/components/ui/Table.tsx Normal file
View File

@ -0,0 +1,150 @@
import { useState } from 'react';
import { ChevronUp, ChevronDown } from 'lucide-react';
import { TableColumn, TableData } from '../../types';
import { clsx } from 'clsx';
interface TableProps {
columns: TableColumn[];
data: TableData[];
loading?: boolean;
}
export const Table = ({ columns, data, loading = false }: TableProps) => {
const [sortField, setSortField] = useState<string>('');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const handleSort = (field: string) => {
if (sortField === field) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortDirection('asc');
}
};
const sortedData = [...data].sort((a, b) => {
if (!sortField) return 0;
const aValue = a[sortField];
const bValue = b[sortField];
if (aValue < bValue) return sortDirection === 'asc' ? -1 : 1;
if (aValue > bValue) return sortDirection === 'asc' ? 1 : -1;
return 0;
});
if (loading) {
return (
<div className="animate-pulse">
<div className="hidden md:block">
<div className="card overflow-hidden">
<div className="bg-gray-50 dark:bg-gray-700 px-6 py-3">
<div className="flex space-x-4">
{columns.map((_, index) => (
<div key={index} className="h-4 bg-gray-300 dark:bg-gray-600 rounded flex-1"></div>
))}
</div>
</div>
{[...Array(5)].map((_, index) => (
<div key={index} className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex space-x-4">
{columns.map((_, colIndex) => (
<div key={colIndex} className="h-4 bg-gray-300 dark:bg-gray-600 rounded flex-1"></div>
))}
</div>
</div>
))}
</div>
</div>
<div className="md:hidden space-y-4">
{[...Array(3)].map((_, index) => (
<div key={index} className="card p-4 space-y-3">
{columns.map((_, colIndex) => (
<div key={colIndex} className="space-y-1">
<div className="h-3 bg-gray-300 dark:bg-gray-600 rounded w-1/3"></div>
<div className="h-4 bg-gray-300 dark:bg-gray-600 rounded w-2/3"></div>
</div>
))}
</div>
))}
</div>
</div>
);
}
return (
<>
<div className="hidden md:block card overflow-hidden">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
{columns.map((column) => (
<th
key={column.key}
className={clsx(
'px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider',
column.sortable && 'cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600'
)}
onClick={() => column.sortable && handleSort(column.key)}
>
<div className="flex items-center justify-end space-x-1">
<span>{column.label}</span>
{column.sortable && (
<div className="flex flex-col">
<ChevronUp
className={clsx(
'h-3 w-3',
sortField === column.key && sortDirection === 'asc'
? 'text-primary-600'
: 'text-gray-400'
)}
/>
<ChevronDown
className={clsx(
'h-3 w-3 -mt-1',
sortField === column.key && sortDirection === 'desc'
? 'text-primary-600'
: 'text-gray-400'
)}
/>
</div>
)}
</div>
</th>
))}
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{sortedData.map((row, rowIndex) => (
<tr key={rowIndex} className="hover:bg-gray-50 dark:hover:bg-gray-700">
{columns.map((column) => (
<td key={column.key} className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 text-right">
{column.render ? column.render(row[column.key], row) : row[column.key]}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<div className="md:hidden space-y-4">
{sortedData.map((row, rowIndex) => (
<div key={rowIndex} className="card p-4 space-y-3">
{columns.map((column) => (
<div key={column.key} className="flex justify-between items-start">
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">
{column.label}:
</span>
<span className="text-sm text-gray-900 dark:text-gray-100 text-right">
{column.render ? column.render(row[column.key], row) : row[column.key]}
</span>
</div>
))}
</div>
))}
</div>
</>
);
};

View File

@ -0,0 +1,132 @@
import { createContext, useContext, useReducer, useEffect } from 'react';
import { AuthState, User } from '../types';
interface AuthContextType extends AuthState {
login: (email: string, password: string) => Promise<boolean>;
logout: () => void;
hasPermission: (permission: number) => boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
type AuthAction =
| { type: 'LOGIN_SUCCESS'; payload: { user: User; token: string } }
| { type: 'LOGOUT' }
| { type: 'RESTORE_SESSION'; payload: { user: User; token: string } };
const authReducer = (state: AuthState, action: AuthAction): AuthState => {
switch (action.type) {
case 'LOGIN_SUCCESS':
return {
isAuthenticated: true,
user: action.payload.user,
permissions: action.payload.user.permissions,
token: action.payload.token,
};
case 'LOGOUT':
return {
isAuthenticated: false,
user: null,
permissions: [],
token: null,
};
case 'RESTORE_SESSION':
return {
isAuthenticated: true,
user: action.payload.user,
permissions: action.payload.user.permissions,
token: action.payload.token,
};
default:
return state;
}
};
const initialState: AuthState = {
isAuthenticated: false,
user: null,
permissions: [],
token: null,
};
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
const [state, dispatch] = useReducer(authReducer, initialState);
useEffect(() => {
const token = localStorage.getItem('admin_token');
const userStr = localStorage.getItem('admin_user');
if (token && userStr) {
try {
const user = JSON.parse(userStr);
dispatch({ type: 'RESTORE_SESSION', payload: { user, token } });
} catch (error) {
localStorage.removeItem('admin_token');
localStorage.removeItem('admin_user');
}
}
}, []);
const login = async (email: string, password: string): Promise<boolean> => {
try {
const mockUser: User = {
id: '1',
name: 'مدیر کل',
email: email,
role: 'admin',
permissions: [1, 2, 3, 4, 5, 10, 15, 20, 22, 25, 30],
status: 'active',
createdAt: new Date().toISOString(),
lastLogin: new Date().toISOString(),
};
const mockToken = 'mock-jwt-token-' + Date.now();
if (email === 'admin@test.com' && password === 'admin123') {
localStorage.setItem('admin_token', mockToken);
localStorage.setItem('admin_user', JSON.stringify(mockUser));
dispatch({
type: 'LOGIN_SUCCESS',
payload: { user: mockUser, token: mockToken }
});
return true;
}
return false;
} catch (error) {
console.error('Login error:', error);
return false;
}
};
const logout = () => {
localStorage.removeItem('admin_token');
localStorage.removeItem('admin_user');
dispatch({ type: 'LOGOUT' });
};
const hasPermission = (permission: number): boolean => {
return state.permissions.includes(permission);
};
return (
<AuthContext.Provider value={{
...state,
login,
logout,
hasPermission,
}}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

View File

@ -0,0 +1,50 @@
import { createContext, useContext, useEffect, useState } from 'react';
import { Theme } from '../types';
interface ThemeContextType extends Theme {
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export const ThemeProvider = ({ children }: { children: any }) => {
const [mode, setMode] = useState<'light' | 'dark'>('light');
useEffect(() => {
const savedTheme = localStorage.getItem('admin_theme') as 'light' | 'dark' | null;
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const initialTheme = savedTheme || (prefersDark ? 'dark' : 'light');
setMode(initialTheme);
if (initialTheme === 'dark') {
document.documentElement.classList.add('dark');
}
}, []);
const toggleTheme = () => {
const newMode = mode === 'light' ? 'dark' : 'light';
setMode(newMode);
localStorage.setItem('admin_theme', newMode);
if (newMode === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
};
return (
<ThemeContext.Provider value={{ mode, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
};
export const useTheme = () => {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
};

View File

@ -0,0 +1,140 @@
import { createContext, useContext, ReactNode } from 'react';
import toast, { Toaster, ToastOptions } from 'react-hot-toast';
interface ToastContextType {
success: (message: string, options?: ToastOptions) => void;
error: (message: string, options?: ToastOptions) => void;
warning: (message: string, options?: ToastOptions) => void;
info: (message: string, options?: ToastOptions) => void;
loading: (message: string, options?: ToastOptions) => string;
dismiss: (id?: string) => void;
promise: <T>(
promise: Promise<T>,
messages: {
loading: string;
success: string | ((data: T) => string);
error: string | ((error: any) => string);
},
options?: ToastOptions
) => Promise<T>;
}
const ToastContext = createContext<ToastContextType | undefined>(undefined);
const defaultOptions: ToastOptions = {
duration: 4000,
position: 'top-center',
style: {
fontFamily: 'inherit',
direction: 'rtl',
},
};
export const ToastProvider = ({ children }: { children: ReactNode }) => {
const success = (message: string, options?: ToastOptions) => {
toast.success(message, { ...defaultOptions, ...options });
};
const error = (message: string, options?: ToastOptions) => {
toast.error(message, { ...defaultOptions, ...options });
};
const warning = (message: string, options?: ToastOptions) => {
toast(message, {
...defaultOptions,
icon: '⚠️',
style: {
...defaultOptions.style,
backgroundColor: '#fef3c7',
color: '#92400e',
},
...options,
});
};
const info = (message: string, options?: ToastOptions) => {
toast(message, {
...defaultOptions,
icon: '',
style: {
...defaultOptions.style,
backgroundColor: '#dbeafe',
color: '#1e40af',
},
...options,
});
};
const loading = (message: string, options?: ToastOptions): string => {
return toast.loading(message, { ...defaultOptions, ...options });
};
const dismiss = (id?: string) => {
toast.dismiss(id);
};
const promiseToast = <T,>(
promise: Promise<T>,
messages: {
loading: string;
success: string | ((data: T) => string);
error: string | ((error: any) => string);
},
options?: ToastOptions
): Promise<T> => {
return toast.promise(promise, messages, { ...defaultOptions, ...options });
};
return (
<ToastContext.Provider
value={{
success,
error,
warning,
info,
loading,
dismiss,
promise: promiseToast,
}}
>
{children}
<Toaster
position="top-center"
reverseOrder={false}
gutter={8}
containerStyle={{
direction: 'rtl',
}}
toastOptions={{
duration: 4000,
style: {
background: 'var(--toast-bg)',
color: 'var(--toast-color)',
fontFamily: 'inherit',
direction: 'rtl',
},
success: {
iconTheme: {
primary: '#10b981',
secondary: '#ffffff',
},
},
error: {
iconTheme: {
primary: '#ef4444',
secondary: '#ffffff',
},
},
}}
/>
</ToastContext.Provider>
);
};
export const useToast = () => {
const context = useContext(ToastContext);
if (context === undefined) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
};

123
src/hooks/useUsers.ts Normal file
View File

@ -0,0 +1,123 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { userService } from "../services/userService";
import {
CreateUserRequest,
UpdateUserRequest,
PaginationParams,
} from "../services/types";
import { useToast } from "../contexts/ToastContext";
import { useLoading } from "../stores/useAppStore";
export const useUsers = (params?: PaginationParams) => {
const { setLoading } = useLoading();
return useQuery({
queryKey: ["users", params],
queryFn: async () => {
setLoading("users", true);
try {
const mockUsers = userService.getMockUsers();
let filteredUsers = mockUsers;
if (params?.search) {
filteredUsers = mockUsers.filter(
(user) =>
user.name.toLowerCase().includes(params.search!.toLowerCase()) ||
user.email.toLowerCase().includes(params.search!.toLowerCase())
);
}
if (params?.sortBy) {
filteredUsers.sort((a, b) => {
const aValue = a[params.sortBy as keyof typeof a];
const bValue = b[params.sortBy as keyof typeof b];
if (params.sortOrder === "desc") {
return aValue < bValue ? 1 : -1;
}
return aValue > bValue ? 1 : -1;
});
}
const page = params?.page || 1;
const limit = params?.limit || 10;
const startIndex = (page - 1) * limit;
const paginatedUsers = filteredUsers.slice(
startIndex,
startIndex + limit
);
return {
success: true,
data: paginatedUsers,
total: filteredUsers.length,
page,
limit,
};
} finally {
setLoading("users", false);
}
},
staleTime: 5 * 60 * 1000,
});
};
export const useUser = (id: string) => {
return useQuery({
queryKey: ["user", id],
queryFn: () => userService.getUser(id),
enabled: !!id,
});
};
export const useCreateUser = () => {
const queryClient = useQueryClient();
const toast = useToast();
return useMutation({
mutationFn: (userData: CreateUserRequest) =>
userService.createUser(userData),
onSuccess: (response) => {
queryClient.invalidateQueries({ queryKey: ["users"] });
toast.success("کاربر با موفقیت ایجاد شد");
},
onError: (error: any) => {
toast.error(error?.response?.data?.message || "خطا در ایجاد کاربر");
},
});
};
export const useUpdateUser = () => {
const queryClient = useQueryClient();
const toast = useToast();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateUserRequest }) =>
userService.updateUser(id, data),
onSuccess: (response, variables) => {
queryClient.invalidateQueries({ queryKey: ["users"] });
queryClient.invalidateQueries({ queryKey: ["user", variables.id] });
toast.success("کاربر با موفقیت ویرایش شد");
},
onError: (error: any) => {
toast.error(error?.response?.data?.message || "خطا در ویرایش کاربر");
},
});
};
export const useDeleteUser = () => {
const queryClient = useQueryClient();
const toast = useToast();
return useMutation({
mutationFn: (id: string) => userService.deleteUser(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users"] });
toast.success("کاربر با موفقیت حذف شد");
},
onError: (error: any) => {
toast.error(error?.response?.data?.message || "خطا در حذف کاربر");
},
});
};

56
src/index.css Normal file
View File

@ -0,0 +1,56 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--toast-bg: #ffffff;
--toast-color: #374151;
}
[data-theme="dark"] {
--toast-bg: #1f2937;
--toast-color: #f3f4f6;
}
@layer base {
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans",
"Helvetica Neue", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
background-color: #f9fafb;
transition: background-color 0.2s ease;
}
.dark body {
background-color: #111827;
}
}
@layer components {
.card {
@apply bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700;
}
.btn-primary {
@apply bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-lg transition-colors duration-200 font-medium;
}
.btn-secondary {
@apply bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 px-4 py-2 rounded-lg transition-colors duration-200 font-medium;
}
.input {
@apply w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors duration-200;
}
}

27
src/lib/queryClient.ts Normal file
View File

@ -0,0 +1,27 @@
import { QueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error: any) => {
if (error?.response?.status === 404) return false;
if (error?.response?.status === 403) return false;
if (error?.response?.status === 401) return false;
return failureCount < 2;
},
staleTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: false,
refetchOnReconnect: true,
},
mutations: {
onError: (error: any) => {
const message =
error?.response?.data?.message ||
error?.message ||
"خطایی رخ داده است";
toast.error(message);
},
},
},
});

10
src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

159
src/pages/Dashboard.tsx Normal file
View File

@ -0,0 +1,159 @@
import { Users, ShoppingBag, DollarSign, TrendingUp } from 'lucide-react';
import { StatsCard } from '../components/dashboard/StatsCard';
import { BarChart } from '../components/charts/BarChart';
import { LineChart } from '../components/charts/LineChart';
import { PieChart } from '../components/charts/PieChart';
import { Table } from '../components/ui/Table';
import { Button } from '../components/ui/Button';
import { PermissionWrapper } from '../components/common/PermissionWrapper';
import { ChartData, TableColumn } from '../types';
const statsData = [
{
title: 'کل کاربران',
value: 1247,
change: 12,
icon: Users,
color: 'blue',
},
{
title: 'فروش ماهانه',
value: '۲۴,۵۶۷,۰۰۰',
change: 8.5,
icon: DollarSign,
color: 'green',
},
{
title: 'کل سفارشات',
value: 356,
change: -2.3,
icon: ShoppingBag,
color: 'yellow',
},
{
title: 'رشد فروش',
value: '۲۳.۵%',
change: 15.2,
icon: TrendingUp,
color: 'purple',
},
];
const chartData: ChartData[] = [
{ name: 'فروردین', value: 4000 },
{ name: 'اردیبهشت', value: 3000 },
{ name: 'خرداد', value: 5000 },
{ name: 'تیر', value: 4500 },
{ name: 'مرداد', value: 6000 },
{ name: 'شهریور', value: 5500 },
];
const pieData: ChartData[] = [
{ name: 'دسکتاپ', value: 45 },
{ name: 'موبایل', value: 35 },
{ name: 'تبلت', value: 20 },
];
const recentUsers = [
{ id: 1, name: 'علی احمدی', email: 'ali@example.com', role: 'کاربر', status: 'فعال', createdAt: '۱۴۰۲/۰۸/۱۵' },
{ id: 2, name: 'فاطمه حسینی', email: 'fateme@example.com', role: 'مدیر', status: 'فعال', createdAt: '۱۴۰۲/۰۸/۱۴' },
{ id: 3, name: 'محمد رضایی', email: 'mohammad@example.com', role: 'کاربر', status: 'غیرفعال', createdAt: '۱۴۰۲/۰۸/۱۳' },
{ id: 4, name: 'زهرا کریمی', email: 'zahra@example.com', role: 'کاربر', status: 'فعال', createdAt: '۱۴۰۲/۰۸/۱۲' },
];
const userColumns: TableColumn[] = [
{ key: 'name', label: 'نام', sortable: true },
{ key: 'email', label: 'ایمیل' },
{ key: 'role', label: 'نقش' },
{
key: 'status',
label: 'وضعیت',
render: (value) => (
<span className={`px-2 py-1 rounded-full text-xs font-medium ${value === 'فعال'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
}`}>
{value}
</span>
)
},
{ key: 'createdAt', label: 'تاریخ عضویت' },
{
key: 'actions',
label: 'عملیات',
render: (_, row) => (
<div className="flex space-x-2">
<Button size="sm" variant="secondary">
ویرایش
</Button>
<PermissionWrapper permission={22}>
<Button size="sm" variant="danger">
حذف
</Button>
</PermissionWrapper>
</div>
)
}
];
export const Dashboard = () => {
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
داشبورد
</h1>
<div className="flex space-x-4">
<Button variant="secondary">
گزارشگیری
</Button>
<PermissionWrapper permission={25}>
<Button>
اضافه کردن
</Button>
</PermissionWrapper>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{statsData.map((stat, index) => (
<StatsCard key={index} {...stat} />
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<BarChart
data={chartData}
title="فروش ماهانه"
color="#3b82f6"
/>
<LineChart
data={chartData}
title="روند رشد"
color="#10b981"
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<div className="card p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
کاربران اخیر
</h3>
<Table
columns={userColumns}
data={recentUsers}
/>
</div>
</div>
<div>
<PieChart
data={pieData}
title="دستگاه‌های کاربری"
colors={['#3b82f6', '#10b981', '#f59e0b']}
/>
</div>
</div>
</div>
);
};

131
src/pages/Login.tsx Normal file
View File

@ -0,0 +1,131 @@
import { useState } from 'react';
import { Navigate } from 'react-router-dom';
import { Eye, EyeOff, Lock, Mail } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import { useAuth } from '../contexts/AuthContext';
import { Button } from '../components/ui/Button';
import { Input } from '../components/ui/Input';
import { loginSchema, LoginFormData } from '../utils/validationSchemas';
export const Login = () => {
const { isAuthenticated, login } = useAuth();
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const {
register,
handleSubmit,
formState: { errors, isValid },
} = useForm<LoginFormData>({
resolver: yupResolver(loginSchema),
mode: 'onChange',
});
if (isAuthenticated) {
return <Navigate to="/" replace />;
}
const onSubmit = async (data: LoginFormData) => {
setLoading(true);
setError('');
try {
const success = await login(data.email, data.password);
if (!success) {
setError('ایمیل یا رمز عبور اشتباه است');
}
} catch (error) {
setError('خطایی رخ داده است. لطفا دوباره تلاش کنید');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<div className="mx-auto h-12 w-12 bg-primary-600 rounded-lg flex items-center justify-center">
<Lock className="h-6 w-6 text-white" />
</div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-gray-100">
ورود به پنل مدیریت
</h2>
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
لطفا اطلاعات خود را وارد کنید
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-4">
<Input
label="ایمیل"
type="email"
placeholder="admin@test.com"
icon={Mail}
error={errors.email?.message}
{...register('email')}
/>
<div className="space-y-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
رمز عبور
</label>
<div className="relative">
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<Lock className="h-5 w-5 text-gray-400" />
</div>
<input
type={showPassword ? 'text' : 'password'}
placeholder="admin123"
className={`input pr-10 pl-10 ${errors.password ? 'border-red-500 dark:border-red-500 focus:ring-red-500' : ''
}`}
{...register('password')}
/>
<button
type="button"
className="absolute inset-y-0 left-0 pl-3 flex items-center"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeOff className="h-5 w-5 text-gray-400 hover:text-gray-600" />
) : (
<Eye className="h-5 w-5 text-gray-400 hover:text-gray-600" />
)}
</button>
</div>
{errors.password && (
<p className="text-sm text-red-600 dark:text-red-400">
{errors.password.message}
</p>
)}
</div>
</div>
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 text-blue-600 dark:text-blue-400 px-4 py-3 rounded-lg text-sm">
<p className="font-medium">اطلاعات تست:</p>
<p>ایمیل: admin@test.com</p>
<p>رمز عبور: admin123</p>
</div>
<Button
type="submit"
loading={loading}
disabled={!isValid}
className="w-full"
>
ورود
</Button>
</form>
</div>
</div>
);
};

344
src/pages/Notifications.tsx Normal file
View File

@ -0,0 +1,344 @@
import { useState } from 'react';
import { Bell, Check, X, Plus, Search, Filter, AlertCircle, Info, CheckCircle, XCircle } from 'lucide-react';
import { Button } from '../components/ui/Button';
import { Pagination } from '../components/ui/Pagination';
const allNotifications = [
{
id: 1,
title: 'سفارش جدید دریافت شد',
message: 'سفارش شماره ۱۰۰۱ از طرف علی احمدی ثبت شد',
type: 'order',
priority: 'high',
isRead: false,
date: '۱۴۰۲/۰۸/۱۵ - ۱۴:۳۰',
sender: 'سیستم فروش'
},
{
id: 2,
title: 'محصول در حال اتمام',
message: 'موجودی لپ‌تاپ ایسوس به کمتر از ۵ عدد رسیده است',
type: 'warning',
priority: 'medium',
isRead: false,
date: '۱۴۰۲/۰۸/۱۵ - ۱۲:۱۵',
sender: 'سیستم انبار'
},
{
id: 3,
title: 'کاربر جدید عضو شد',
message: 'فاطمه حسینی با موفقیت در سیستم ثبت نام کرد',
type: 'info',
priority: 'low',
isRead: true,
date: '۱۴۰۲/۰۸/۱۴ - ۱۶:۴۵',
sender: 'سیستم کاربری'
},
{
id: 4,
title: 'پرداخت انجام شد',
message: 'پرداخت سفارش ۱۰۰۲ با موفقیت تایید شد',
type: 'success',
priority: 'medium',
isRead: true,
date: '۱۴۰۲/۰۸/۱۴ - ۱۰:۲۰',
sender: 'سیستم پرداخت'
},
{
id: 5,
title: 'خطا در سیستم',
message: 'خطا در اتصال به درگاه پرداخت - نیاز به بررسی فوری',
type: 'error',
priority: 'high',
isRead: false,
date: '۱۴۰۲/۰۸/۱۴ - ۰۹:۱۰',
sender: 'سیستم مانیتورینگ'
},
{
id: 6,
title: 'بک‌آپ تکمیل شد',
message: 'بک‌آپ روزانه اطلاعات با موفقیت انجام شد',
type: 'success',
priority: 'low',
isRead: true,
date: '۱۴۰۲/۰۸/۱۳ - ۲۳:۰۰',
sender: 'سیستم بک‌آپ'
},
{
id: 7,
title: 'بروزرسانی سیستم',
message: 'نسخه جدید سیستم منتشر شد - بروزرسانی در دسترس است',
type: 'info',
priority: 'medium',
isRead: false,
date: '۱۴۰۲/۰۸/۱۳ - ۱۱:۳۰',
sender: 'تیم توسعه'
},
{
id: 8,
title: 'گزارش فروش آماده شد',
message: 'گزارش فروش ماهانه تولید و آماده دانلود است',
type: 'info',
priority: 'low',
isRead: true,
date: '۱۴۰۲/۰۸/۱۲ - ۰۸:۰۰',
sender: 'سیستم گزارش‌گیری'
}
];
export const Notifications = () => {
const [notifications, setNotifications] = useState(allNotifications);
const [searchTerm, setSearchTerm] = useState('');
const [filterType, setFilterType] = useState('all');
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 6;
const getNotificationIcon = (type: string) => {
switch (type) {
case 'error':
return <XCircle className="h-5 w-5 text-red-600" />;
case 'warning':
return <AlertCircle className="h-5 w-5 text-yellow-600" />;
case 'success':
return <CheckCircle className="h-5 w-5 text-green-600" />;
case 'info':
return <Info className="h-5 w-5 text-blue-600" />;
default:
return <Bell className="h-5 w-5 text-gray-600" />;
}
};
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'high':
return 'border-r-red-500';
case 'medium':
return 'border-r-yellow-500';
case 'low':
return 'border-r-green-500';
default:
return 'border-r-gray-300';
}
};
const filteredNotifications = notifications.filter((notification) => {
const matchesSearch = notification.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
notification.message.toLowerCase().includes(searchTerm.toLowerCase());
const matchesFilter = filterType === 'all' ||
(filterType === 'unread' && !notification.isRead) ||
(filterType === 'read' && notification.isRead) ||
notification.type === filterType;
return matchesSearch && matchesFilter;
});
const totalPages = Math.ceil(filteredNotifications.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const paginatedNotifications = filteredNotifications.slice(startIndex, startIndex + itemsPerPage);
const handleMarkAsRead = (id: number) => {
setNotifications(prev =>
prev.map(notification =>
notification.id === id ? { ...notification, isRead: true } : notification
)
);
};
const handleMarkAllAsRead = () => {
setNotifications(prev =>
prev.map(notification => ({ ...notification, isRead: true }))
);
};
const handleDeleteNotification = (id: number) => {
setNotifications(prev => prev.filter(notification => notification.id !== id));
};
const unreadCount = notifications.filter(n => !n.isRead).length;
return (
<div className="p-6 space-y-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
اعلانات
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
{unreadCount} اعلان خوانده نشده از {notifications.length} اعلان
</p>
</div>
<div className="flex items-center space-x-4">
<Button
variant="secondary"
onClick={handleMarkAllAsRead}
disabled={unreadCount === 0}
>
<Check className="h-4 w-4 ml-2" />
همه را خوانده شده علامت بزن
</Button>
<Button>
<Plus className="h-4 w-4 ml-2" />
اعلان جدید
</Button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
<div className="flex items-center">
<Bell className="h-8 w-8 text-blue-600" />
<div className="mr-3">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">کل اعلانات</p>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">{notifications.length}</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
<div className="flex items-center">
<AlertCircle className="h-8 w-8 text-red-600" />
<div className="mr-3">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">خوانده نشده</p>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">{unreadCount}</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
<div className="flex items-center">
<XCircle className="h-8 w-8 text-red-600" />
<div className="mr-3">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">خطا</p>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{notifications.filter(n => n.type === 'error').length}
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
<div className="flex items-center">
<AlertCircle className="h-8 w-8 text-yellow-600" />
<div className="mr-3">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">هشدار</p>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{notifications.filter(n => n.type === 'warning').length}
</p>
</div>
</div>
</div>
</div>
<div className="card p-6">
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<div className="relative flex-1">
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<Search className="h-5 w-5 text-gray-400" />
</div>
<input
type="text"
placeholder="جستجو در اعلانات..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="input pr-10"
/>
</div>
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value)}
className="input min-w-[150px]"
>
<option value="all">همه اعلانات</option>
<option value="unread">خوانده نشده</option>
<option value="read">خوانده شده</option>
<option value="error">خطا</option>
<option value="warning">هشدار</option>
<option value="success">موفق</option>
<option value="info">اطلاعات</option>
</select>
</div>
<div className="space-y-4">
{paginatedNotifications.map((notification) => (
<div
key={notification.id}
className={`p-4 border-r-4 ${getPriorityColor(notification.priority)} ${notification.isRead
? 'bg-gray-50 dark:bg-gray-700'
: 'bg-white dark:bg-gray-800'
} border border-gray-200 dark:border-gray-600 rounded-lg shadow-sm hover:shadow-md transition-shadow`}
>
<div className="flex items-start justify-between">
<div className="flex items-start space-x-3">
<div className="flex-shrink-0 mt-1">
{getNotificationIcon(notification.type)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2">
<h3 className={`text-sm font-medium ${notification.isRead
? 'text-gray-600 dark:text-gray-400'
: 'text-gray-900 dark:text-gray-100'
}`}>
{notification.title}
</h3>
{!notification.isRead && (
<div className="w-2 h-2 bg-blue-600 rounded-full"></div>
)}
</div>
<p className={`mt-1 text-sm ${notification.isRead
? 'text-gray-500 dark:text-gray-500'
: 'text-gray-700 dark:text-gray-300'
}`}>
{notification.message}
</p>
<div className="mt-2 flex items-center text-xs text-gray-500 dark:text-gray-500 space-x-4">
<span>{notification.date}</span>
<span>از: {notification.sender}</span>
</div>
</div>
</div>
<div className="flex items-center space-x-2">
{!notification.isRead && (
<Button
size="sm"
variant="secondary"
onClick={() => handleMarkAsRead(notification.id)}
>
<Check className="h-4 w-4" />
</Button>
)}
<Button
size="sm"
variant="danger"
onClick={() => handleDeleteNotification(notification.id)}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</div>
))}
</div>
{paginatedNotifications.length === 0 && (
<div className="text-center py-12">
<Bell className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-500 dark:text-gray-400">هیچ اعلانی یافت نشد</p>
</div>
)}
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
itemsPerPage={itemsPerPage}
totalItems={filteredNotifications.length}
/>
</div>
</div>
);
};

204
src/pages/Orders.tsx Normal file
View File

@ -0,0 +1,204 @@
import { useState } from 'react';
import { Search, Filter, ShoppingCart, TrendingUp } from 'lucide-react';
import { Table } from '../components/ui/Table';
import { Button } from '../components/ui/Button';
import { Pagination } from '../components/ui/Pagination';
import { TableColumn } from '../types';
const allOrders = [
{ id: 1001, customer: 'علی احمدی', products: '۳ محصول', amount: '۴۵,۰۰۰,۰۰۰', status: 'تحویل شده', date: '۱۴۰۲/۰۸/۱۵' },
{ id: 1002, customer: 'فاطمه حسینی', products: '۱ محصول', amount: '۲۵,۰۰۰,۰۰۰', status: 'در حال پردازش', date: '۱۴۰۲/۰۸/۱۴' },
{ id: 1003, customer: 'محمد رضایی', products: '۲ محصول', amount: '۳۲,۰۰۰,۰۰۰', status: 'ارسال شده', date: '۱۴۰۲/۰۸/۱۳' },
{ id: 1004, customer: 'زهرا کریمی', products: '۵ محصول', amount: '۱۲۰,۰۰۰,۰۰۰', status: 'تحویل شده', date: '۱۴۰۲/۰۸/۱۲' },
{ id: 1005, customer: 'حسن نوری', products: '۱ محصول', amount: '۱۸,۰۰۰,۰۰۰', status: 'لغو شده', date: '۱۴۰۲/۰۸/۱۱' },
{ id: 1006, customer: 'مریم صادقی', products: '۴ محصول', amount: '۸۵,۰۰۰,۰۰۰', status: 'در حال پردازش', date: '۱۴۰۲/۰۸/۱۰' },
{ id: 1007, customer: 'احمد قاسمی', products: '۲ محصول', amount: '۳۸,۰۰۰,۰۰۰', status: 'ارسال شده', date: '۱۴۰۲/۰۸/۰۹' },
{ id: 1008, customer: 'سارا محمدی', products: '۳ محصول', amount: '۶۲,۰۰۰,۰۰۰', status: 'تحویل شده', date: '۱۴۰۲/۰۸/۰۸' },
{ id: 1009, customer: 'رضا کریمی', products: '۱ محصول', amount: '۱۵,۰۰۰,۰۰۰', status: 'در حال پردازش', date: '۱۴۰۲/۰۸/۰۷' },
{ id: 1010, customer: 'نرگس احمدی', products: '۶ محصول', amount: '۱۴۵,۰۰۰,۰۰۰', status: 'تحویل شده', date: '۱۴۰۲/۰۸/۰۶' },
];
export const Orders = () => {
const [searchTerm, setSearchTerm] = useState('');
const [loading, setLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 6;
const columns: TableColumn[] = [
{ key: 'id', label: 'شماره سفارش', sortable: true },
{ key: 'customer', label: 'مشتری', sortable: true },
{ key: 'products', label: 'محصولات' },
{
key: 'amount',
label: 'مبلغ',
render: (value) => (
<span className="font-medium text-gray-900 dark:text-gray-100">
{value} تومان
</span>
)
},
{
key: 'status',
label: 'وضعیت',
render: (value) => (
<span className={`px-2 py-1 rounded-full text-xs font-medium ${value === 'تحویل شده'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: value === 'ارسال شده'
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
: value === 'در حال پردازش'
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
}`}>
{value}
</span>
)
},
{ key: 'date', label: 'تاریخ سفارش', sortable: true },
{
key: 'actions',
label: 'عملیات',
render: (_, row) => (
<div className="flex space-x-2">
<Button
size="sm"
variant="secondary"
onClick={() => handleViewOrder(row)}
>
مشاهده
</Button>
<Button
size="sm"
variant="primary"
onClick={() => handleEditOrder(row)}
>
ویرایش
</Button>
</div>
)
}
];
const filteredOrders = allOrders.filter((order: any) =>
order.customer.toLowerCase().includes(searchTerm.toLowerCase()) ||
order.id.toString().includes(searchTerm)
);
const totalPages = Math.ceil(filteredOrders.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const paginatedOrders = filteredOrders.slice(startIndex, startIndex + itemsPerPage);
const handleViewOrder = (order: any) => {
console.log('Viewing order:', order);
};
const handleEditOrder = (order: any) => {
console.log('Editing order:', order);
};
const totalRevenue = allOrders.reduce((sum, order) => {
const amount = parseInt(order.amount.replace(/[,]/g, ''));
return sum + amount;
}, 0);
return (
<div className="p-6 space-y-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
مدیریت سفارشات
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
{filteredOrders.length} سفارش یافت شد
</p>
</div>
<div className="flex items-center space-x-4">
<Button variant="secondary">
<Filter className="h-4 w-4 ml-2" />
فیلتر
</Button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-6">
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
<div className="flex items-center">
<ShoppingCart className="h-8 w-8 text-blue-600" />
<div className="mr-3">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">کل سفارشات</p>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">{allOrders.length}</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
<div className="flex items-center">
<TrendingUp className="h-8 w-8 text-green-600" />
<div className="mr-3">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">تحویل شده</p>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{allOrders.filter(o => o.status === 'تحویل شده').length}
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
<div className="flex items-center">
<ShoppingCart className="h-8 w-8 text-yellow-600" />
<div className="mr-3">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">در انتظار</p>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{allOrders.filter(o => o.status === 'در حال پردازش').length}
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
<div className="flex items-center">
<TrendingUp className="h-8 w-8 text-purple-600" />
<div className="mr-3">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">کل فروش</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{totalRevenue.toLocaleString()} تومان
</p>
</div>
</div>
</div>
</div>
<div className="card p-6">
<div className="mb-6">
<div className="relative">
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<Search className="h-5 w-5 text-gray-400" />
</div>
<input
type="text"
placeholder="جستجو در سفارشات..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="input pr-10 max-w-md"
/>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg overflow-hidden">
<Table
columns={columns}
data={paginatedOrders}
loading={loading}
/>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
itemsPerPage={itemsPerPage}
totalItems={filteredOrders.length}
/>
</div>
</div>
</div>
);
};

203
src/pages/Products.tsx Normal file
View File

@ -0,0 +1,203 @@
import { useState } from 'react';
import { Plus, Search, Filter, Package } from 'lucide-react';
import { Table } from '../components/ui/Table';
import { Button } from '../components/ui/Button';
import { Pagination } from '../components/ui/Pagination';
import { PermissionWrapper } from '../components/common/PermissionWrapper';
import { TableColumn } from '../types';
const allProducts = [
{ id: 1, name: 'لپ‌تاپ ایسوس', category: 'کامپیوتر', price: '۲۵,۰۰۰,۰۰۰', stock: 15, status: 'موجود', createdAt: '۱۴۰۲/۰۸/۱۵' },
{ id: 2, name: 'گوشی سامسونگ گلکسی', category: 'موبایل', price: '۱۸,۰۰۰,۰۰۰', stock: 8, status: 'موجود', createdAt: '۱۴۰۲/۰۸/۱۴' },
{ id: 3, name: 'هدفون بی‌سیم', category: 'لوازم جانبی', price: '۲,۵۰۰,۰۰۰', stock: 0, status: 'ناموجود', createdAt: '۱۴۰۲/۰۸/۱۳' },
{ id: 4, name: 'کیبورد گیمینگ', category: 'لوازم جانبی', price: '۳,۲۰۰,۰۰۰', stock: 25, status: 'موجود', createdAt: '۱۴۰۲/۰۸/۱۲' },
{ id: 5, name: 'مانیتور ۲۴ اینچ', category: 'نمایشگر', price: '۱۲,۰۰۰,۰۰۰', stock: 5, status: 'کم موجود', createdAt: '۱۴۰۲/۰۸/۱۱' },
{ id: 6, name: 'ماوس بی‌سیم', category: 'لوازم جانبی', price: '۱,۸۰۰,۰۰۰', stock: 30, status: 'موجود', createdAt: '۱۴۰۲/۰۸/۱۰' },
{ id: 7, name: 'تبلت آیپد', category: 'تبلت', price: '۳۵,۰۰۰,۰۰۰', stock: 3, status: 'کم موجود', createdAt: '۱۴۰۲/۰۸/۰۹' },
{ id: 8, name: 'هارد اکسترنال', category: 'ذخیره‌سازی', price: '۴,۵۰۰,۰۰۰', stock: 12, status: 'موجود', createdAt: '۱۴۰۲/۰۸/۰۸' },
];
const Products = () => {
const [searchTerm, setSearchTerm] = useState('');
const [loading, setLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 6;
const columns: TableColumn[] = [
{ key: 'name', label: 'نام محصول', sortable: true },
{ key: 'category', label: 'دسته‌بندی', sortable: true },
{ key: 'price', label: 'قیمت', sortable: true },
{
key: 'stock',
label: 'موجودی',
render: (value) => (
<span className={`font-medium ${value === 0 ? 'text-red-600 dark:text-red-400' :
value < 10 ? 'text-yellow-600 dark:text-yellow-400' :
'text-green-600 dark:text-green-400'
}`}>
{value}
</span>
)
},
{
key: 'status',
label: 'وضعیت',
render: (value) => (
<span className={`px-2 py-1 rounded-full text-xs font-medium ${value === 'موجود'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: value === 'کم موجود'
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
}`}>
{value}
</span>
)
},
{ key: 'createdAt', label: 'تاریخ افزودن', sortable: true },
{
key: 'actions',
label: 'عملیات',
render: (_, row) => (
<div className="flex space-x-2">
<Button
size="sm"
variant="secondary"
onClick={() => handleEditProduct(row)}
>
ویرایش
</Button>
<PermissionWrapper permission={22}>
<Button
size="sm"
variant="danger"
onClick={() => handleDeleteProduct(row.id)}
>
حذف
</Button>
</PermissionWrapper>
</div>
)
}
];
const filteredProducts = allProducts.filter((product: any) =>
product.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
product.category.toLowerCase().includes(searchTerm.toLowerCase())
);
const totalPages = Math.ceil(filteredProducts.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const paginatedProducts = filteredProducts.slice(startIndex, startIndex + itemsPerPage);
const handleAddProduct = () => {
console.log('Adding new product');
};
const handleEditProduct = (product: any) => {
console.log('Editing product:', product);
};
const handleDeleteProduct = (productId: number) => {
if (confirm('آیا از حذف این محصول اطمینان دارید؟')) {
console.log('Deleting product:', productId);
}
};
return (
<div className="p-6 space-y-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
مدیریت محصولات
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
{filteredProducts.length} محصول یافت شد
</p>
</div>
<div className="flex items-center space-x-4">
<Button variant="secondary">
<Filter className="h-4 w-4 ml-2" />
فیلتر
</Button>
<PermissionWrapper permission={25}>
<Button onClick={handleAddProduct}>
<Plus className="h-4 w-4 ml-2" />
افزودن محصول
</Button>
</PermissionWrapper>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
<div className="flex items-center">
<Package className="h-8 w-8 text-blue-600" />
<div className="mr-3">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">کل محصولات</p>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">{allProducts.length}</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
<div className="flex items-center">
<Package className="h-8 w-8 text-green-600" />
<div className="mr-3">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">محصولات موجود</p>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{allProducts.filter(p => p.status === 'موجود').length}
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
<div className="flex items-center">
<Package className="h-8 w-8 text-red-600" />
<div className="mr-3">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">محصولات ناموجود</p>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{allProducts.filter(p => p.status === 'ناموجود').length}
</p>
</div>
</div>
</div>
</div>
<div className="card p-6">
<div className="mb-6">
<div className="relative">
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<Search className="h-5 w-5 text-gray-400" />
</div>
<input
type="text"
placeholder="جستجو در محصولات..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="input pr-10 max-w-md"
/>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg overflow-hidden">
<Table
columns={columns}
data={paginatedProducts}
loading={loading}
/>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
itemsPerPage={itemsPerPage}
totalItems={filteredProducts.length}
/>
</div>
</div>
</div>
);
};
export default Products;

223
src/pages/Reports.tsx Normal file
View File

@ -0,0 +1,223 @@
import { useState } from 'react';
import { FileText, Download, Calendar, TrendingUp, Users, ShoppingBag, DollarSign } from 'lucide-react';
import { Button } from '../components/ui/Button';
import { BarChart } from '../components/charts/BarChart';
import { LineChart } from '../components/charts/LineChart';
export const Reports = () => {
const [selectedPeriod, setSelectedPeriod] = useState('month');
const salesData = [
{ name: 'فروردین', value: 12000000 },
{ name: 'اردیبهشت', value: 19000000 },
{ name: 'خرداد', value: 15000000 },
{ name: 'تیر', value: 22000000 },
{ name: 'مرداد', value: 18000000 },
{ name: 'شهریور', value: 25000000 },
];
const userGrowthData = [
{ name: 'فروردین', value: 150 },
{ name: 'اردیبهشت', value: 230 },
{ name: 'خرداد', value: 180 },
{ name: 'تیر', value: 280 },
{ name: 'مرداد', value: 200 },
{ name: 'شهریور', value: 320 },
];
const reports = [
{
id: 1,
title: 'گزارش فروش ماهانه',
description: 'گزارش کامل فروش محصولات در ماه گذشته',
type: 'فروش',
date: '۱۴۰۲/۰۸/۳۰',
format: 'PDF'
},
{
id: 2,
title: 'گزارش کاربران جدید',
description: 'آمار کاربران جدید عضو شده در سیستم',
type: 'کاربران',
date: '۱۴۰۲/۰۸/۲۹',
format: 'Excel'
},
{
id: 3,
title: 'گزارش موجودی انبار',
description: 'وضعیت موجودی محصولات در انبار',
type: 'انبار',
date: '۱۴۰۲/۰۸/۲۸',
format: 'PDF'
},
{
id: 4,
title: 'گزارش درآمد روزانه',
description: 'جزئیات درآمد حاصل از فروش در ۳۰ روز گذشته',
type: 'مالی',
date: '۱۴۰۲/۰۸/۲۷',
format: 'Excel'
}
];
const handleDownloadReport = (reportId: number) => {
console.log('Downloading report:', reportId);
};
const handleGenerateReport = () => {
console.log('Generating new report');
};
return (
<div className="p-6 space-y-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
گزارشها و آمار
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
مشاهده و دانلود گزارشهای مختلف سیستم
</p>
</div>
<div className="flex items-center space-x-4">
<select
value={selectedPeriod}
onChange={(e) => setSelectedPeriod(e.target.value)}
className="input max-w-xs"
>
<option value="week">هفته گذشته</option>
<option value="month">ماه گذشته</option>
<option value="quarter">سه ماه گذشته</option>
<option value="year">سال گذشته</option>
</select>
<Button onClick={handleGenerateReport}>
<FileText className="h-4 w-4 ml-2" />
تولید گزارش جدید
</Button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow">
<div className="flex items-center">
<div className="p-3 rounded-full bg-blue-100 dark:bg-blue-900">
<DollarSign className="h-6 w-6 text-blue-600 dark:text-blue-400" />
</div>
<div className="mr-4">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">کل فروش</p>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">۱۱۱ میلیون</p>
<p className="text-xs text-green-600 dark:text-green-400">+۱۲% از ماه قبل</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow">
<div className="flex items-center">
<div className="p-3 rounded-full bg-green-100 dark:bg-green-900">
<Users className="h-6 w-6 text-green-600 dark:text-green-400" />
</div>
<div className="mr-4">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">کاربران جدید</p>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">۳۲۰</p>
<p className="text-xs text-green-600 dark:text-green-400">+۸% از ماه قبل</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow">
<div className="flex items-center">
<div className="p-3 rounded-full bg-purple-100 dark:bg-purple-900">
<ShoppingBag className="h-6 w-6 text-purple-600 dark:text-purple-400" />
</div>
<div className="mr-4">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">کل سفارشات</p>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">۱,۲۵۴</p>
<p className="text-xs text-green-600 dark:text-green-400">+۱۵% از ماه قبل</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow">
<div className="flex items-center">
<div className="p-3 rounded-full bg-yellow-100 dark:bg-yellow-900">
<TrendingUp className="h-6 w-6 text-yellow-600 dark:text-yellow-400" />
</div>
<div className="mr-4">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">نرخ رشد</p>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">+۲۳%</p>
<p className="text-xs text-green-600 dark:text-green-400">بهبود یافته</p>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
روند فروش
</h3>
<BarChart data={salesData} />
</div>
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
رشد کاربران
</h3>
<LineChart data={userGrowthData} />
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
گزارشهای اخیر
</h3>
</div>
<div className="p-6">
<div className="space-y-4">
{reports.map((report) => (
<div
key={report.id}
className="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<div className="flex items-center">
<div className="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg ml-4">
<FileText className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<div>
<h4 className="font-medium text-gray-900 dark:text-gray-100">
{report.title}
</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
{report.description}
</p>
<div className="flex items-center mt-1 space-x-4">
<span className="text-xs text-gray-500 dark:text-gray-500">
نوع: {report.type}
</span>
<span className="text-xs text-gray-500 dark:text-gray-500">
تاریخ: {report.date}
</span>
<span className="text-xs text-gray-500 dark:text-gray-500">
فرمت: {report.format}
</span>
</div>
</div>
</div>
<Button
size="sm"
variant="secondary"
onClick={() => handleDownloadReport(report.id)}
>
<Download className="h-4 w-4 ml-2" />
دانلود
</Button>
</div>
))}
</div>
</div>
</div>
</div>
);
};

238
src/pages/Settings.tsx Normal file
View File

@ -0,0 +1,238 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import { Settings as SettingsIcon, Save, Globe, Mail } from 'lucide-react';
import { Input } from '../components/ui/Input';
import { Button } from '../components/ui/Button';
import { settingsSchema, SettingsFormData } from '../utils/validationSchemas';
export const Settings = () => {
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const {
register,
handleSubmit,
formState: { errors, isValid, isDirty },
} = useForm<SettingsFormData>({
resolver: yupResolver(settingsSchema),
mode: 'onChange',
defaultValues: {
siteName: 'پنل مدیریت',
siteDescription: 'سیستم مدیریت محتوا پیشرفته',
adminEmail: 'admin@example.com',
language: 'fa',
},
});
const onSubmit = async (data: SettingsFormData) => {
setLoading(true);
setSuccess(false);
try {
await new Promise(resolve => setTimeout(resolve, 1000));
console.log('Settings saved:', data);
setSuccess(true);
setTimeout(() => setSuccess(false), 3000);
} catch (error) {
console.error('Error saving settings:', error);
} finally {
setLoading(false);
}
};
return (
<div className="p-6 max-w-4xl mx-auto">
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center">
<SettingsIcon className="h-6 w-6 ml-3" />
تنظیمات سیستم
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-2">
تنظیمات کلی سیستم را اینجا مدیریت کنید
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
<div className="card p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
اطلاعات اصلی
</h2>
<div className="space-y-6">
<Input
label="نام سایت"
placeholder="پنل مدیریت"
icon={Globe}
error={errors.siteName?.message}
{...register('siteName')}
/>
<div className="space-y-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
توضیحات سایت
</label>
<textarea
placeholder="توضیح کوتاهی درباره سایت..."
rows={4}
className={`input resize-none ${errors.siteDescription ? 'border-red-500 dark:border-red-500 focus:ring-red-500' : ''
}`}
{...register('siteDescription')}
/>
{errors.siteDescription && (
<p className="text-sm text-red-600 dark:text-red-400">
{errors.siteDescription.message}
</p>
)}
</div>
<Input
label="ایمیل مدیر"
type="email"
placeholder="admin@example.com"
icon={Mail}
error={errors.adminEmail?.message}
{...register('adminEmail')}
/>
<div className="space-y-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
زبان پیشفرض
</label>
<select
className={`input ${errors.language ? 'border-red-500 dark:border-red-500 focus:ring-red-500' : ''
}`}
{...register('language')}
>
<option value="fa">فارسی</option>
<option value="en">English</option>
<option value="ar">العربية</option>
</select>
{errors.language && (
<p className="text-sm text-red-600 dark:text-red-400">
{errors.language.message}
</p>
)}
</div>
</div>
</div>
<div className="card p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
تنظیمات امنیتی
</h2>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
احراز هویت دو مرحلهای
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
افزایش امنیت حساب کاربری
</p>
</div>
<label className="flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only"
/>
<div className="relative">
<div className="w-10 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
</div>
</label>
</div>
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
ثبت لاگها
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
ثبت فعالیتهای سیستم
</p>
</div>
<label className="flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only"
defaultChecked
/>
<div className="relative">
<div className="w-10 h-6 bg-blue-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
</div>
</label>
</div>
</div>
</div>
<div className="flex items-center justify-end space-x-4">
<Button
type="button"
variant="secondary"
disabled={loading}
>
بازگردانی
</Button>
<Button
type="submit"
loading={loading}
disabled={!isValid || !isDirty}
>
<Save className="h-4 w-4 ml-2" />
ذخیره تغییرات
</Button>
</div>
</form>
</div>
<div className="space-y-6">
<div className="card p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
نکات مهم
</h3>
<ul className="space-y-3 text-sm text-gray-600 dark:text-gray-400">
<li className="flex items-start">
<span className="w-2 h-2 bg-blue-500 rounded-full mt-2 ml-2 flex-shrink-0"></span>
تغییرات تنظیمات ممکن است چند دقیقه طول بکشد
</li>
<li className="flex items-start">
<span className="w-2 h-2 bg-green-500 rounded-full mt-2 ml-2 flex-shrink-0"></span>
پس از تغییر ایمیل مدیر، لینک تأیید ارسال میشود
</li>
<li className="flex items-start">
<span className="w-2 h-2 bg-yellow-500 rounded-full mt-2 ml-2 flex-shrink-0"></span>
تنظیمات امنیتی بر تمام کاربران تأثیر میگذارد
</li>
</ul>
</div>
{success && (
<div className="card p-4 bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800">
<div className="flex items-center">
<div className="w-4 h-4 bg-green-500 rounded-full ml-2"></div>
<p className="text-sm text-green-800 dark:text-green-200">
تنظیمات با موفقیت ذخیره شد
</p>
</div>
</div>
)}
<div className="card p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
عملیات خطرناک
</h3>
<div className="space-y-4">
<Button variant="danger" size="sm">
پاک کردن کش
</Button>
<Button variant="danger" size="sm">
ریست کامل سیستم
</Button>
</div>
</div>
</div>
</div>
</div>
);
};

187
src/pages/Users.tsx Normal file
View File

@ -0,0 +1,187 @@
import { useState } from 'react';
import { Plus, Search, Filter } from 'lucide-react';
import { Table } from '../components/ui/Table';
import { Button } from '../components/ui/Button';
import { Modal } from '../components/ui/Modal';
import { Pagination } from '../components/ui/Pagination';
import { UserForm } from '../components/forms/UserForm';
import { PermissionWrapper } from '../components/common/PermissionWrapper';
import { TableColumn } from '../types';
import { UserFormData } from '../utils/validationSchemas';
const allUsers = [
{ id: 1, name: 'علی احمدی', email: 'ali@example.com', role: 'کاربر', status: 'فعال', createdAt: '۱۴۰۲/۰۸/۱۵', phone: '۰۹۱۲۳۴۵۶۷۸۹' },
{ id: 2, name: 'فاطمه حسینی', email: 'fateme@example.com', role: 'مدیر', status: 'فعال', createdAt: '۱۴۰۲/۰۸/۱۴', phone: '۰۹۱۲۳۴۵۶۷۸۹' },
{ id: 3, name: 'محمد رضایی', email: 'mohammad@example.com', role: 'کاربر', status: 'غیرفعال', createdAt: '۱۴۰۲/۰۸/۱۳', phone: '۰۹۱۲۳۴۵۶۷۸۹' },
{ id: 4, name: 'زهرا کریمی', email: 'zahra@example.com', role: 'کاربر', status: 'فعال', createdAt: '۱۴۰۲/۰۸/۱۲', phone: '۰۹۱۲۳۴۵۶۷۸۹' },
{ id: 5, name: 'حسن نوری', email: 'hassan@example.com', role: 'مدیر', status: 'فعال', createdAt: '۱۴۰۲/۰۸/۱۱', phone: '۰۹۱۲۳۴۵۶۷۸۹' },
{ id: 6, name: 'مریم صادقی', email: 'maryam@example.com', role: 'کاربر', status: 'غیرفعال', createdAt: '۱۴۰۲/۰۸/۱۰', phone: '۰۹۱۲۳۴۵۶۷۸۹' },
{ id: 7, name: 'احمد قاسمی', email: 'ahmad@example.com', role: 'کاربر', status: 'فعال', createdAt: '۱۴۰۲/۰۸/۰۹', phone: '۰۹۱۲۳۴۵۶۷۸۹' },
{ id: 8, name: 'سارا محمدی', email: 'sara@example.com', role: 'مدیر', status: 'فعال', createdAt: '۱۴۰۲/۰۸/۰۸', phone: '۰۹۱۲۳۴۵۶۷۸۹' },
{ id: 9, name: 'رضا کریمی', email: 'reza@example.com', role: 'کاربر', status: 'فعال', createdAt: '۱۴۰۲/۰۸/۰۷', phone: '۰۹۱۲۳۴۵۶۷۸۹' },
{ id: 10, name: 'نرگس احمدی', email: 'narges@example.com', role: 'کاربر', status: 'فعال', createdAt: '۱۴۰۲/۰۸/۰۶', phone: '۰۹۱۲۳۴۵۶۷۸۹' },
{ id: 11, name: 'امیر حسینی', email: 'amir@example.com', role: 'مدیر', status: 'فعال', createdAt: '۱۴۰۲/۰۸/۰۵', phone: '۰۹۱۲۳۴۵۶۷۸۹' },
{ id: 12, name: 'مینا رضایی', email: 'mina@example.com', role: 'کاربر', status: 'غیرفعال', createdAt: '۱۴۰۲/۰۸/۰۴', phone: '۰۹۱۲۳۴۵۶۷۸۹' },
];
export const Users = () => {
const [searchTerm, setSearchTerm] = useState('');
const [loading, setLoading] = useState(false);
const [showUserModal, setShowUserModal] = useState(false);
const [editingUser, setEditingUser] = useState<any>(null);
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 5;
const columns: TableColumn[] = [
{ key: 'name', label: 'نام', sortable: true },
{ key: 'email', label: 'ایمیل', sortable: true },
{ key: 'phone', label: 'تلفن' },
{ key: 'role', label: 'نقش' },
{
key: 'status',
label: 'وضعیت',
render: (value) => (
<span className={`px-2 py-1 rounded-full text-xs font-medium ${value === 'فعال'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
}`}>
{value}
</span>
)
},
{ key: 'createdAt', label: 'تاریخ عضویت', sortable: true },
{
key: 'actions',
label: 'عملیات',
render: (_, row) => (
<div className="flex space-x-2">
<Button
size="sm"
variant="secondary"
onClick={() => handleEditUser(row)}
>
ویرایش
</Button>
<PermissionWrapper permission={22}>
<Button
size="sm"
variant="danger"
onClick={() => handleDeleteUser(row.id)}
>
حذف
</Button>
</PermissionWrapper>
</div>
)
}
];
const filteredUsers = allUsers.filter((user: any) =>
user.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
user.email.toLowerCase().includes(searchTerm.toLowerCase())
);
const totalPages = Math.ceil(filteredUsers.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const paginatedUsers = filteredUsers.slice(startIndex, startIndex + itemsPerPage);
const handleAddUser = () => {
setEditingUser(null);
setShowUserModal(true);
};
const handleEditUser = (user: any) => {
setEditingUser(user);
setShowUserModal(true);
};
const handleDeleteUser = (userId: number) => {
if (confirm('آیا از حذف این کاربر اطمینان دارید؟')) {
console.log('Deleting user:', userId);
}
};
const handleSubmitUser = (data: UserFormData) => {
console.log('User data:', data);
setShowUserModal(false);
};
const handleCloseModal = () => {
setShowUserModal(false);
setEditingUser(null);
};
return (
<div className="p-6 space-y-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
مدیریت کاربران
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
{filteredUsers.length} کاربر یافت شد
</p>
</div>
<div className="flex items-center space-x-4">
<Button variant="secondary">
<Filter className="h-4 w-4 ml-2" />
فیلتر
</Button>
<PermissionWrapper permission={25}>
<Button onClick={handleAddUser}>
<Plus className="h-4 w-4 ml-2" />
افزودن کاربر
</Button>
</PermissionWrapper>
</div>
</div>
<div className="card p-6">
<div className="mb-6">
<div className="relative">
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<Search className="h-5 w-5 text-gray-400" />
</div>
<input
type="text"
placeholder="جستجو در کاربران..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="input pr-10 max-w-md"
/>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg overflow-hidden">
<Table
columns={columns}
data={paginatedUsers}
loading={loading}
/>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
itemsPerPage={itemsPerPage}
totalItems={filteredUsers.length}
/>
</div>
</div>
<Modal
isOpen={showUserModal}
onClose={handleCloseModal}
size="lg"
>
<UserForm
initialData={editingUser}
onSubmit={handleSubmitUser}
onCancel={handleCloseModal}
loading={loading}
isEdit={!!editingUser}
/>
</Modal>
</div>
);
};

252
src/pages/UsersNew.tsx Normal file
View File

@ -0,0 +1,252 @@
import { useState } from 'react';
import { Plus, Search, Filter } from 'lucide-react';
import { Table } from '../components/ui/Table';
import { Button } from '../components/ui/Button';
import { Modal } from '../components/ui/Modal';
import { Pagination } from '../components/ui/Pagination';
import { UserForm } from '../components/forms/UserForm';
import { PermissionWrapper } from '../components/common/PermissionWrapper';
import { LoadingSpinner } from '../components/ui/LoadingSpinner';
import { TableColumn } from '../types';
import { UserFormData } from '../utils/validationSchemas';
import { useUsers, useCreateUser, useUpdateUser, useDeleteUser } from '../hooks/useUsers';
import { useToast } from '../contexts/ToastContext';
import { useFilters } from '../stores/useAppStore';
const Users = () => {
const [showUserModal, setShowUserModal] = useState(false);
const [editingUser, setEditingUser] = useState<any>(null);
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 5;
const { filters, setFilters } = useFilters();
const toast = useToast();
const queryParams = {
page: currentPage,
limit: itemsPerPage,
search: filters.search,
sortBy: 'createdAt',
sortOrder: 'desc' as const,
};
const { data: usersResponse, isLoading, error } = useUsers(queryParams);
const createUserMutation = useCreateUser();
const updateUserMutation = useUpdateUser();
const deleteUserMutation = useDeleteUser();
const users = usersResponse?.data || [];
const totalItems = usersResponse?.total || 0;
const totalPages = Math.ceil(totalItems / itemsPerPage);
const columns: TableColumn[] = [
{ key: 'name', label: 'نام', sortable: true },
{ key: 'email', label: 'ایمیل', sortable: true },
{ key: 'phone', label: 'تلفن' },
{ key: 'role', label: 'نقش' },
{
key: 'status',
label: 'وضعیت',
render: (value) => (
<span className={`px-2 py-1 rounded-full text-xs font-medium ${value === 'active'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
}`}>
{value === 'active' ? 'فعال' : 'غیرفعال'}
</span>
)
},
{
key: 'createdAt',
label: 'تاریخ عضویت',
sortable: true,
render: (value) => new Date(value).toLocaleDateString('fa-IR')
},
{
key: 'actions',
label: 'عملیات',
render: (_, row) => (
<div className="flex space-x-2">
<Button
size="sm"
variant="secondary"
onClick={() => handleEditUser(row)}
disabled={updateUserMutation.isPending}
>
ویرایش
</Button>
<PermissionWrapper permission={22}>
<Button
size="sm"
variant="danger"
onClick={() => handleDeleteUser(row.id)}
disabled={deleteUserMutation.isPending}
>
حذف
</Button>
</PermissionWrapper>
</div>
)
}
];
const handleAddUser = () => {
setEditingUser(null);
setShowUserModal(true);
};
const handleEditUser = (user: any) => {
setEditingUser(user);
setShowUserModal(true);
};
const handleDeleteUser = async (userId: string) => {
if (confirm('آیا از حذف این کاربر اطمینان دارید؟')) {
try {
await deleteUserMutation.mutateAsync(userId);
} catch (error) {
console.error('Delete error:', error);
}
}
};
const handleSubmitUser = async (data: UserFormData) => {
try {
if (editingUser) {
await updateUserMutation.mutateAsync({
id: editingUser.id,
data: {
name: data.name,
email: data.email,
phone: data.phone,
role: data.role,
}
});
} else {
await createUserMutation.mutateAsync({
name: data.name,
email: data.email,
phone: data.phone,
role: data.role,
password: data.password || '123456',
});
}
setShowUserModal(false);
} catch (error) {
console.error('Submit error:', error);
}
};
const handleCloseModal = () => {
setShowUserModal(false);
setEditingUser(null);
};
const handleSearchChange = (value: string) => {
setFilters({ search: value });
setCurrentPage(1);
};
if (error) {
return (
<div className="p-6">
<div className="text-center py-12">
<p className="text-red-600 dark:text-red-400">
خطا در بارگذاری کاربران: {error.message}
</p>
<Button
onClick={() => window.location.reload()}
className="mt-4"
>
تلاش دوباره
</Button>
</div>
</div>
);
}
return (
<div className="p-6 space-y-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
مدیریت کاربران
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
{totalItems} کاربر یافت شد
</p>
</div>
<div className="flex items-center space-x-4">
<Button variant="secondary">
<Filter className="h-4 w-4 ml-2" />
فیلتر
</Button>
<PermissionWrapper permission={25}>
<Button
onClick={handleAddUser}
disabled={createUserMutation.isPending}
>
<Plus className="h-4 w-4 ml-2" />
افزودن کاربر
</Button>
</PermissionWrapper>
</div>
</div>
<div className="card p-6">
<div className="mb-6">
<div className="relative">
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<Search className="h-5 w-5 text-gray-400" />
</div>
<input
type="text"
placeholder="جستجو در کاربران..."
value={filters.search}
onChange={(e) => handleSearchChange(e.target.value)}
className="input pr-10 max-w-md"
/>
</div>
</div>
{isLoading ? (
<LoadingSpinner />
) : (
<>
<div className="bg-white dark:bg-gray-800 rounded-lg overflow-hidden">
<Table
columns={columns}
data={users}
loading={isLoading}
/>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
itemsPerPage={itemsPerPage}
totalItems={totalItems}
/>
</div>
</>
)}
</div>
<Modal
isOpen={showUserModal}
onClose={handleCloseModal}
size="lg"
>
<UserForm
initialData={editingUser}
onSubmit={handleSubmitUser}
onCancel={handleCloseModal}
loading={createUserMutation.isPending || updateUserMutation.isPending}
isEdit={!!editingUser}
/>
</Modal>
</div>
);
};
export default Users;

99
src/services/api.ts Normal file
View File

@ -0,0 +1,99 @@
import axios, { AxiosInstance, AxiosResponse, AxiosError } from "axios";
import toast from "react-hot-toast";
class ApiService {
private api: AxiosInstance;
constructor() {
this.api = axios.create({
baseURL: import.meta.env.VITE_API_URL || "http://localhost:3001/api",
timeout: 10000,
headers: {
"Content-Type": "application/json",
},
});
this.setupInterceptors();
}
private setupInterceptors() {
this.api.interceptors.request.use(
(config) => {
const token = localStorage.getItem("admin_token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
this.api.interceptors.response.use(
(response: AxiosResponse) => {
return response;
},
(error: AxiosError) => {
this.handleError(error);
return Promise.reject(error);
}
);
}
private handleError(error: AxiosError) {
if (error.response?.status === 401) {
localStorage.removeItem("admin_token");
localStorage.removeItem("admin_user");
window.location.href = "/login";
return;
}
const message = this.getErrorMessage(error);
toast.error(message);
}
private getErrorMessage(error: AxiosError): string {
if (error.response?.data) {
const data = error.response.data as any;
return data.message || data.error || "خطایی رخ داده است";
}
if (error.code === "NETWORK_ERROR") {
return "خطا در اتصال به سرور";
}
if (error.code === "TIMEOUT") {
return "درخواست منقضی شد";
}
return "خطای غیرمنتظره‌ای رخ داده است";
}
async get<T>(url: string, params?: any): Promise<T> {
const response = await this.api.get(url, { params });
return response.data;
}
async post<T>(url: string, data?: any): Promise<T> {
const response = await this.api.post(url, data);
return response.data;
}
async put<T>(url: string, data?: any): Promise<T> {
const response = await this.api.put(url, data);
return response.data;
}
async delete<T>(url: string): Promise<T> {
const response = await this.api.delete(url);
return response.data;
}
async patch<T>(url: string, data?: any): Promise<T> {
const response = await this.api.patch(url, data);
return response.data;
}
}
export const apiService = new ApiService();

107
src/services/types.ts Normal file
View File

@ -0,0 +1,107 @@
export interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
total?: number;
page?: number;
limit?: number;
}
export interface PaginationParams {
page?: number;
limit?: number;
search?: string;
sortBy?: string;
sortOrder?: "asc" | "desc";
}
export interface User {
id: string;
name: string;
email: string;
phone: string;
role: string;
status: "active" | "inactive";
permissions: number[];
createdAt: string;
updatedAt: string;
}
export interface Product {
id: string;
name: string;
category: string;
price: number;
stock: number;
status: "available" | "out_of_stock" | "low_stock";
description?: string;
image?: string;
createdAt: string;
updatedAt: string;
}
export interface Order {
id: string;
customerId: string;
customerName: string;
products: OrderProduct[];
totalAmount: number;
status: "pending" | "processing" | "shipped" | "delivered" | "cancelled";
createdAt: string;
updatedAt: string;
}
export interface OrderProduct {
productId: string;
productName: string;
quantity: number;
price: number;
}
export interface Notification {
id: string;
title: string;
message: string;
type: "info" | "success" | "warning" | "error";
priority: "low" | "medium" | "high";
isRead: boolean;
senderId?: string;
senderName?: string;
createdAt: string;
}
export interface DashboardStats {
totalUsers: number;
totalProducts: number;
totalOrders: number;
totalRevenue: number;
monthlyGrowth: number;
weeklyGrowth: number;
}
export interface LoginRequest {
email: string;
password: string;
}
export interface LoginResponse {
user: User;
token: string;
refreshToken: string;
}
export interface CreateUserRequest {
name: string;
email: string;
phone: string;
role: string;
password: string;
}
export interface UpdateUserRequest {
name?: string;
email?: string;
phone?: string;
role?: string;
status?: "active" | "inactive";
}

133
src/services/userService.ts Normal file
View File

@ -0,0 +1,133 @@
import { apiService } from "./api";
import {
ApiResponse,
User,
CreateUserRequest,
UpdateUserRequest,
PaginationParams,
} from "./types";
export const userService = {
getUsers: async (params?: PaginationParams): Promise<ApiResponse<User[]>> => {
try {
return await apiService.get<ApiResponse<User[]>>("/users", params);
} catch (error) {
return {
success: false,
data: [],
message: "خطا در دریافت کاربران",
};
}
},
getUser: async (id: string): Promise<ApiResponse<User>> => {
try {
return await apiService.get<ApiResponse<User>>(`/users/${id}`);
} catch (error) {
return {
success: false,
data: {} as User,
message: "خطا در دریافت کاربر",
};
}
},
createUser: async (
userData: CreateUserRequest
): Promise<ApiResponse<User>> => {
try {
return await apiService.post<ApiResponse<User>>("/users", userData);
} catch (error) {
return {
success: false,
data: {} as User,
message: "خطا در ایجاد کاربر",
};
}
},
updateUser: async (
id: string,
userData: UpdateUserRequest
): Promise<ApiResponse<User>> => {
try {
return await apiService.put<ApiResponse<User>>(`/users/${id}`, userData);
} catch (error) {
return {
success: false,
data: {} as User,
message: "خطا در ویرایش کاربر",
};
}
},
deleteUser: async (id: string): Promise<ApiResponse<void>> => {
try {
return await apiService.delete<ApiResponse<void>>(`/users/${id}`);
} catch (error) {
return {
success: false,
data: undefined,
message: "خطا در حذف کاربر",
};
}
},
getMockUsers: (): User[] => [
{
id: "1",
name: "علی احمدی",
email: "ali@example.com",
phone: "09123456789",
role: "user",
status: "active",
permissions: [10, 15],
createdAt: "2024-01-15T10:30:00Z",
updatedAt: "2024-01-15T10:30:00Z",
},
{
id: "2",
name: "فاطمه حسینی",
email: "fateme@example.com",
phone: "09123456789",
role: "admin",
status: "active",
permissions: [10, 15, 20, 22, 25, 30],
createdAt: "2024-01-14T10:30:00Z",
updatedAt: "2024-01-14T10:30:00Z",
},
{
id: "3",
name: "محمد رضایی",
email: "mohammad@example.com",
phone: "09123456789",
role: "user",
status: "inactive",
permissions: [10],
createdAt: "2024-01-13T10:30:00Z",
updatedAt: "2024-01-13T10:30:00Z",
},
{
id: "4",
name: "زهرا کریمی",
email: "zahra@example.com",
phone: "09123456789",
role: "user",
status: "active",
permissions: [10, 15],
createdAt: "2024-01-12T10:30:00Z",
updatedAt: "2024-01-12T10:30:00Z",
},
{
id: "5",
name: "حسن نوری",
email: "hassan@example.com",
phone: "09123456789",
role: "admin",
status: "active",
permissions: [10, 15, 20, 22, 25, 30],
createdAt: "2024-01-11T10:30:00Z",
updatedAt: "2024-01-11T10:30:00Z",
},
],
};

175
src/stores/useAppStore.ts Normal file
View File

@ -0,0 +1,175 @@
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import { User } from "../services/types";
interface LoadingState {
users: boolean;
products: boolean;
orders: boolean;
notifications: boolean;
dashboard: boolean;
}
interface AppState {
sidebarOpen: boolean;
loading: LoadingState;
selectedItems: string[];
filters: {
search: string;
status: string;
dateRange: { start: string; end: string } | null;
};
currentUser: User | null;
// Actions
setSidebarOpen: (open: boolean) => void;
setLoading: (key: keyof LoadingState, value: boolean) => void;
setSelectedItems: (items: string[]) => void;
addSelectedItem: (item: string) => void;
removeSelectedItem: (item: string) => void;
clearSelectedItems: () => void;
setFilters: (filters: Partial<AppState["filters"]>) => void;
resetFilters: () => void;
setCurrentUser: (user: User | null) => void;
// Bulk actions
setGlobalLoading: (loading: boolean) => void;
}
const initialFilters = {
search: "",
status: "",
dateRange: null,
};
const initialLoading: LoadingState = {
users: false,
products: false,
orders: false,
notifications: false,
dashboard: false,
};
export const useAppStore = create<AppState>()(
persist(
(set, get) => ({
sidebarOpen: false,
loading: initialLoading,
selectedItems: [],
filters: initialFilters,
currentUser: null,
setSidebarOpen: (open) => set({ sidebarOpen: open }),
setLoading: (key, value) =>
set((state) => ({
loading: { ...state.loading, [key]: value },
})),
setSelectedItems: (items) => set({ selectedItems: items }),
addSelectedItem: (item) =>
set((state) => ({
selectedItems: state.selectedItems.includes(item)
? state.selectedItems
: [...state.selectedItems, item],
})),
removeSelectedItem: (item) =>
set((state) => ({
selectedItems: state.selectedItems.filter((i) => i !== item),
})),
clearSelectedItems: () => set({ selectedItems: [] }),
setFilters: (newFilters) =>
set((state) => ({
filters: { ...state.filters, ...newFilters },
})),
resetFilters: () => set({ filters: initialFilters }),
setCurrentUser: (user) => set({ currentUser: user }),
setGlobalLoading: (loading) =>
set((state) => ({
loading: Object.keys(state.loading).reduce(
(acc, key) => ({ ...acc, [key]: loading }),
{} as LoadingState
),
})),
}),
{
name: "app-storage",
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
sidebarOpen: state.sidebarOpen,
filters: state.filters,
currentUser: state.currentUser,
}),
}
)
);
export const useSidebar = () => {
const { sidebarOpen, setSidebarOpen } = useAppStore();
const toggleSidebar = () => setSidebarOpen(!sidebarOpen);
const closeSidebar = () => setSidebarOpen(false);
const openSidebar = () => setSidebarOpen(true);
return {
sidebarOpen,
toggleSidebar,
closeSidebar,
openSidebar,
};
};
export const useLoading = () => {
const { loading, setLoading, setGlobalLoading } = useAppStore();
return {
loading,
setLoading,
setGlobalLoading,
isAnyLoading: Object.values(loading).some(Boolean),
};
};
export const useSelection = () => {
const {
selectedItems,
setSelectedItems,
addSelectedItem,
removeSelectedItem,
clearSelectedItems,
} = useAppStore();
const isSelected = (item: string) => selectedItems.includes(item);
const hasSelection = selectedItems.length > 0;
return {
selectedItems,
setSelectedItems,
addSelectedItem,
removeSelectedItem,
clearSelectedItems,
isSelected,
hasSelection,
};
};
export const useFilters = () => {
const { filters, setFilters, resetFilters } = useAppStore();
return {
filters,
setFilters,
resetFilters,
hasActiveFilters:
filters.search !== "" ||
filters.status !== "" ||
filters.dateRange !== null,
};
};

66
src/types/index.ts Normal file
View File

@ -0,0 +1,66 @@
export interface User {
id: string;
name: string;
email: string;
role: string;
permissions: number[];
avatar?: string;
status: "active" | "inactive";
createdAt: string;
lastLogin?: string;
}
export interface AuthState {
isAuthenticated: boolean;
user: User | null;
permissions: number[];
token: string | null;
}
export interface DashboardStats {
totalUsers: number;
totalRevenue: number;
totalOrders: number;
totalProducts: number;
monthlyGrowth: number;
weeklyGrowth: number;
}
export interface ChartData {
name: string;
value: number;
date?: string;
}
export interface TableColumn {
key: string;
label: string;
sortable?: boolean;
render?: (value: any, row: any) => any;
}
export interface TableData {
[key: string]: any;
}
export interface MenuItem {
id: string;
label: string;
icon: any;
path: string;
permission?: number;
children?: MenuItem[];
}
export interface Theme {
mode: "light" | "dark";
}
export interface Notification {
id: string;
title: string;
message: string;
type: "success" | "error" | "warning" | "info";
timestamp: string;
read: boolean;
}

View File

@ -0,0 +1,46 @@
import * as yup from "yup";
export const loginSchema = yup.object({
email: yup
.string()
.required("ایمیل الزامی است")
.email("فرمت ایمیل صحیح نیست"),
password: yup
.string()
.required("رمز عبور الزامی است")
.min(6, "رمز عبور باید حداقل ۶ کاراکتر باشد"),
});
export const userSchema = yup.object({
name: yup
.string()
.required("نام الزامی است")
.min(2, "نام باید حداقل ۲ کاراکتر باشد"),
email: yup
.string()
.required("ایمیل الزامی است")
.email("فرمت ایمیل صحیح نیست"),
phone: yup
.string()
.required("شماره تلفن الزامی است")
.matches(/^09\d{9}$/, "شماره تلفن صحیح نیست"),
role: yup.string().required("نقش الزامی است"),
password: yup
.string()
.optional()
.min(6, "رمز عبور باید حداقل ۶ کاراکتر باشد"),
});
export const settingsSchema = yup.object({
siteName: yup.string().required("نام سایت الزامی است"),
siteDescription: yup.string().required("توضیحات سایت الزامی است"),
adminEmail: yup
.string()
.required("ایمیل مدیر الزامی است")
.email("فرمت ایمیل صحیح نیست"),
language: yup.string().required("زبان الزامی است"),
});
export type LoginFormData = yup.InferType<typeof loginSchema>;
export type UserFormData = yup.InferType<typeof userSchema>;
export type SettingsFormData = yup.InferType<typeof settingsSchema>;

9
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

44
tailwind.config.js Normal file
View File

@ -0,0 +1,44 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
darkMode: "class",
theme: {
extend: {
colors: {
primary: {
50: "#eff6ff",
500: "#3b82f6",
600: "#2563eb",
700: "#1d4ed8",
},
secondary: {
50: "#f9fafb",
100: "#f3f4f6",
200: "#e5e7eb",
300: "#d1d5db",
400: "#9ca3af",
500: "#6b7280",
600: "#4b5563",
700: "#374151",
800: "#1f2937",
900: "#111827",
},
},
animation: {
"fade-in": "fadeIn 0.5s ease-in-out",
"slide-up": "slideUp 0.3s ease-out",
},
keyframes: {
fadeIn: {
"0%": { opacity: "0" },
"100%": { opacity: "1" },
},
slideUp: {
"0%": { transform: "translateY(20px)", opacity: "0" },
"100%": { transform: "translateY(0)", opacity: "1" },
},
},
},
},
plugins: [],
};

25
tsconfig.json Normal file
View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
tsconfig.node.json Normal file
View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

11
vite.config.ts Normal file
View File

@ -0,0 +1,11 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": "/src",
},
},
});