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