chore(root): initial commit with gitignore
This commit is contained in:
commit
50e1034d46
|
@ -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
|
|
@ -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 منتشر شده است.
|
|
@ -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>
|
File diff suppressed because it is too large
Load Diff
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
|
@ -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;
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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;
|
||||||
|
};
|
|
@ -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;
|
||||||
|
};
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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';
|
|
@ -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;
|
||||||
|
};
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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;
|
||||||
|
};
|
|
@ -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;
|
||||||
|
};
|
|
@ -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;
|
||||||
|
};
|
|
@ -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 || "خطا در حذف کاربر");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
|
@ -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>,
|
||||||
|
)
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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;
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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;
|
|
@ -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();
|
|
@ -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";
|
||||||
|
}
|
|
@ -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",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
|
@ -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,
|
||||||
|
};
|
||||||
|
};
|
|
@ -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;
|
||||||
|
}
|
|
@ -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>;
|
|
@ -0,0 +1,9 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_URL: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
|
@ -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: [],
|
||||||
|
};
|
|
@ -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" }]
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": "/src",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
Loading…
Reference in New Issue